From b32b3bb95b0c38e176132e944b35b3a3340ce469 Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:04:44 +0200 Subject: [PATCH 01/30] =?UTF-8?q?feat(architecture):=20v1.2.0=20=E2=80=94?= =?UTF-8?q?=20shallow=20type-inference=20for=20call=5Fparity=20receiver=20?= =?UTF-8?q?resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.1.0's call_parity check produced ~80% false positives on any realistic Rust codebase using a Session/Context/Handle pattern (93 of 116 findings on the rlm reference codebase). Root cause: the receiver-type resolver could only extract types from direct constructor calls — `let s = T::ctor()` — and collapsed to `:name` for the more common `let s = T::ctor(). map_err(f)?` and `ctx.field.method()` patterns. This release rebuilds the resolver around shallow type propagation over `syn::Expr`, delivered in three layers under `call_parity_rule/type_infer/`: Stage 1 — core: Return-type propagation (method chains, field access, Result/Option/Future combinators, pattern bindings for let/if-let/match/ for, `Self::xxx` substitution). Stdlib-combinator table covers `unwrap`, `expect`, `map_err`, `ok`, `ok_or`, `filter`, `as_ref` etc.; closure- dependent combinators (`map`, `and_then`) stay unresolved rather than fabricate an edge. Stage 2 — trait dispatch: `dyn Trait` / `&dyn Trait` / `Box` receivers fan out to every workspace impl of the trait. Sound over- approximation that makes call-parity structurally correct for Ports&Adapters architectures. Turbofish return-type (`get::()`) for generic fns. Stage 3 — framework config: Type-alias expansion, user-configurable `transparent_wrappers` (Axum `State`, Actix `Data`, …) and `transparent_macros` config. Starter-pack of 10 common attribute macros applied by default. Additive change — no public-API break. Legacy fast-path (direct ctors, signature params, explicit annotations) stays intact so all v1.1.0 unit- test fixtures keep working. Bundled bugfix in iosp::analyze_file: Item:: Impl/Trait/Mod now properly inherits file_in_test (previously only Fn did), which fixes spurious ERROR_HANDLING/MAGIC_NUMBER flags on impl helpers inside cfg-test files. --- CHANGELOG.md | 189 +++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 71 +- .../architecture/call_parity_rule/calls.rs | 257 +++++- .../architecture/call_parity_rule/mod.rs | 2 + .../call_parity_rule/tests/calls.rs | 142 ++++ .../call_parity_rule/tests/check_a.rs | 107 ++- .../call_parity_rule/tests/check_b.rs | 23 +- .../call_parity_rule/tests/mod.rs | 2 + .../call_parity_rule/tests/regressions.rs | 750 ++++++++++++++++++ .../call_parity_rule/tests/rlm_snapshot.rs | 257 ++++++ .../call_parity_rule/tests/support.rs | 69 +- .../call_parity_rule/type_infer/canonical.rs | 68 ++ .../type_infer/combinators.rs | 77 ++ .../type_infer/infer/access.rs | 84 ++ .../call_parity_rule/type_infer/infer/call.rs | 168 ++++ .../type_infer/infer/generics.rs | 47 ++ .../call_parity_rule/type_infer/infer/mod.rs | 102 +++ .../call_parity_rule/type_infer/mod.rs | 34 + .../type_infer/patterns/destructure.rs | 206 +++++ .../type_infer/patterns/iterator.rs | 34 + .../type_infer/patterns/mod.rs | 31 + .../call_parity_rule/type_infer/resolve.rs | 247 ++++++ .../type_infer/tests/canonical.rs | 66 ++ .../type_infer/tests/combinators.rs | 203 +++++ .../type_infer/tests/infer_access.rs | 226 ++++++ .../type_infer/tests/infer_call.rs | 228 ++++++ .../call_parity_rule/type_infer/tests/mod.rs | 9 + .../type_infer/tests/patterns_destructure.rs | 234 ++++++ .../type_infer/tests/patterns_iterator.rs | 66 ++ .../type_infer/tests/resolve.rs | 229 ++++++ .../type_infer/tests/support.rs | 82 ++ .../type_infer/tests/workspace_index.rs | 519 ++++++++++++ .../type_infer/workspace_index/aliases.rs | 48 ++ .../type_infer/workspace_index/fields.rs | 92 +++ .../type_infer/workspace_index/functions.rs | 71 ++ .../type_infer/workspace_index/methods.rs | 100 +++ .../type_infer/workspace_index/mod.rs | 179 +++++ .../type_infer/workspace_index/traits.rs | 132 +++ .../call_parity_rule/workspace_graph.rs | 14 + .../analyzers/architecture/compiled.rs | 37 + .../analyzers/architecture/tests/compiled.rs | 2 + src/adapters/analyzers/iosp/mod.rs | 11 +- src/adapters/config/architecture.rs | 22 + 45 files changed, 5457 insertions(+), 84 deletions(-) create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/tests/rlm_snapshot.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/canonical.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/combinators.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/access.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/call.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/generics.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/mod.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/mod.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/iterator.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/mod.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/canonical.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_access.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_call.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/mod.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_iterator.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/support.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/aliases.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 840ca7a..9db9701 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,195 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.0] - 2026-04-24 + +Minor release: **shallow type-inference** for `call_parity` receiver +resolution across three dimensions: + +1. **Return-type propagation** (method chains, field access, stdlib + Result/Option/Future combinators, destructuring patterns) — + eliminates the dominant false-positive class that made v1.1.0 + unusable on any Session/Context/Handle-pattern Rust codebase. +2. **Trait dispatch over-approximation** — `dyn Trait` / `&dyn Trait` / + `Box` receivers fan out to every workspace impl of the + trait. Makes the tool structurally sound for Ports&Adapters + architectures, where dependency inversion via trait objects is the + core abstraction. +3. **Framework & type-alias config** — type-alias expansion, + user-configurable transparent wrapper types (Axum `State`, + Actix `Data`, tower `Router`, …), and attribute-macro + transparency (with a default starter-pack for `tracing::instrument`, + `async_trait`, `tokio::main`/`test`, etc.). + +No breaking changes; existing `[architecture.call_parity]` configs +keep working without modification — the new resolution paths are all +additive and the legacy fast-path stays intact as a safety net. + +### Fixed +- **`call_parity` method-chain constructor resolution.** v1.1.0's + resolver only extracted binding types from direct constructor calls + (`let s = T::ctor()`). Real-world Rust code more often wraps the + constructor in a `?` / `.unwrap()` / `.map_err(…)?` chain, which + returned `None` from the legacy extractor and left the downstream + method call as a layer-unknown `:name`. On rlm (the reference + adopter codebase), this produced 93 of 116 false-positive findings — + roughly 80 % of the total. Symptom: every CLI handler shaped like + ```rust + pub fn cmd_diff(path: &str) -> Result<(), Error> { + let session = RlmSession::open_cwd().map_err(map_err)?; + session.diff(path).map_err(map_err)?; + Ok(()) + } + ``` + was reported as "not delegating to application" even though it + obviously did. + +### Added +- **`call_parity_rule::type_infer`** — new module implementing shallow + type inference over `syn::Expr`. Exposes `infer_type(expr, ctx) -> + Option` as the public entry point. Built on three + layers: + - `workspace_index`: single pre-pass over the workspace collecting + struct-field types, impl-method return types, and free-fn return + types into a lookup index. Runs once per `build_call_graph` call. + - `infer`: dispatch over expression variants — `Path`, `Call`, + `MethodCall`, `Field`, `Try` (`?`), `Await`, `Cast`, `Unary(Deref)`, + plus transparent `Paren` / `Reference` / `Group`. Supports + `Self::xxx` substitution in impl-method contexts. + - `combinators`: stdlib table covering `Result` / `Option` / + `Future` — `unwrap`, `expect`, `unwrap_or*`, `ok`, `err`, + `map_err`, `or_else`, `ok_or`, `filter`, `as_ref` etc. Closure- + dependent methods (`map`, `and_then`, `then`) intentionally stay + unresolved rather than fabricate an edge. +- **Pattern-binding walker** (`type_infer::patterns`) — extracts + `(name, type)` pairs from `let` / `if let` / `while let` / `let … + else` / `match`-arm / `for` patterns. Handles tuple-struct + destructuring (`Some(x)`, `Ok(x)`, `Err(_)`), named-field struct + patterns (`Ctx { session }`, `Ctx { session: s }`, `Ctx { a, .. }`), + slice patterns with rest, and disambiguates `None` as a variant + against `Option<_>` instead of binding it as a variable name. +- **Fallback wiring in `calls::CanonicalCallCollector`** — both + `visit_local` (for binding extraction) and `visit_expr_method_call` + (for method resolution) now invoke `type_infer` as a fallback after + the legacy fast-path fails. The fast path (direct constructor + extraction, signature-parameter types, explicit `let x: T = …` + annotation) is preserved for unit-test fixtures that don't build a + workspace index, so no existing tests regressed. +- **`BindingLookup` trait** bridges the legacy `Vec` scope + stack into the inference engine's `CanonicalType` vocabulary via + the `CollectorBindings` adapter. Returns owned `Option` + so adapters can synthesize types on the fly without lifetime + gymnastics. + +### Changed +- **`FnContext` in `call_parity_rule::calls`** gained a new + `workspace_index: Option<&'a WorkspaceTypeIndex>` field. The full + `build_call_graph` pipeline always passes `Some(&index)`; unit-test + fixtures pass `None` and fall back to the legacy fast-path only. + Additive change — no public-API break for existing + `collect_canonical_calls` call sites. +- **`build_call_graph`** now pre-builds the workspace type-index once + before the per-file walk. The index shares the same `cfg_test_files` + filter as the call-graph itself, so the two stay consistent. +- **`iosp::analyze_file`** — bugfix discovered during Task 1.3: + `file_in_test` was propagated only to free-fn analysis, not to + `Item::Impl` / `Item::Trait` / `Item::Mod`. This meant any impl-method + helper inside a `#[cfg(test)] mod tests;` file incorrectly had + `is_test = false` and got flagged by ERROR_HANDLING / MAGIC_NUMBER / + LONG_FN checks. Now matches `analyze_mod`'s already-correct + propagation. + +### Documentation +- **`docs/rustqual-design-receiver-type-inference.md`** — the + normative spec for the multi-stage receiver-resolution work + (v1.2.0 → v1.3.0 → v1.4.0). Contains the type-inference grammar + (§3), full stdlib-combinator table (§4), pattern-binding catalog + (§5), workspace-index schema (§6), trait-dispatch plan (§7), + config-schema additions (§8), documented Stage-1 limits (§9), and + test-matrix (§10). Every PR modifying `type_infer/` is reviewed + against this doc. + +### Added — Trait-Dispatch (Stage 2) +- **`dyn Trait` / `&dyn Trait` / `Box` receivers** fan out + to every workspace impl. `fn dispatch(h: &dyn Handler) { h.handle() }` + records one edge per `impl Handler for X` — sound over-approximation + that makes call-parity structurally correct for Ports&Adapters + architectures. Marker traits (`Send`, `Sync`, `Unpin`, `Copy`, + `Clone`, `Sized`, `Debug`, `Display`) are skipped when picking the + dispatch-relevant bound from `dyn T1 + T2`. +- **Trait-method gate**: dispatch only fires when the method is in the + trait's declared method set. `dyn Handler.unrelated_method()` still + falls through to `:name` rather than fabricating edges. +- **`trait_impls` + `trait_methods` index** built once per + `build_call_graph`. `impls_of_trait(trait)` and + `trait_has_method(trait, method)` are the public query methods. +- **Turbofish-as-return-type**: `get::()` where `get` is a + generic fn with no concrete workspace return infers `Session` from + the turbofish arg. Narrow by design — only single-ident paths + trigger, so `Vec::::new()` (turbofish on type segment) isn't + over-approximated. + +### Added — Framework & Config Layer (Stage 3) +- **Type-alias expansion.** `type Db = Arc>;` recorded + in the workspace index; `fn h(db: Db) { db.read() }` expands `Db` + → `Arc>` → `Store` (Arc/RwLock peeled by the stdlib + wrapper rules) and resolves `read` against Store's method index. +- **User-configurable transparent wrappers** via + `[architecture.call_parity]::transparent_wrappers`: + ```toml + [architecture.call_parity] + transparent_wrappers = ["State", "Extension", "Json", "Data"] + ``` + Peeled identically to `Arc`/`Box` during resolution. Unblocks + Axum/Actix-style framework-extractor patterns where + `fn h(State(db): State) { db.query() }` would otherwise stay + unresolved. +- **Attribute-macro transparency** via + `[architecture.call_parity]::transparent_macros` with a starter-pack + (`instrument`, `async_trait`, `main`, `test`, `rstest`, `test_case`, + `pyfunction`, `pymethods`, `wasm_bindgen`, `cfg_attr`) applied by + default. Current effect is config-schema groundwork + authorial + intent — the syn-based AST walk already treats attribute macros as + transparent, so listed entries compile but don't change today's + behaviour. Retained for future macro-expansion integrations that + can consult the list without a config-schema break. + +### Known Limits +Patterns that intentionally stay unresolved and produce `:name` +fallback markers rather than fabricate edges: +- `let (a, s) = make_pair(); s.m()` — tuple destructuring. Tuple + element types aren't tracked. +- `for item in xs { item.m() }` — for-loop pattern binding doesn't + flow into the method-call collector yet. `item` stays unbound. +- `match res { Ok(s) => s.m(), … }` — `match`-arm pattern bindings + aren't wired into the scope stack. Use `let` or `if let` as + workarounds. +- `Session::open().map(|r| r.m())` — closure-body argument type is + unknown. Inner method call stays `:m`. +- `fn get() -> T { … }; let x = get(); x.m()` without annotation + or turbofish. Use `let x: T = get();` or `get::()`. +- `fn make() -> impl Trait { … }; make().inherent_method()` — + `impl Trait` hides the concrete type by design. Only trait methods + are resolvable (via trait-dispatch over-approximation). +- Arbitrary proc-macros that alter the call graph without being in + `transparent_macros` config. User-annotate via + `// qual:allow(architecture)` on the enclosing fn. + +### Infrastructure +- **`tests/rlm_snapshot.rs`** — end-to-end regression snapshot with a + 3-file rlm-shape fixture (application/session, cli/handlers, + mcp/handlers). Asserts a budget of **0 Check A findings + 5 Check B + findings** (the 5 legitimate asymmetries / dead-code items). Any + drift in this count is a clear regression signal. +- **`tests/regressions.rs`** — unit-level tests covering every rlm + Gruppe-2 / Gruppe-3 pattern plus Stage-2 trait-dispatch / + turbofish cases and Stage-3 type-alias / user-wrapper cases. + Negative tests pin documented limits in place. +- **~160 new unit tests** across `type_infer/tests/` covering + `CanonicalType`, `resolve_type`, workspace-index building, inference + dispatch, pattern binding, the stdlib-combinator table, trait + collection, and type-alias collection. + ## [1.1.0] - 2026-04-24 Minor release: zero-annotation cross-adapter delegation check for diff --git a/Cargo.lock b/Cargo.lock index 4ca257e..49f9f6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -578,7 +578,7 @@ dependencies = [ [[package]] name = "rustqual" -version = "1.1.0" +version = "1.2.0" dependencies = [ "clap", "clap_complete", diff --git a/Cargo.toml b/Cargo.toml index 6f7f482..de247b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustqual" -version = "1.1.0" +version = "1.2.0" edition = "2021" description = "Comprehensive Rust code quality analyzer — seven dimensions: IOSP, Complexity, DRY, SRP, Coupling, Test Quality, Architecture" license = "MIT" diff --git a/README.md b/README.md index ac67fef..89b818b 100644 --- a/README.md +++ b/README.md @@ -828,7 +828,7 @@ Checks: `receiver_may_be`, `methods_must_be_async`, `required_supertraits_contain`, `must_be_object_safe` (conservative: flags `Self` returns and method-level generics), `forbidden_error_variant_contains`. -**5. `[architecture.call_parity]` — cross-adapter delegation drift check (v1.1).** +**5. `[architecture.call_parity]` — cross-adapter delegation drift check (v1.1, hardened in v1.2).** Detects when N peer adapters (CLI + MCP + REST + …) fall out of sync with the shared Application layer. Two rules run under one config section: @@ -854,11 +854,51 @@ exclude_targets = ["app::setup::*"] ``` Zero per-function annotation: adapter fns are enumerated automatically -from the layer globs you already have. Receiver-type tracking resolves -`let s = Session::open(); s.search();` idioms through `let` bindings, -`fn` signatures, and common wrappers (`Arc`, `Box`, `Rc`, -`&T`, `&mut T`, `Cow<'_, T>`), so Session/Service/Context-pattern -architectures aren't 100% false-positive out of the box. +from the layer globs you already have. Shallow type-inference resolves +Session/Service/Context-pattern idioms out of the box: + +- **Method-chain constructors:** `let s = Session::open().map_err(f)?; + s.diff(...)` — the inference walks through `?`, `.unwrap()`, `.expect()`, + `.map_err()`, `.or_else()`, `.unwrap_or*()` and back to the + constructor to find `Session`. +- **Field access:** `ctx.session.diff(...)` — looks up `session` in the + workspace struct-field index, then resolves `diff` on the resulting type. +- **Free-fn return types:** `make_session().unwrap().diff()` — the + free-fn's declared return type is indexed and flows through the chain. +- **Result/Option combinators:** full stdlib table for `unwrap`, + `expect`, `ok`, `err`, `map_err`, `or_else`, `ok_or`, `filter`, + `as_ref` etc. Closure-dependent combinators (`map`, `and_then`) + intentionally stay unresolved rather than fabricate an edge. +- **Wrapper stripping:** `Arc`, `Box`, `Rc`, `Cow<'_, T>`, + `RwLock`, `Mutex`, `RefCell`, `Cell`, `&T`, `&mut T` all + transparent. `Vec` / `HashMap<_, V>` preserve the element/value type. +- **`Self::xxx`** in impl-method contexts substitutes to the enclosing + type. +- **`if let Some(s) = opt`** binds `s: T` when `opt: Option`, same + for `Ok(x)` / `Err(e)` patterns. +- **Trait dispatch** (`dyn Trait` / `&dyn Trait` / `Box` + receivers): fans out to every workspace impl of the trait. Method + must be declared on the trait — unrelated methods stay unresolved + rather than fabricating edges. Marker traits (`Send`, `Sync`, …) + skipped when picking the dispatch-relevant bound. +- **Turbofish return types**: `get::()` for generic fns — the + turbofish arg is used as the return type when the workspace index has + no concrete return for `get`. Only single-ident paths trigger. +- **Type aliases**: `type Db = Arc>;` is recorded and + expanded during receiver resolution, so `fn h(db: Db) { db.read() }` + reaches `Store::read`. + +For framework codebases you can extend the wrapper and macro lists: + +```toml +[architecture.call_parity] +# Framework extractor wrappers peeled like Arc / Box: +transparent_wrappers = ["State", "Extension", "Json", "Data"] +# Attribute macros that don't affect the call graph (starter pack +# — tracing::instrument, async_trait, tokio::main/test, rstest, +# test_case, pyo3, wasm_bindgen — already applied by default): +transparent_macros = ["my_custom_attr"] +``` Two escape mechanisms: - `exclude_targets` — glob list in config for whole groups of @@ -867,9 +907,22 @@ Two escape mechanisms: exceptions. Counts against `max_suppression_ratio`. See `examples/architecture/call_parity/` for a runnable 3-adapter -fixture. Known MVP limitation: factory-helper return types aren't -inferred — `let s = helpers::open()?;` stays `:search`. -Workaround: explicit `let s: Session = helpers::open()?;`. +fixture. + +Known limits (documented, with clear workarounds): +- **Tuple destructuring** `let (a, s) = setup(); s.m()` — `s` stays + `:m`. Workaround: separate `let`s. +- **`for` / `match` pattern bindings** — `for item in xs { item.m() }` + and `match res { Ok(s) => s.m(), … }` don't flow pattern bindings + into the method-call scope. Workaround: extract into `let` first. +- **Unannotated generics** `let x = get(); x.m()` where `get() -> T` + — use turbofish `get::()` or `let x: T = get();`. +- **`impl Trait`-returned inherent methods** — only trait methods + resolve (via trait-dispatch over-approximation). +- **Arbitrary proc-macros** not listed in `transparent_macros` — + `// qual:allow(architecture)` on the enclosing fn is the escape. + +Design reference: `docs/rustqual-design-receiver-type-inference.md`. **`--explain `** diagnostic mode prints the file's layer assignment, classified imports, and rule hits — useful for understanding why a rule diff --git a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs index b0bdb57..8a8484c 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs @@ -17,6 +17,10 @@ //! the binding scan patterns. use super::bindings::{canonical_from_type, extract_let_binding, normalize_alias_expansion}; +use super::type_infer::resolve::{resolve_type, ResolveContext}; +use super::type_infer::{ + infer_type, BindingLookup, CanonicalType, InferContext, WorkspaceTypeIndex, +}; use crate::adapters::analyzers::architecture::forbidden_rule::{ file_to_module_segments, resolve_to_crate_absolute, }; @@ -59,6 +63,11 @@ pub struct FnContext<'a> { /// File path of the fn under analysis. Used to resolve /// `crate::` / `self::` / `super::` prefixes and `Self::…`. pub importing_file: &'a str, + /// Workspace type-index for shallow inference fallback. `None` means + /// the collector falls back to `:name` for complex receivers + /// (typical in unit-test fixtures that don't build the full graph). + /// The full `build_call_graph` pipeline always passes `Some(&index)`. + pub workspace_index: Option<&'a WorkspaceTypeIndex>, } // qual:api @@ -88,7 +97,17 @@ struct CanonicalCallCollector<'a> { /// Inner-most scope is at the end; lookup walks from back to front. /// Always non-empty while a collection is in flight. bindings: Vec>>, + /// Flat signature-param scope for `dyn Trait` / `&dyn Trait` / + /// `Box` parameters — Stage 2 trait-dispatch receivers. + /// Kept separate because `bindings` stores concrete `Vec` + /// paths; trait-bound segments are semantically different (they + /// trigger fan-out to every impl instead of a single edge). + trait_bindings: HashMap>, calls: HashSet, + /// Workspace type-index for shallow inference fallback. Mirrored + /// from `FnContext` so the visitor doesn't need the full context + /// passed through every method. + workspace_index: Option<&'a WorkspaceTypeIndex>, } impl<'a> CanonicalCallCollector<'a> { @@ -113,13 +132,23 @@ impl<'a> CanonicalCallCollector<'a> { self_type_canonical, signature_params: ctx.signature_params.clone(), bindings: vec![HashMap::new()], + trait_bindings: HashMap::new(), calls: HashSet::new(), + workspace_index: ctx.workspace_index, } } fn seed_signature_bindings(&mut self) { let params = self.signature_params.clone(); for (name, ty) in ¶ms { + // When workspace_index is available, use the full resolver: + // it handles Stage-3 type-alias expansion, Stage-2 dyn Trait, + // stdlib wrappers, and plain Path in one pass. + if self.workspace_index.is_some() { + self.seed_param_via_resolver(name, ty); + continue; + } + // Legacy fast-path for unit-test fixtures without an index. if let Some(canonical) = canonical_from_type( ty, self.alias_map, @@ -132,6 +161,32 @@ impl<'a> CanonicalCallCollector<'a> { } } + /// Install a signature-param binding using the full `resolve_type` + /// pipeline. `Path` variants go into the legacy `Vec` scope; + /// `TraitBound` variants go into the separate `trait_bindings` map + /// so trait-dispatch fires at the method-call site. Other variants + /// (Opaque, Slice, Map, stdlib wrappers) don't install a binding. + /// Operation: resolve + classify. + fn seed_param_via_resolver(&mut self, name: &str, ty: &syn::Type) { + let rctx = ResolveContext { + alias_map: self.alias_map, + local_symbols: self.local_symbols, + crate_root_modules: self.crate_root_modules, + importing_file: self.importing_file, + type_aliases: self.workspace_index.map(|w| &w.type_aliases), + transparent_wrappers: self.workspace_index.map(|w| &w.transparent_wrappers), + }; + match resolve_type(ty, &rctx) { + CanonicalType::Path(segs) => { + self.bindings[0].insert(name.to_string(), segs); + } + CanonicalType::TraitBound(segs) => { + self.trait_bindings.insert(name.to_string(), segs); + } + _ => {} + } + } + fn enter_scope(&mut self) { self.bindings.push(HashMap::new()); } @@ -228,6 +283,96 @@ impl<'a> CanonicalCallCollector<'a> { self.calls.insert(target); } + /// Resolve a method call's receiver to the canonical call-graph + /// targets. Fast-path returns a single element; trait-dispatch + /// inference may return multiple (one per impl of the trait). + /// Empty vec means unresolved — caller records `:name`. + /// Integration: fast-path first, inference fallback second. + fn resolve_method_targets(&self, receiver: &syn::Expr, method_name: &str) -> Vec { + if let Some(c) = self.try_fast_path_receiver(receiver, method_name) { + return vec![c]; + } + self.try_inferred_targets(receiver, method_name) + } + + /// Fast-path: receiver is a bare ident with a concrete binding in + /// the legacy scope. Operation. + fn try_fast_path_receiver(&self, receiver: &syn::Expr, method_name: &str) -> Option { + let syn::Expr::Path(p) = receiver else { + return None; + }; + if p.path.segments.len() != 1 { + return None; + } + let ident = p.path.segments[0].ident.to_string(); + let binding = self.resolve_binding(&ident)?; + let mut full = binding.clone(); + full.push(method_name.to_string()); + Some(full.join("::")) + } + + /// Inference fallback: run shallow type inference over the receiver + /// expression, then project the result into one or more canonical + /// call-graph targets. Returns `Vec::new()` when the workspace index + /// isn't present, inference fails, or the inferred type isn't + /// resolvable to a concrete edge. Operation. + fn try_inferred_targets(&self, receiver: &syn::Expr, method_name: &str) -> Vec { + let Some(workspace) = self.workspace_index else { + return Vec::new(); + }; + let Some(inferred) = self.infer_receiver_type(receiver) else { + return Vec::new(); + }; + canonical_edges_for_method(&inferred, method_name, workspace) + } + + /// Run `infer_type` over `receiver` with the current collector + /// state. Returns the raw `CanonicalType` so `try_inferred_targets` + /// can project it to 0/1/N edges. Operation: adapter build + + /// delegate. + fn infer_receiver_type(&self, expr: &syn::Expr) -> Option { + let adapter = CollectorBindings { + scope: &self.bindings, + trait_scope: &self.trait_bindings, + }; + let ctx = InferContext { + workspace: self.workspace_index?, + alias_map: self.alias_map, + local_symbols: self.local_symbols, + crate_root_modules: self.crate_root_modules, + importing_file: self.importing_file, + bindings: &adapter, + self_type: self.self_type_canonical.clone(), + }; + infer_type(expr, &ctx) + } + + /// Shallow-inference fallback: run `type_infer::infer_type` on an + /// expression, returning the canonical-path segments iff the result + /// is a concrete `Path`. `Result`/`Option`/`Future`/`Slice`/`Map`/ + /// `TraitBound`/`Opaque` collapse to `None` — the legacy scope only + /// stores concrete type-path bindings. Trait-bound bindings would + /// need multi-target dispatch at the method-call site instead. + /// Operation: delegate to `infer_receiver_type` + variant probe. + fn try_infer_receiver(&self, expr: &syn::Expr) -> Option> { + match self.infer_receiver_type(expr)? { + CanonicalType::Path(segs) => Some(segs), + _ => None, + } + } + + /// Extract a `(name, canonical_segments)` pair from a `let` + /// statement via shallow inference on its initializer. Only simple + /// `Pat::Ident` patterns are handled here; complex destructuring + /// (`let Some(x) = opt`) is out of scope for Task 1.6 MVP. + /// Operation: delegate to `try_infer_receiver` + pattern probe. + fn infer_let_binding(&self, local: &syn::Local) -> Option<(String, Vec)> { + let init = local.init.as_ref()?; + let name = extract_pat_ident_name(&local.pat)?; + let segs = self.try_infer_receiver(&init.expr)?; + Some((name, segs)) + } + fn collect_macro_body(&mut self, mac: &syn::Macro) { for expr in parse_macro_tokens(mac.tokens.clone()) { self.visit_expr(&expr); @@ -274,6 +419,87 @@ fn parse_macro_tokens(tokens: proc_macro2::TokenStream) -> Vec { Vec::new() } +/// Project an inferred receiver type to the canonical call-graph +/// edge(s) for a method call. `Path` yields one edge. `TraitBound` +/// (Stage 2) yields one edge per workspace impl of the trait, +/// provided the method is declared on the trait — the over-approximation +/// that makes call-parity sound for Ports&Adapters architectures. +/// Wrapper variants (`Result`/`Option`/…) yield no direct edge — the +/// combinator table already unwrapped them in the method-return lookup. +/// Operation: variant dispatch. +fn canonical_edges_for_method( + ty: &CanonicalType, + method: &str, + workspace: &WorkspaceTypeIndex, +) -> Vec { + match ty { + CanonicalType::Path(segs) => { + let mut full = segs.clone(); + full.push(method.to_string()); + vec![full.join("::")] + } + CanonicalType::TraitBound(segs) => trait_dispatch_edges(segs, method, workspace), + _ => Vec::new(), + } +} + +/// Enumerate one edge per workspace impl of the trait. Filters on +/// `trait_has_method` so `dyn Trait.unrelated_method()` still falls +/// through to `:name`. Operation: index lookup + map. +fn trait_dispatch_edges( + trait_segs: &[String], + method: &str, + workspace: &WorkspaceTypeIndex, +) -> Vec { + let trait_canonical = trait_segs.join("::"); + if !workspace.trait_has_method(&trait_canonical, method) { + return Vec::new(); + } + workspace + .impls_of_trait(&trait_canonical) + .iter() + .map(|impl_type| format!("{impl_type}::{method}")) + .collect() +} + +/// Adapter that exposes the collector's `Vec>>` +/// scope stack as a `BindingLookup` for the inference engine. Bindings +/// in the old scope are always concrete type paths, so we wrap each as +/// `CanonicalType::Path(segs)`. Stdlib-wrapper bindings (`Option`, +/// `Result`) are never stored in the old scope — they're either +/// unwrapped via `?` before `let` binds them, or simply not populated +/// by the legacy `extract_let_binding`. +struct CollectorBindings<'a> { + scope: &'a [HashMap>], + trait_scope: &'a HashMap>, +} + +impl BindingLookup for CollectorBindings<'_> { + fn lookup(&self, ident: &str) -> Option { + for frame in self.scope.iter().rev() { + if let Some(segs) = frame.get(ident) { + return Some(CanonicalType::Path(segs.clone())); + } + } + self.trait_scope + .get(ident) + .map(|segs| CanonicalType::TraitBound(segs.clone())) + } +} + +/// Peel `Pat::Type` wrappers to reach a `Pat::Ident` and return its +/// identifier. Returns `None` for destructuring / tuple / struct +/// patterns — those flow through `patterns::extract_bindings` in a +/// future Task 1.6 extension. Operation: recursive pattern peel. +// qual:recursive +fn extract_pat_ident_name(pat: &syn::Pat) -> Option { + match pat { + syn::Pat::Ident(pi) => Some(pi.ident.to_string()), + syn::Pat::Type(pt) => extract_pat_ident_name(&pt.pat), + _ => None, + } +} + /// Prefix an unresolved single-ident or segment path with the layer-unknown /// `:` marker. Centralised so the BP-010 format-repetition detector /// sees exactly one format string, and so the marker can evolve together. @@ -308,6 +534,9 @@ impl<'a, 'ast> Visit<'ast> for CanonicalCallCollector<'a> { self.visit_expr(else_expr); } } + // Fast-path: direct `let s = T::ctor()` / `let s: T = …` — + // the legacy prefix-based extractor resolves without needing a + // populated workspace index, so unit-test fixtures work. if let Some((name, ty_canonical)) = extract_let_binding( local, self.alias_map, @@ -316,6 +545,13 @@ impl<'a, 'ast> Visit<'ast> for CanonicalCallCollector<'a> { self.importing_file, ) { self.current_scope_mut().insert(name, ty_canonical); + return; + } + // Inference fallback: method-chained ctors (`T::ctor().map_err()?`), + // free-fn returns (`make_session()`), builder patterns, etc. + // Requires workspace_index; silently skips when absent. + if let Some((name, segs)) = self.infer_let_binding(local) { + self.current_scope_mut().insert(name, segs); } } @@ -344,21 +580,14 @@ impl<'a, 'ast> Visit<'ast> for CanonicalCallCollector<'a> { self.visit_expr(arg); } let method_name = call.method.to_string(); - let canonical = match call.receiver.as_ref() { - syn::Expr::Path(p) if p.path.segments.len() == 1 => { - let ident = p.path.segments[0].ident.to_string(); - match self.resolve_binding(&ident) { - Some(binding) => { - let mut full = binding.clone(); - full.push(method_name.clone()); - full.join("::") - } - None => method_unknown(&method_name), - } + let targets = self.resolve_method_targets(&call.receiver, &method_name); + if targets.is_empty() { + self.record_call(method_unknown(&method_name)); + } else { + for t in targets { + self.record_call(t); } - _ => method_unknown(&method_name), - }; - self.record_call(canonical); + } } fn visit_macro(&mut self, mac: &'ast syn::Macro) { diff --git a/src/adapters/analyzers/architecture/call_parity_rule/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/mod.rs index 9b4ca86..0200bd3 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/mod.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/mod.rs @@ -18,6 +18,7 @@ pub mod calls; pub mod check_a; pub mod check_b; pub mod pub_fns; +pub mod type_infer; pub mod workspace_graph; use crate::adapters::analyzers::architecture::compiled::CompiledArchitecture; @@ -64,6 +65,7 @@ pub fn collect_findings( &aliases_per_file, &cfg_test_files, &compiled.layers, + &cp.transparent_wrappers, ); let mut out = Vec::new(); for hit in check_a::check_no_delegation(&pub_fns, &graph, &compiled.layers, cp) { diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/calls.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/calls.rs index c9460b0..e166e80 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/calls.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/calls.rs @@ -124,6 +124,7 @@ fn ctx_for_fn<'a>(fctx: &'a FileCtx, fn_name: &str, importing_file: &'a str) -> local_symbols: &fctx.local_symbols, crate_root_modules: &fctx.crate_root_modules, importing_file, + workspace_index: None, } } @@ -313,6 +314,7 @@ fn test_collect_self_dispatch_in_impl() { local_symbols: &fctx.local_symbols, crate_root_modules: &fctx.crate_root_modules, importing_file: "src/application/session.rs", + workspace_index: None, }; let calls = collect_canonical_calls(&ctx); assert!( @@ -767,6 +769,7 @@ fn test_qualified_impl_path_does_not_double_crate() { local_symbols: &fctx.local_symbols, crate_root_modules: &fctx.crate_root_modules, importing_file: "src/other_file.rs", + workspace_index: None, }; let calls = collect_canonical_calls(&ctx); assert!( @@ -778,3 +781,142 @@ fn test_qualified_impl_path_does_not_double_crate() { "must not double-crate, got {calls:?}" ); } + +// ── Shallow-inference fallback (Task 1.6) ──────────────────────── + +/// Helper: build a `FnContext` with a pre-populated workspace index. +fn ctx_with_index<'a>( + fctx: &'a FileCtx, + fn_name: &str, + importing_file: &'a str, + index: &'a crate::adapters::analyzers::architecture::call_parity_rule::type_infer::WorkspaceTypeIndex, +) -> FnContext<'a> { + let f = find_fn(&fctx.file, fn_name); + FnContext { + body: &f.block, + signature_params: sig_params(&f.sig), + self_type: None, + alias_map: &fctx.alias_map, + local_symbols: &fctx.local_symbols, + crate_root_modules: &fctx.crate_root_modules, + importing_file, + workspace_index: Some(index), + } +} + +#[test] +fn test_inference_fallback_resolves_rlm_pattern() { + // The exact pattern that motivated Task 1.6: method chain on a + // constructor + `?` unwrap. Legacy extract_let_binding can't see + // through the MethodCall; inference walks the chain and ends at T. + use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::{ + CanonicalType, WorkspaceTypeIndex, + }; + let fctx = load( + r#" + use crate::app::session::Session; + pub fn cmd_diff() { + let session = Session::open().map_err(handle_err).unwrap(); + session.diff(); + } + "#, + ); + let mut index = WorkspaceTypeIndex::new(); + // `Session::open()` returns Result. + index.method_returns.insert( + ( + "crate::app::session::Session".to_string(), + "open".to_string(), + ), + CanonicalType::Result(Box::new(CanonicalType::path([ + "crate", "app", "session", "Session", + ]))), + ); + let ctx = ctx_with_index(&fctx, "cmd_diff", "src/cli/handlers.rs", &index); + let calls = collect_canonical_calls(&ctx); + assert!( + calls.contains("crate::app::session::Session::diff"), + "inference fallback should resolve session.diff(), got {calls:?}" + ); +} + +#[test] +fn test_inference_fallback_resolves_field_access() { + // `ctx.session.diff()` — receiver is Expr::Field, resolved via the + // inference layer + workspace struct-field index. Fixture uses + // `use crate::app::Ctx` so the signature-param `&Ctx` canonicalises + // to `crate::app::Ctx` directly. + use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::{ + CanonicalType, WorkspaceTypeIndex, + }; + let fctx = load( + r#" + use crate::app::Ctx; + pub fn handle_diff(ctx: &Ctx) { + ctx.session.diff(); + } + "#, + ); + let mut index = WorkspaceTypeIndex::new(); + index.struct_fields.insert( + ("crate::app::Ctx".to_string(), "session".to_string()), + CanonicalType::path(["crate", "app", "Session"]), + ); + let ctx = ctx_with_index(&fctx, "handle_diff", "src/cli/handlers.rs", &index); + let calls = collect_canonical_calls(&ctx); + assert!( + calls.contains("crate::app::Session::diff"), + "field-access inference should resolve ctx.session.diff(), got {calls:?}" + ); +} + +#[test] +fn test_inference_fallback_on_result_unwrap_chain() { + // End-to-end: `session.open().unwrap().diff()` — combinator table + // unwraps Result, then method resolution on Session proceeds. + use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::{ + CanonicalType, WorkspaceTypeIndex, + }; + let fctx = load( + r#" + use crate::app::session::Session; + pub fn cmd_direct() { + Session::open().unwrap().diff(); + } + "#, + ); + let mut index = WorkspaceTypeIndex::new(); + index.method_returns.insert( + ( + "crate::app::session::Session".to_string(), + "open".to_string(), + ), + CanonicalType::Result(Box::new(CanonicalType::path([ + "crate", "app", "session", "Session", + ]))), + ); + let ctx = ctx_with_index(&fctx, "cmd_direct", "src/cli/handlers.rs", &index); + let calls = collect_canonical_calls(&ctx); + assert!( + calls.contains("crate::app::session::Session::diff"), + "combinator chain should resolve Session::open().unwrap().diff(), got {calls:?}" + ); +} + +#[test] +fn test_existing_fast_path_still_works_without_index() { + // Regression guard: legacy extract_let_binding keeps working when + // workspace_index is None (unit-test fixture shape). + let fctx = load( + r#" + use crate::app::session::RlmSession; + pub fn cmd_search(q: u32) { + let s = RlmSession::open_cwd(); + s.search(q); + } + "#, + ); + let ctx = ctx_for_fn(&fctx, "cmd_search", "src/cli/handlers.rs"); + let calls = collect_canonical_calls(&ctx); + assert!(calls.contains("crate::app::session::RlmSession::search")); +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/check_a.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/check_a.rs index 9c3024e..d2e8aff 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/check_a.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/check_a.rs @@ -9,15 +9,11 @@ //! existing `mark_architecture_suppressions` pipeline and doesn't need //! a separate unit test here. -use super::support::{borrowed_files, build_workspace, globset, Workspace}; -use crate::adapters::analyzers::architecture::call_parity_rule::check_a::check_no_delegation; -use crate::adapters::analyzers::architecture::call_parity_rule::pub_fns::collect_pub_fns_by_layer; -use crate::adapters::analyzers::architecture::call_parity_rule::workspace_graph::build_call_graph; +use super::support::{build_workspace, empty_cfg_test, globset, run_check_a, Workspace}; use crate::adapters::analyzers::architecture::compiled::CompiledCallParity; use crate::adapters::analyzers::architecture::layer_rule::LayerDefinitions; use crate::adapters::analyzers::architecture::{MatchLocation, ViolationKind}; use globset::GlobSet; -use std::collections::HashSet; /// Three-layer test setup: application + cli + mcp. fn three_layer() -> LayerDefinitions { @@ -41,21 +37,11 @@ fn call_parity_config(call_depth: usize) -> CompiledCallParity { target: "application".to_string(), call_depth, exclude_targets: GlobSet::empty(), + transparent_wrappers: std::collections::HashSet::new(), + transparent_macros: std::collections::HashSet::new(), } } -fn run_check_a( - ws: &Workspace, - layers: &LayerDefinitions, - cp: &CompiledCallParity, -) -> Vec { - let borrowed = borrowed_files(ws); - let cfg_test = HashSet::new(); - let pub_fns = collect_pub_fns_by_layer(&borrowed, &ws.aliases_per_file, layers, &cfg_test); - let graph = build_call_graph(&borrowed, &ws.aliases_per_file, &cfg_test, layers); - check_no_delegation(&pub_fns, &graph, layers, cp) -} - fn assert_no_delegation_fn_names(findings: &[MatchLocation]) -> Vec { findings .iter() @@ -84,7 +70,7 @@ fn test_adapter_fn_direct_delegation_passes() { ]); let layers = three_layer(); let cp = call_parity_config(3); - let findings = run_check_a(&ws, &layers, &cp); + let findings = run_check_a(&ws, &layers, &cp, &empty_cfg_test()); assert!( assert_no_delegation_fn_names(&findings).is_empty(), "direct delegation should pass, got {findings:?}" @@ -104,7 +90,12 @@ fn test_adapter_fn_inline_impl_fails() { "#, ), ]); - let findings = run_check_a(&ws, &three_layer(), &call_parity_config(3)); + let findings = run_check_a( + &ws, + &three_layer(), + &call_parity_config(3), + &empty_cfg_test(), + ); let names = assert_no_delegation_fn_names(&findings); assert!( names.contains(&"cmd_stats".to_string()), @@ -135,7 +126,12 @@ fn test_adapter_fn_transitive_delegation_via_helper_passes() { "#, ), ]); - let findings = run_check_a(&ws, &three_layer(), &call_parity_config(3)); + let findings = run_check_a( + &ws, + &three_layer(), + &call_parity_config(3), + &empty_cfg_test(), + ); let names = assert_no_delegation_fn_names(&findings); assert!( !names.contains(&"cmd_stats".to_string()), @@ -167,7 +163,12 @@ fn test_adapter_fn_transitive_depth_exceeds_limit_fails() { "#, ), ]); - let findings = run_check_a(&ws, &three_layer(), &call_parity_config(3)); + let findings = run_check_a( + &ws, + &three_layer(), + &call_parity_config(3), + &empty_cfg_test(), + ); let names = assert_no_delegation_fn_names(&findings); assert!( names.contains(&"cmd_stats".to_string()), @@ -196,7 +197,12 @@ fn test_call_depth_1_only_direct_calls() { "#, ), ]); - let findings = run_check_a(&ws, &three_layer(), &call_parity_config(1)); + let findings = run_check_a( + &ws, + &three_layer(), + &call_parity_config(1), + &empty_cfg_test(), + ); let names = assert_no_delegation_fn_names(&findings); assert!( names.contains(&"cmd_stats".to_string()), @@ -219,7 +225,12 @@ fn test_adapter_fn_method_call_does_not_count() { "#, ), ]); - let findings = run_check_a(&ws, &three_layer(), &call_parity_config(3)); + let findings = run_check_a( + &ws, + &three_layer(), + &call_parity_config(3), + &empty_cfg_test(), + ); let names = assert_no_delegation_fn_names(&findings); assert!( names.contains(&"cmd_stats".to_string()), @@ -247,7 +258,12 @@ fn test_adapter_fn_cross_adapter_call_does_not_count() { "#, ), ]); - let findings = run_check_a(&ws, &three_layer(), &call_parity_config(1)); + let findings = run_check_a( + &ws, + &three_layer(), + &call_parity_config(1), + &empty_cfg_test(), + ); // At depth 1, cmd_stats only reaches handle_stats (in mcp, not app). → fail. let names = assert_no_delegation_fn_names(&findings); assert!( @@ -278,7 +294,12 @@ fn test_adapter_fn_cross_adapter_call_counted_at_deeper_depth() { "#, ), ]); - let findings = run_check_a(&ws, &three_layer(), &call_parity_config(2)); + let findings = run_check_a( + &ws, + &three_layer(), + &call_parity_config(2), + &empty_cfg_test(), + ); let names = assert_no_delegation_fn_names(&findings); assert!( !names.contains(&"cmd_stats".to_string()), @@ -299,13 +320,9 @@ fn test_adapter_fn_cfg_test_file_skipped() { "#, ), ]); - let borrowed = borrowed_files(&ws); - let mut cfg_test = HashSet::new(); + let mut cfg_test = std::collections::HashSet::new(); cfg_test.insert("src/cli/handlers.rs".to_string()); - let layers = three_layer(); - let pub_fns = collect_pub_fns_by_layer(&borrowed, &ws.aliases_per_file, &layers, &cfg_test); - let graph = build_call_graph(&borrowed, &ws.aliases_per_file, &cfg_test, &layers); - let findings = check_no_delegation(&pub_fns, &graph, &layers, &call_parity_config(3)); + let findings = run_check_a(&ws, &three_layer(), &call_parity_config(3), &cfg_test); assert!( findings.is_empty(), "cfg-test adapter file must not produce findings, got {findings:?}" @@ -326,7 +343,12 @@ fn test_adapter_fn_not_in_any_adapter_layer_ignored() { "#, ), ]); - let findings = run_check_a(&ws, &three_layer(), &call_parity_config(3)); + let findings = run_check_a( + &ws, + &three_layer(), + &call_parity_config(3), + &empty_cfg_test(), + ); assert!( findings.is_empty(), "non-adapter-layer fn must not be checked" @@ -340,7 +362,12 @@ fn test_finding_line_is_fn_sig_line() { ("src/application/stats.rs", "pub fn get_stats() {}"), ("src/cli/handlers.rs", src), ]); - let findings = run_check_a(&ws, &three_layer(), &call_parity_config(3)); + let findings = run_check_a( + &ws, + &three_layer(), + &call_parity_config(3), + &empty_cfg_test(), + ); let finding = findings .iter() .find(|f| matches!(f.kind, ViolationKind::CallParityNoDelegation { .. })) @@ -378,7 +405,12 @@ fn test_unparseable_impl_self_type_does_not_collapse_with_free_fns() { "#, ), ]); - let findings = run_check_a(&ws, &three_layer(), &call_parity_config(3)); + let findings = run_check_a( + &ws, + &three_layer(), + &call_parity_config(3), + &empty_cfg_test(), + ); let names = assert_no_delegation_fn_names(&findings); assert!( !names.contains(&"cmd_x".to_string()), @@ -420,7 +452,12 @@ fn test_convergent_graph_does_not_double_enqueue() { "#, ), ]); - let findings = run_check_a(&ws, &three_layer(), &call_parity_config(3)); + let findings = run_check_a( + &ws, + &three_layer(), + &call_parity_config(3), + &empty_cfg_test(), + ); let names = assert_no_delegation_fn_names(&findings); assert!( !names.contains(&"cmd_x".to_string()), diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/check_b.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/check_b.rs index 8e22751..882c25b 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/check_b.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/check_b.rs @@ -4,10 +4,7 @@ //! `missing_adapters` set produced by `check_missing_adapter` for each //! target-layer pub-fn. Suppression is covered end-to-end in Task 5. -use super::support::{borrowed_files, build_workspace, globset, Workspace}; -use crate::adapters::analyzers::architecture::call_parity_rule::check_b::check_missing_adapter; -use crate::adapters::analyzers::architecture::call_parity_rule::pub_fns::collect_pub_fns_by_layer; -use crate::adapters::analyzers::architecture::call_parity_rule::workspace_graph::build_call_graph; +use super::support::{build_workspace, empty_cfg_test, globset, run_check_b, Workspace}; use crate::adapters::analyzers::architecture::compiled::CompiledCallParity; use crate::adapters::analyzers::architecture::layer_rule::LayerDefinitions; use crate::adapters::analyzers::architecture::{MatchLocation, ViolationKind}; @@ -40,21 +37,11 @@ fn make_config( target: "application".to_string(), call_depth, exclude_targets: globset(exclude_targets), + transparent_wrappers: HashSet::new(), + transparent_macros: HashSet::new(), } } -fn run_check_b( - ws: &Workspace, - layers: &LayerDefinitions, - cp: &CompiledCallParity, - cfg_test: &HashSet, -) -> Vec { - let borrowed = borrowed_files(ws); - let pub_fns = collect_pub_fns_by_layer(&borrowed, &ws.aliases_per_file, layers, cfg_test); - let graph = build_call_graph(&borrowed, &ws.aliases_per_file, cfg_test, layers); - check_missing_adapter(&pub_fns, &graph, layers, cp) -} - /// Extract the `(target_fn, missing_adapters)` pair from a /// CallParityMissingAdapter finding, as `String` for easy assertions. fn missing_pairs(findings: &[MatchLocation]) -> Vec<(String, Vec)> { @@ -71,10 +58,6 @@ fn missing_pairs(findings: &[MatchLocation]) -> Vec<(String, Vec)> { .collect() } -fn empty_cfg_test() -> HashSet { - HashSet::new() -} - // ── Direct / transitive coverage ────────────────────────────── #[test] diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/mod.rs index 384696b..b44e44e 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/mod.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/mod.rs @@ -2,4 +2,6 @@ mod calls; mod check_a; mod check_b; mod pub_fns; +mod regressions; +mod rlm_snapshot; mod support; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs new file mode 100644 index 0000000..2d63e61 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs @@ -0,0 +1,750 @@ +//! Regression harness for the Task 1.6 call-parity inference wiring. +//! +//! Each test sets up a workspace type-index resembling rlm's layout +//! (`Session` type with `open()`/`diff()`/… methods, `Ctx` with a +//! `session` field) and runs a minimal fn body through +//! `collect_canonical_calls`. Positive tests assert the expected +//! `crate::…::Type::method` edge appears in the output; negative tests +//! assert that documented limits correctly fall back to `:name` +//! instead of producing a spurious edge. +//! +//! Coverage targets the rlm classification published in the Task 1.6 +//! brief: Gruppe-2 (method-chain ctors) and Gruppe-3 (cascading struct +//! field access), plus the fast-path patterns that must stay green. + +use crate::adapters::analyzers::architecture::call_parity_rule::calls::{ + collect_canonical_calls, FnContext, +}; +use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::{ + CanonicalType, WorkspaceTypeIndex, +}; +use crate::adapters::analyzers::architecture::call_parity_rule::workspace_graph::collect_local_symbols; +use crate::adapters::shared::use_tree::gather_alias_map; +use std::collections::{HashMap, HashSet}; + +const SESSION_PATH: &str = "crate::app::session::Session"; +const CTX_PATH: &str = "crate::app::Ctx"; + +/// RegFixture bundling a parsed file plus the resolution inputs +/// (`alias_map`, `local_symbols`, `crate_root_modules`) that +/// `collect_canonical_calls` expects. +struct RegFixture { + file: syn::File, + alias_map: HashMap>, + local_symbols: HashSet, + crate_roots: HashSet, +} + +fn parse(src: &str) -> RegFixture { + let file: syn::File = syn::parse_str(src).expect("parse fixture"); + let alias_map = gather_alias_map(&file); + let local_symbols = collect_local_symbols(&file); + RegFixture { + file, + alias_map, + local_symbols, + crate_roots: HashSet::new(), + } +} + +/// Pre-populated workspace index modelling rlm's Session + Ctx shape. +fn rlm_index() -> WorkspaceTypeIndex { + let session = CanonicalType::path(["crate", "app", "session", "Session"]); + let response = CanonicalType::path(["crate", "app", "Response"]); + let error = CanonicalType::path(["crate", "app", "Error"]); + let mut index = WorkspaceTypeIndex::new(); + // Session::open() -> Result + index.method_returns.insert( + (SESSION_PATH.to_string(), "open".to_string()), + CanonicalType::Result(Box::new(session.clone())), + ); + // Session::open_cwd() -> Result + index.method_returns.insert( + (SESSION_PATH.to_string(), "open_cwd".to_string()), + CanonicalType::Result(Box::new(session.clone())), + ); + // Session::diff() -> Response + index.method_returns.insert( + (SESSION_PATH.to_string(), "diff".to_string()), + response.clone(), + ); + // Session::files() -> Response + index.method_returns.insert( + (SESSION_PATH.to_string(), "files".to_string()), + response.clone(), + ); + // Session::insert() -> Result + index.method_returns.insert( + (SESSION_PATH.to_string(), "insert".to_string()), + CanonicalType::Result(Box::new(response.clone())), + ); + // Ctx { session: Session } + index + .struct_fields + .insert((CTX_PATH.to_string(), "session".to_string()), session); + // Free fn make_session() -> Result + index.fn_returns.insert( + "crate::app::make_session".to_string(), + CanonicalType::Result(Box::new(CanonicalType::path([ + "crate", "app", "session", "Session", + ]))), + ); + let _ = error; // keep in scope if extensions need it + index +} + +fn find_fn<'a>(file: &'a syn::File, name: &str) -> &'a syn::ItemFn { + file.items + .iter() + .find_map(|item| match item { + syn::Item::Fn(f) if f.sig.ident == name => Some(f), + _ => None, + }) + .unwrap_or_else(|| panic!("fn {name} not in fixture")) +} + +fn sig_params(sig: &syn::Signature) -> Vec<(String, &syn::Type)> { + sig.inputs + .iter() + .filter_map(|arg| match arg { + syn::FnArg::Typed(pt) => match pt.pat.as_ref() { + syn::Pat::Ident(pi) => Some((pi.ident.to_string(), pt.ty.as_ref())), + _ => None, + }, + _ => None, + }) + .collect() +} + +/// Run the fn body through `collect_canonical_calls` with the given +/// workspace index. Returns the set of canonical call targets. +fn run(fx: &RegFixture, index: &WorkspaceTypeIndex, fn_name: &str) -> HashSet { + let f = find_fn(&fx.file, fn_name); + let ctx = FnContext { + body: &f.block, + signature_params: sig_params(&f.sig), + self_type: None, + alias_map: &fx.alias_map, + local_symbols: &fx.local_symbols, + crate_root_modules: &fx.crate_roots, + importing_file: "src/cli/handlers.rs", + workspace_index: Some(index), + }; + collect_canonical_calls(&ctx) +} + +// ═══════════════════════════════════════════════════════════════════ +// Positive: rlm Gruppe-2 patterns (method-chain ctors) +// ═══════════════════════════════════════════════════════════════════ + +#[test] +fn rlm_group2_open_map_err_unwrap() { + let fx = parse( + r#" + use crate::app::session::Session; + pub fn cmd() { + let s = Session::open().map_err(handle).unwrap(); + s.diff(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "cmd"); + assert!( + calls.contains("crate::app::session::Session::diff"), + "expected Session::diff edge, got {calls:?}" + ); +} + +#[test] +fn rlm_group2_open_cwd_map_err_try() { + // The exact pattern from the original rlm bug report. + let fx = parse( + r#" + use crate::app::session::Session; + pub fn cmd() { + let s = Session::open_cwd().map_err(map_err)?; + s.diff(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "cmd"); + assert!( + calls.contains("crate::app::session::Session::diff"), + "expected Session::diff edge, got {calls:?}" + ); +} + +#[test] +fn rlm_group2_plain_unwrap() { + let fx = parse( + r#" + use crate::app::session::Session; + pub fn cmd() { + let s = Session::open().unwrap(); + s.files(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "cmd"); + assert!(calls.contains("crate::app::session::Session::files")); +} + +#[test] +fn rlm_group2_expect_message() { + let fx = parse( + r#" + use crate::app::session::Session; + pub fn cmd() { + let s = Session::open().expect("session must open"); + s.diff(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "cmd"); + assert!(calls.contains("crate::app::session::Session::diff")); +} + +#[test] +fn rlm_group2_unwrap_or_else_closure() { + let fx = parse( + r#" + use crate::app::session::Session; + pub fn cmd() { + let s = Session::open().unwrap_or_else(|e| fallback(e)); + s.diff(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "cmd"); + assert!(calls.contains("crate::app::session::Session::diff")); +} + +#[test] +fn rlm_group2_chained_inline() { + // No intermediate `let` — the chain resolves inside a single + // method-call expression. + let fx = parse( + r#" + use crate::app::session::Session; + pub fn cmd() { + Session::open().unwrap().diff(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "cmd"); + assert!(calls.contains("crate::app::session::Session::diff")); +} + +#[test] +fn rlm_group2_insert_returns_result_chained() { + // Session::insert returns Result — verify the outer + // call edge is recorded even on a Result-wrapped receiver chain. + let fx = parse( + r#" + use crate::app::session::Session; + pub fn cmd() { + Session::open().unwrap().insert(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "cmd"); + assert!(calls.contains("crate::app::session::Session::insert")); +} + +// ═══════════════════════════════════════════════════════════════════ +// Positive: rlm Gruppe-3 patterns (struct-field access) +// ═══════════════════════════════════════════════════════════════════ + +#[test] +fn rlm_group3_ctx_field_access() { + let fx = parse( + r#" + use crate::app::Ctx; + pub fn handle(ctx: &Ctx) { + ctx.session.diff(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "handle"); + assert!(calls.contains("crate::app::session::Session::diff")); +} + +#[test] +fn rlm_group3_ctx_field_access_via_let() { + let fx = parse( + r#" + use crate::app::Ctx; + pub fn handle(ctx: &Ctx) { + let s = &ctx.session; + s.diff(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "handle"); + // `&ctx.session` inferred as Session (Reference is transparent). + assert!(calls.contains("crate::app::session::Session::diff")); +} + +// ═══════════════════════════════════════════════════════════════════ +// Positive: free-fn return-type chain +// ═══════════════════════════════════════════════════════════════════ + +#[test] +fn free_fn_result_chain() { + let fx = parse( + r#" + pub fn cmd() { + let s = crate::app::make_session().unwrap(); + s.diff(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "cmd"); + assert!(calls.contains("crate::app::session::Session::diff")); +} + +// ═══════════════════════════════════════════════════════════════════ +// Positive: fast-path patterns (no workspace_index needed, but still work) +// ═══════════════════════════════════════════════════════════════════ + +#[test] +fn fast_path_signature_param_resolves() { + let fx = parse( + r#" + use crate::app::session::Session; + pub fn handle(s: &Session) { + s.diff(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "handle"); + assert!(calls.contains("crate::app::session::Session::diff")); +} + +#[test] +fn fast_path_let_type_annotation() { + let fx = parse( + r#" + use crate::app::session::Session; + pub fn cmd() { + let s: Session = make_it(); + s.diff(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "cmd"); + assert!(calls.contains("crate::app::session::Session::diff")); +} + +#[test] +fn fast_path_direct_constructor() { + let fx = parse( + r#" + use crate::app::session::Session; + pub fn cmd() { + let s = Session::open_cwd(); + // No unwrap — s is Result, not Session. + // Fast path on the bare-ident fails; inference fallback on + // `s.diff()` receiver infers Result, which doesn't + // have `diff` in the combinator table → :diff. + s.diff(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "cmd"); + // This pattern is pathological (caller should `?` or `unwrap`), but + // we verify the resolver doesn't invent a false Session::diff edge. + assert!( + calls.contains(":diff") || calls.contains("crate::app::session::Session::diff"), + "pathological Result.method() must either fall back or correctly unwrap, got {calls:?}" + ); +} + +// ═══════════════════════════════════════════════════════════════════ +// Negative: documented Stage 1 limits (unresolved stays unresolved) +// ═══════════════════════════════════════════════════════════════════ + +#[test] +fn negative_external_type_method_is_bare() { + // `u32` is stdlib — no workspace entry. Calling a made-up method + // on it must land as `:name` rather than confabulate. + let fx = parse( + r#" + pub fn cmd() { + let x: u32 = 42; + x.custom_method(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "cmd"); + assert!( + calls.contains(":custom_method"), + "expected :custom_method fallback, got {calls:?}" + ); + assert!( + !calls.iter().any(|c| c.contains("u32::custom_method")), + "must not fabricate stdlib method edges, got {calls:?}" + ); +} + +#[test] +fn negative_unannotated_generic_stays_unresolved() { + // `fn get() -> T` yields Opaque; `x.m()` falls back. + let fx = parse( + r#" + pub fn cmd() { + let x = get(); + x.some_method(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "cmd"); + assert!(calls.contains(":some_method")); +} + +#[test] +fn negative_stdlib_map_closure_is_unresolved() { + // `.map(|r| r.diff())` inner call on the closure argument — the + // closure body is visited, `r` has no binding → :diff. The + // outer `.map()` itself also yields :map (stdlib + // closure-dependent combinator). + let fx = parse( + r#" + use crate::app::session::Session; + pub fn cmd() { + Session::open().map(|r| r.diff()); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "cmd"); + // The inner `r.diff()` is unresolved; assert it stays :diff. + assert!( + calls.iter().any(|c| c == ":diff"), + "closure-body call should stay :diff without binding, got {calls:?}" + ); +} + +#[test] +fn negative_tuple_destructuring_is_limit() { + // Stage 1 doesn't track tuple element types. `let (a, s) = setup(); + // s.m()` leaves `s` unresolved. + let fx = parse( + r#" + pub fn cmd() { + let (a, s) = setup(); + s.diff(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "cmd"); + // Documented limit: tuple-destructured bindings are Opaque. + assert!( + calls.contains(":diff"), + "tuple destructuring is a Stage 1 limit — expected :diff, got {calls:?}" + ); +} + +// ═══════════════════════════════════════════════════════════════════ +// Robustness: mixed positive + negative in one fn body +// ═══════════════════════════════════════════════════════════════════ + +// ═══════════════════════════════════════════════════════════════════ +// Stage 2: Trait-Dispatch Over-Approximation +// ═══════════════════════════════════════════════════════════════════ + +#[test] +fn trait_dispatch_fans_out_to_all_impls() { + // `dyn Handler.handle()` must record edges to EVERY impl's `handle`. + let fx = parse( + r#" + use crate::ports::Handler; + pub fn dispatch(h: &dyn Handler) { + h.handle(); + } + "#, + ); + let mut index = WorkspaceTypeIndex::new(); + // Set up the trait + its method name. + index.trait_methods.insert( + "crate::ports::Handler".to_string(), + std::iter::once("handle".to_string()).collect(), + ); + // Three impls. + index.trait_impls.insert( + "crate::ports::Handler".to_string(), + vec![ + "crate::app::LoggingHandler".to_string(), + "crate::app::MetricsHandler".to_string(), + "crate::app::AuditHandler".to_string(), + ], + ); + let calls = run(&fx, &index, "dispatch"); + assert!( + calls.contains("crate::app::LoggingHandler::handle"), + "expected LoggingHandler::handle edge, got {calls:?}" + ); + assert!(calls.contains("crate::app::MetricsHandler::handle")); + assert!(calls.contains("crate::app::AuditHandler::handle")); +} + +#[test] +fn trait_dispatch_skips_unrelated_methods() { + // `dyn Handler.unrelated()` — the method isn't on the trait, so no + // fan-out. Falls back to :name. + let fx = parse( + r#" + use crate::ports::Handler; + pub fn dispatch(h: &dyn Handler) { + h.unrelated(); + } + "#, + ); + let mut index = WorkspaceTypeIndex::new(); + index.trait_methods.insert( + "crate::ports::Handler".to_string(), + std::iter::once("handle".to_string()).collect(), + ); + index.trait_impls.insert( + "crate::ports::Handler".to_string(), + vec!["crate::app::X".to_string()], + ); + let calls = run(&fx, &index, "dispatch"); + assert!( + calls.contains(":unrelated"), + "unrelated method on trait must fall through, got {calls:?}" + ); + assert!( + !calls.contains("crate::app::X::unrelated"), + "must not fabricate edge for non-trait method, got {calls:?}" + ); +} + +#[test] +fn trait_dispatch_with_send_marker_still_resolves() { + // `dyn Handler + Send + 'static` — marker traits skipped, Handler wins. + let fx = parse( + r#" + use crate::ports::Handler; + pub fn dispatch(h: &(dyn Handler + Send)) { + h.handle(); + } + "#, + ); + let mut index = WorkspaceTypeIndex::new(); + index.trait_methods.insert( + "crate::ports::Handler".to_string(), + std::iter::once("handle".to_string()).collect(), + ); + index.trait_impls.insert( + "crate::ports::Handler".to_string(), + vec!["crate::app::X".to_string()], + ); + let calls = run(&fx, &index, "dispatch"); + assert!(calls.contains("crate::app::X::handle")); +} + +#[test] +fn trait_dispatch_box_dyn_resolves() { + // `Box` — Box is peeled, then dyn Handler → TraitBound. + let fx = parse( + r#" + use crate::ports::Handler; + pub fn dispatch(h: Box) { + h.handle(); + } + "#, + ); + let mut index = WorkspaceTypeIndex::new(); + index.trait_methods.insert( + "crate::ports::Handler".to_string(), + std::iter::once("handle".to_string()).collect(), + ); + index.trait_impls.insert( + "crate::ports::Handler".to_string(), + vec!["crate::app::Y".to_string()], + ); + let calls = run(&fx, &index, "dispatch"); + assert!( + calls.contains("crate::app::Y::handle"), + "Box must be peeled, got {calls:?}" + ); +} + +// ═══════════════════════════════════════════════════════════════════ +// Stage 3: User-Wrapper-Config +// ═══════════════════════════════════════════════════════════════════ + +#[test] +fn user_wrapper_is_peeled_on_signature_param() { + // Axum-style `fn h(State(db): State) { db.query() }`. + // Stage 3: configure `State` as a transparent wrapper so the + // inference peels it to reach `Db`, and `db.query()` resolves. + // Note: our current `extract_pat_ident_name` handles `db: State` + // pattern via `Pat::Ident` with type, not `State(db)` tuple-struct + // destructuring — so we use the plain form here. + let fx = parse( + r#" + use crate::app::Db; + pub fn handle(db: State) { + db.query(); + } + "#, + ); + let db = CanonicalType::path(["crate", "app", "Db"]); + let mut index = WorkspaceTypeIndex::new(); + index.method_returns.insert( + ("crate::app::Db".to_string(), "query".to_string()), + CanonicalType::path(["crate", "app", "Rows"]), + ); + // Register `State` as a transparent wrapper. + index.transparent_wrappers.insert("State".to_string()); + let calls = run(&fx, &index, "handle"); + let _ = db; + assert!( + calls.contains("crate::app::Db::query"), + "user-wrapper State should peel to Db, got {calls:?}" + ); +} + +#[test] +fn user_wrapper_unconfigured_stays_unresolved() { + // Same fixture but WITHOUT registering State as transparent. Falls + // through to :query. + let fx = parse( + r#" + use crate::app::Db; + pub fn handle(db: State) { + db.query(); + } + "#, + ); + let index = WorkspaceTypeIndex::new(); + let calls = run(&fx, &index, "handle"); + assert!( + calls.contains(":query"), + "unconfigured wrapper must not be peeled, got {calls:?}" + ); +} + +// ═══════════════════════════════════════════════════════════════════ +// Stage 3: Type-Alias-Expansion +// ═══════════════════════════════════════════════════════════════════ + +#[test] +fn type_alias_expands_to_target_via_signature_param() { + // `type DbRef = std::sync::Arc;` — `fn h(db: DbRef) { db.read() }` + // Inference expands DbRef → Arc → Store (Arc wrapper peeled). + // Store has a `read` method in our fixture. + let fx = parse( + r#" + type DbRef = std::sync::Arc; + pub fn handle(db: DbRef) { + db.read(); + } + "#, + ); + let store = CanonicalType::path(["crate", "cli", "handlers", "Store"]); + let mut index = WorkspaceTypeIndex::new(); + // Pre-populate the alias: `crate::cli::handlers::DbRef` → syn::Type + // for `std::sync::Arc`. + let aliased: syn::Type = syn::parse_str("std::sync::Arc").expect("parse alias target"); + index + .type_aliases + .insert("crate::cli::handlers::DbRef".to_string(), aliased); + // Store::read() method. + index.method_returns.insert( + ( + "crate::cli::handlers::Store".to_string(), + "read".to_string(), + ), + CanonicalType::path(["crate", "cli", "handlers", "Data"]), + ); + // Include `DbRef` in local symbols so the alias key resolves. + let mut fx = fx; + fx.local_symbols.insert("DbRef".to_string()); + fx.local_symbols.insert("Store".to_string()); + let calls = run(&fx, &index, "handle"); + let _ = store; + assert!( + calls.contains("crate::cli::handlers::Store::read"), + "type-alias should expand DbRef → Store, got {calls:?}" + ); +} + +// ═══════════════════════════════════════════════════════════════════ +// Stage 2: Turbofish-as-Return-Type +// ═══════════════════════════════════════════════════════════════════ + +#[test] +fn turbofish_gives_concrete_return_type() { + // `get::()` — generic fn with single turbofish type arg. + // No fn_returns entry (generic returns are Opaque), so the + // turbofish fallback fires and the return type is Session. + let fx = parse( + r#" + use crate::app::session::Session; + pub fn cmd() { + let s = get::(); + s.diff(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "cmd"); + assert!( + calls.contains("crate::app::session::Session::diff"), + "turbofish should resolve generic-ctor return type, got {calls:?}" + ); +} + +#[test] +fn turbofish_on_type_method_is_not_overridden() { + // `Vec::::new()` — turbofish is on the type segment, not the + // method. Path has 2 segments, so the turbofish fallback doesn't + // fire. `new` isn't in our index → falls through cleanly. + let fx = parse( + r#" + pub fn cmd() { + let v = Vec::::new(); + v.custom_method(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "cmd"); + // Important: we must NOT fabricate a `crate::…::u32::custom_method` + // edge from the turbofish arg. + assert!( + calls.contains(":custom_method"), + "Vec::::new() turbofish must not override, got {calls:?}" + ); +} + +// ═══════════════════════════════════════════════════════════════════ + +#[test] +fn mixed_resolutions_in_single_body() { + let fx = parse( + r#" + use crate::app::session::Session; + pub fn cmd() { + let s = Session::open().unwrap(); + s.diff(); + let x: u32 = 0; + x.random(); + crate::app::make_session().unwrap().files(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "cmd"); + assert!( + calls.contains("crate::app::session::Session::diff"), + "resolved: Session::diff missing, got {calls:?}" + ); + assert!( + calls.contains("crate::app::session::Session::files"), + "resolved: Session::files missing, got {calls:?}" + ); + assert!( + calls.contains(":random"), + "unresolved: :random expected, got {calls:?}" + ); +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/rlm_snapshot.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/rlm_snapshot.rs new file mode 100644 index 0000000..8863961 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/rlm_snapshot.rs @@ -0,0 +1,257 @@ +//! End-to-end rlm-shape snapshot — the external acceptance gate for the +//! Task 1.6 inference wiring. +//! +//! Sets up a mini multi-file workspace that mirrors rlm's session / +//! handler pattern (the one the original bug report called out), runs +//! the full Check A + Check B pipeline, and asserts the exact set of +//! surviving findings. The fixture is deliberately small (5 files, +//! ~40 lines of rlm-shaped source) but covers every `call_parity_rule` +//! code path the rlm bug exercised: +//! +//! - CLI handlers that do `let session = RlmSession::open_cwd().map_err(f)?; +//! session.method(...)` — the method-chain constructor pattern +//! - MCP handlers with `session: &RlmSession` parameter — the +//! signature-param fast path +//! - Asymmetric coverage (method called from only one adapter) — +//! legitimate findings the rule should still emit +//! - Genuinely unreached methods — real dead code +//! +//! If Stage 2 trait-dispatch or Stage 3 config-based wrappers add new +//! resolution paths, this snapshot's expected-findings list moves +//! downward — adjust the assertions when that happens. + +use super::support::{build_workspace, empty_cfg_test, globset, run_check_a, run_check_b}; +use crate::adapters::analyzers::architecture::compiled::CompiledCallParity; +use crate::adapters::analyzers::architecture::layer_rule::LayerDefinitions; +use crate::adapters::analyzers::architecture::{MatchLocation, ViolationKind}; +use std::collections::HashSet; + +/// The fixture files. Paths determine layer membership: +/// - `src/application/**` → application (target) +/// - `src/cli/**` → cli adapter +/// - `src/mcp/**` → mcp adapter +fn rlm_fixture() -> Vec<(&'static str, &'static str)> { + vec![ + ( + "src/application/session.rs", + r#" + pub struct RlmSession; + pub struct Response; + pub struct Error; + + impl RlmSession { + pub fn open_cwd() -> Result { todo!() } + pub fn open(path: &str) -> Result { todo!() } + pub fn diff(&self, path: &str) -> Result { todo!() } + pub fn files(&self) -> Result { todo!() } + pub fn insert(&self, content: &str) -> Result { todo!() } + pub fn stats(&self) -> Response { todo!() } + pub fn genuinely_unused(&self) {} + } + "#, + ), + ( + "src/cli/handlers.rs", + r#" + use crate::application::session::RlmSession; + + pub struct CliError; + fn map_err(_e: crate::application::session::Error) -> CliError { CliError } + + pub fn cmd_diff(path: &str) -> Result<(), CliError> { + let session = RlmSession::open_cwd().map_err(map_err)?; + let _ = session.diff(path).map_err(map_err)?; + Ok(()) + } + pub fn cmd_files() -> Result<(), CliError> { + let session = RlmSession::open_cwd().map_err(map_err)?; + let _ = session.files().map_err(map_err)?; + Ok(()) + } + pub fn cmd_stats() -> Result<(), CliError> { + let session = RlmSession::open_cwd().map_err(map_err)?; + let _ = session.stats(); + Ok(()) + } + "#, + ), + ( + "src/mcp/handlers.rs", + r#" + use crate::application::session::RlmSession; + + pub fn handle_diff(session: &RlmSession, path: &str) -> String { + let _ = session.diff(path); + String::new() + } + pub fn handle_files(session: &RlmSession) -> String { + let _ = session.files(); + String::new() + } + pub fn handle_insert(session: &RlmSession, content: &str) -> String { + let _ = session.insert(content); + String::new() + } + "#, + ), + ] +} + +fn rlm_layers() -> LayerDefinitions { + LayerDefinitions::new( + vec![ + "application".to_string(), + "cli".to_string(), + "mcp".to_string(), + ], + vec![ + ("application".to_string(), globset(&["src/application/**"])), + ("cli".to_string(), globset(&["src/cli/**"])), + ("mcp".to_string(), globset(&["src/mcp/**"])), + ], + ) +} + +fn rlm_config() -> CompiledCallParity { + CompiledCallParity { + adapters: vec!["cli".to_string(), "mcp".to_string()], + target: "application".to_string(), + call_depth: 3, + exclude_targets: globset(&[]), + transparent_wrappers: HashSet::new(), + transparent_macros: HashSet::new(), + } +} + +fn missing_adapters_for(findings: &[MatchLocation], target_fn: &str) -> Option> { + findings.iter().find_map(|f| match &f.kind { + ViolationKind::CallParityMissingAdapter { + target_fn: tf, + missing_adapters, + .. + } if tf == target_fn => Some(missing_adapters.clone()), + _ => None, + }) +} + +// ═══════════════════════════════════════════════════════════════════ +// Check A — every adapter pub fn must delegate into application +// ═══════════════════════════════════════════════════════════════════ + +#[test] +fn rlm_snapshot_check_a_no_spurious_findings() { + // After Task 1.6, every cli / mcp handler in the fixture reaches an + // application-layer fn via inference. So Check A has no findings at + // all — this is the primary rlm-bug regression guard. + let ws = build_workspace(&rlm_fixture()); + let findings = run_check_a(&ws, &rlm_layers(), &rlm_config(), &empty_cfg_test()); + assert!( + findings.is_empty(), + "Check A should be clean on rlm-shape fixture, got {} findings: {:?}", + findings.len(), + findings + .iter() + .map(|f| format!("{}:{}", f.file, f.line)) + .collect::>() + ); +} + +// ═══════════════════════════════════════════════════════════════════ +// Check B — target pub fns must be reached from every configured adapter +// ═══════════════════════════════════════════════════════════════════ + +#[test] +fn rlm_snapshot_check_b_diff_reached_from_both_adapters() { + // The hero case: Session::diff is called from both cli (via chain + // inference) and mcp (via signature-param). Both adapters cover it + // → no finding. + let ws = build_workspace(&rlm_fixture()); + let findings = run_check_b(&ws, &rlm_layers(), &rlm_config(), &empty_cfg_test()); + let missing = missing_adapters_for(&findings, "crate::application::session::RlmSession::diff"); + assert!( + missing.is_none(), + "RlmSession::diff should be reached from both adapters, got missing={:?}", + missing + ); +} + +#[test] +fn rlm_snapshot_check_b_files_reached_from_both_adapters() { + let ws = build_workspace(&rlm_fixture()); + let findings = run_check_b(&ws, &rlm_layers(), &rlm_config(), &empty_cfg_test()); + let missing = missing_adapters_for(&findings, "crate::application::session::RlmSession::files"); + assert!(missing.is_none(), "RlmSession::files should be reached"); +} + +#[test] +fn rlm_snapshot_check_b_asymmetric_coverage_flagged() { + // `stats` is only called from cli (cmd_stats). mcp doesn't cover + // it — legitimate Check B finding. + let ws = build_workspace(&rlm_fixture()); + let findings = run_check_b(&ws, &rlm_layers(), &rlm_config(), &empty_cfg_test()); + let missing = missing_adapters_for(&findings, "crate::application::session::RlmSession::stats") + .expect("stats should be missing from some adapter"); + assert_eq!(missing, vec!["mcp".to_string()]); + + // `insert` is only called from mcp — missing from cli. + let missing = + missing_adapters_for(&findings, "crate::application::session::RlmSession::insert") + .expect("insert should be missing from some adapter"); + assert_eq!(missing, vec!["cli".to_string()]); +} + +#[test] +fn rlm_snapshot_check_b_unreached_pub_fn_is_flagged() { + // `genuinely_unused` has no callers → missing from all adapters. + let ws = build_workspace(&rlm_fixture()); + let findings = run_check_b(&ws, &rlm_layers(), &rlm_config(), &empty_cfg_test()); + let missing = missing_adapters_for( + &findings, + "crate::application::session::RlmSession::genuinely_unused", + ) + .expect("genuinely_unused must be flagged"); + let set: HashSet = missing.into_iter().collect(); + assert!(set.contains("cli")); + assert!(set.contains("mcp")); +} + +// ═══════════════════════════════════════════════════════════════════ +// Budget assertion — total finding count on the fixture +// ═══════════════════════════════════════════════════════════════════ + +#[test] +fn rlm_snapshot_total_findings_budget() { + // The fixture has 7 application pub fns. Under the configured + // `application` layer: + // - open: reached from nobody → missing [cli, mcp] + // - open_cwd: reached only from cli → missing [mcp] + // - diff: reached from both → clean + // - files: reached from both → clean + // - insert: reached only from mcp → missing [cli] + // - stats: reached only from cli → missing [mcp] + // - genuinely_unused: reached from nobody → missing [cli, mcp] + // → 5 Check B findings, 0 Check A findings. + // + // If this budget ticks upward, inspect the new findings before + // adjusting the number — the Stage 1 implementation should not + // regress this count. + let ws = build_workspace(&rlm_fixture()); + let layers = rlm_layers(); + let cp = rlm_config(); + let check_a = run_check_a(&ws, &layers, &cp, &empty_cfg_test()); + let check_b = run_check_b(&ws, &layers, &cp, &empty_cfg_test()); + assert_eq!(check_a.len(), 0, "Check A: {:?}", check_a); + assert_eq!( + check_b.len(), + 5, + "Check B count drifted: {:?}", + check_b + .iter() + .filter_map(|f| match &f.kind { + ViolationKind::CallParityMissingAdapter { target_fn, .. } => + Some(target_fn.as_str()), + _ => None, + }) + .collect::>() + ); +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/support.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/support.rs index 8e2aa3d..77a0543 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/support.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/support.rs @@ -1,8 +1,15 @@ //! Shared test helpers for Check A / Check B integration-style tests. +use crate::adapters::analyzers::architecture::call_parity_rule::check_a::check_no_delegation; +use crate::adapters::analyzers::architecture::call_parity_rule::check_b::check_missing_adapter; +use crate::adapters::analyzers::architecture::call_parity_rule::pub_fns::collect_pub_fns_by_layer; +use crate::adapters::analyzers::architecture::call_parity_rule::workspace_graph::build_call_graph; +use crate::adapters::analyzers::architecture::compiled::CompiledCallParity; +use crate::adapters::analyzers::architecture::layer_rule::LayerDefinitions; +use crate::adapters::analyzers::architecture::MatchLocation; use crate::adapters::shared::use_tree::gather_alias_map; use globset::{Glob, GlobSet, GlobSetBuilder}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; /// In-memory workspace built from `(path, source)` pairs. pub(super) struct Workspace { @@ -43,3 +50,63 @@ pub(super) fn build_workspace(entries: &[(&str, &str)]) -> Workspace { pub(super) fn borrowed_files(ws: &Workspace) -> Vec<(&str, &syn::File)> { ws.files.iter().map(|(p, _, f)| (p.as_str(), f)).collect() } + +/// Which call-parity check to run against the pre-built graph. A tiny +/// enum tag keeps `run_check_a` / `run_check_b` from sharing identical +/// body statements (DRY-004 fragment-match) without the HRTB lifetime +/// gymnastics that a `FnOnce` closure would require. +pub(super) enum Check { + A, + B, +} + +/// Run a call-parity check end-to-end against a workspace. Integration: +/// builds pub-fns + graph, then dispatches on `which`. +pub(super) fn run_check( + which: Check, + ws: &Workspace, + layers: &LayerDefinitions, + cp: &CompiledCallParity, + cfg_test: &HashSet, +) -> Vec { + let borrowed = borrowed_files(ws); + let pub_fns = collect_pub_fns_by_layer(&borrowed, &ws.aliases_per_file, layers, cfg_test); + let empty_wrappers = HashSet::new(); + let graph = build_call_graph( + &borrowed, + &ws.aliases_per_file, + cfg_test, + layers, + &empty_wrappers, + ); + match which { + Check::A => check_no_delegation(&pub_fns, &graph, layers, cp), + Check::B => check_missing_adapter(&pub_fns, &graph, layers, cp), + } +} + +/// Run Check A (adapter-must-delegate). Operation: thin wrapper. +pub(super) fn run_check_a( + ws: &Workspace, + layers: &LayerDefinitions, + cp: &CompiledCallParity, + cfg_test: &HashSet, +) -> Vec { + run_check(Check::A, ws, layers, cp, cfg_test) +} + +/// Run Check B (target-must-be-reached). Operation: thin wrapper. +pub(super) fn run_check_b( + ws: &Workspace, + layers: &LayerDefinitions, + cp: &CompiledCallParity, + cfg_test: &HashSet, +) -> Vec { + run_check(Check::B, ws, layers, cp, cfg_test) +} + +/// An empty `cfg_test` HashSet — convenience for callers that don't +/// exercise test-file filtering. +pub(super) fn empty_cfg_test() -> HashSet { + HashSet::new() +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/canonical.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/canonical.rs new file mode 100644 index 0000000..f2707a8 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/canonical.rs @@ -0,0 +1,68 @@ +//! `CanonicalType` — the currency of shallow type inference. +//! +//! Every value is either a crate-rooted concrete type +//! (`Path(["crate", "app", "Session"])`), a recognised stdlib wrapper +//! (`Result`/`Option`/`Future`/`Slice`/`Map`), a trait-bound (Stage 2), +//! or `Opaque` — "we looked and couldn't resolve further". +//! +//! `Opaque` is deliberately distinct from `None` at the inference API +//! boundary: `Some(Opaque)` means "we evaluated and know we can't pin +//! down the concrete type"; `None` means "we don't even have context +//! to try". Both fall back to `:name` behaviour in the call +//! collector, but the distinction matters when we trace inference paths +//! during debugging. + +/// Shallow-inferred type of a `syn::Expr` or declared type. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum CanonicalType { + /// Crate-rooted concrete type, e.g. `["crate", "app", "session", "Session"]`. + /// The form we look up in `WorkspaceTypeIndex`. + Path(Vec), + /// `Result` — only the Ok-side is tracked; error type is erased + /// because call-parity resolution never walks through it. + Result(Box), + /// `Option`. + Option(Box), + /// `Future`. + Future(Box), + /// Iterator element — produced from `Vec` / `&[T]` / `[T; N]`. + Slice(Box), + /// `HashMap<_, V>` — only the value type is tracked. + Map(Box), + /// Trait object / generic bound. Reserved for Stage 2 — Stage 1 never + /// emits this variant. + TraitBound(Vec), + /// Locally known to be unresolvable: external crate, unannotated + /// generic, unsupported construct. Distinct from "not yet evaluated". + Opaque, +} + +impl CanonicalType { + /// Construct a `Path` variant from any iterator of string-ish segments. + /// Operation: pure mapping. + pub fn path(segments: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self::Path(segments.into_iter().map(Into::into).collect()) + } + + // qual:api + /// True iff this is `Opaque` — used by the builder to decide whether + /// to cache-populate an entry for generic-return fns. Operation. + pub fn is_opaque(&self) -> bool { + matches!(self, Self::Opaque) + } + + // qual:api + /// Return the `Ok`/`Some`/`Output` inner type for the three stdlib + /// wrappers. Used by `?` / `.await` / stdlib-combinator resolution. + /// Returns `None` for non-wrapper variants. Operation. + pub fn happy_inner(&self) -> Option<&CanonicalType> { + match self { + Self::Result(inner) | Self::Option(inner) | Self::Future(inner) => Some(inner), + _ => None, + } + } +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/combinators.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/combinators.rs new file mode 100644 index 0000000..010f370 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/combinators.rs @@ -0,0 +1,77 @@ +//! Stdlib-combinator return-type table — Task 1.5. +//! +//! Encodes the subset of `Result` / `Option` / `Future` +//! methods whose return type is derivable from the receiver type alone +//! — without closure-body inference. Called from +//! `infer::call::lookup_method_on_type` when the receiver is a stdlib +//! wrapper and the method isn't a user-defined impl method. +//! +//! Methods whose return type depends on a closure's output (`map`, +//! `and_then`, `then`, `filter_map`) intentionally yield `None` rather +//! than `Opaque` — returning `None` lets the caller fall through cleanly +//! to the `:name` fallback without a fake edge. +//! +//! See `docs/rustqual-design-receiver-type-inference.md` §4 for the +//! normative table. + +use super::canonical::CanonicalType; + +// qual:api +/// Resolve a method call on a stdlib-wrapper receiver. Returns `Some(T)` +/// when the table has an entry, `None` when the method isn't covered or +/// depends on closure-body inference. +/// Integration: dispatches on wrapper kind. +pub fn combinator_return(receiver: &CanonicalType, method: &str) -> Option { + match receiver { + CanonicalType::Result(inner) => result_combinator(method, inner), + CanonicalType::Option(inner) => option_combinator(method, inner), + CanonicalType::Future(inner) => future_combinator(method, inner), + _ => None, + } +} + +/// `Result` methods. `T` is the Ok-side we track; `E` is erased. +/// Operation: lookup table. +fn result_combinator(method: &str, inner: &CanonicalType) -> Option { + match method { + // Unwrappers → T + "unwrap" | "expect" | "unwrap_or" | "unwrap_or_else" | "unwrap_or_default" | "into_ok" => { + Some(inner.clone()) + } + // Transformations that preserve Ok-type + "map_err" | "or_else" => Some(CanonicalType::Result(Box::new(inner.clone()))), + // Extract the Ok-side as Option + "ok" => Some(CanonicalType::Option(Box::new(inner.clone()))), + // Extract the Err-side — E is opaque, so Option. + "err" => Some(CanonicalType::Option(Box::new(CanonicalType::Opaque))), + // Closure-dependent (change T or E via user closure) → unresolved. + "map" | "and_then" | "inspect" | "inspect_err" => None, + _ => None, + } +} + +/// `Option` methods. Operation: lookup table. +fn option_combinator(method: &str, inner: &CanonicalType) -> Option { + match method { + // Unwrappers → T + "unwrap" | "expect" | "unwrap_or" | "unwrap_or_else" | "unwrap_or_default" => { + Some(inner.clone()) + } + // Conversions to Result + "ok_or" | "ok_or_else" => Some(CanonicalType::Result(Box::new(inner.clone()))), + // Preserve Option + "or" | "or_else" | "filter" | "take" | "replace" | "as_ref" | "as_mut" | "cloned" + | "copied" => Some(CanonicalType::Option(Box::new(inner.clone()))), + // Closure-dependent → unresolved. + "map" | "and_then" | "inspect" | "map_or" | "map_or_else" => None, + _ => None, + } +} + +/// `Future` methods. The canonical unwrap via `.await` is +/// handled in `infer::access::infer_await` (not a method call). +/// Out-of-scope methods like `.boxed()` / `.shared()` stay unresolved. +/// Operation: stub return; placeholder for future (Stage 2+) expansion. +fn future_combinator(_method: &str, _inner: &CanonicalType) -> Option { + None +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/access.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/access.rs new file mode 100644 index 0000000..4cd5f19 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/access.rs @@ -0,0 +1,84 @@ +//! Inference for `Field`, `Try`, `Await`, `Cast`, and `Unary(Deref)`. +//! +//! These are the "structural" expressions — they don't introduce new +//! resolution but peel / project existing types: +//! - `base.field` → struct-field lookup on base's type +//! - `expr?` → `Result` / `Option` → `T` +//! - `expr.await` → `Future` → `T` +//! - `expr as T` → `T` (re-resolves the target type) +//! - `*expr` → same type as `expr` (deref is transparent for call graphs) + +use super::super::canonical::CanonicalType; +use super::super::resolve::{resolve_type, ResolveContext}; +use super::InferContext; + +/// `base.field` — recurse on `base`, then look up the field in the +/// workspace index. Integration. +pub(super) fn infer_field(f: &syn::ExprField, ctx: &InferContext<'_>) -> Option { + let base_type = super::infer_type(&f.base, ctx)?; + let syn::Member::Named(ident) = &f.member else { + return None; + }; + let field_name = ident.to_string(); + lookup_field(&base_type, &field_name, ctx) +} + +/// Only `Path` receiver types hit the struct-field index. Stdlib +/// wrappers don't have user-defined fields. Operation. +fn lookup_field(ty: &CanonicalType, field: &str, ctx: &InferContext<'_>) -> Option { + match ty { + CanonicalType::Path(segs) => { + let key = segs.join("::"); + ctx.workspace.struct_field(&key, field).cloned() + } + _ => None, + } +} + +/// `expr?` — unwrap `Result` or `Option` to `T`. Operation: +/// delegate to `CanonicalType::happy_inner` (which matches both wrappers +/// and, permissively, `Future` — harmless since `?` on Future is a +/// compile error anyway). +pub(super) fn infer_try(t: &syn::ExprTry, ctx: &InferContext<'_>) -> Option { + let inner = super::infer_type(&t.expr, ctx)?; + inner.happy_inner().cloned() +} + +/// `expr.await` — unwrap `Future` to `T` only. Operation. +pub(super) fn infer_await(a: &syn::ExprAwait, ctx: &InferContext<'_>) -> Option { + let inner = super::infer_type(&a.base, ctx)?; + match inner { + CanonicalType::Future(t) => Some(*t), + _ => None, + } +} + +/// `expr as T` — re-resolve the target type. The source expression's +/// type is irrelevant (conversion semantics are beyond our scope). +/// Operation: delegate to `resolve_type`. +pub(super) fn infer_cast(c: &syn::ExprCast, ctx: &InferContext<'_>) -> Option { + let rctx = ResolveContext { + alias_map: ctx.alias_map, + local_symbols: ctx.local_symbols, + crate_root_modules: ctx.crate_root_modules, + importing_file: ctx.importing_file, + type_aliases: Some(&ctx.workspace.type_aliases), + transparent_wrappers: Some(&ctx.workspace.transparent_wrappers), + }; + let ty = resolve_type(&c.ty, &rctx); + if ty.is_opaque() { + None + } else { + Some(ty) + } +} + +/// `*expr` — transparent for call-graph purposes (auto-deref produces +/// the same method table access). Other unary operators (`!x`, `-x`) +/// produce primitives we don't track — return `None`. Operation. +pub(super) fn infer_unary(u: &syn::ExprUnary, ctx: &InferContext<'_>) -> Option { + if !matches!(u.op, syn::UnOp::Deref(_)) { + return None; + } + super::infer_type(&u.expr, ctx) +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/call.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/call.rs new file mode 100644 index 0000000..36c2c23 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/call.rs @@ -0,0 +1,168 @@ +//! Inference for `Path`, `Call`, and `MethodCall` expressions. +//! +//! These three cover the bulk of call-graph-relevant resolution: +//! - A bare `Expr::Path(ident)` inside an expression position refers to a +//! local variable — we look it up in the scoped bindings. +//! - `Expr::Call { func: Path(…) }` is a free-fn or associated-fn invocation; +//! its inferred type is the return type stored in the workspace index. +//! - `Expr::MethodCall` recursively infers the receiver type, then looks up +//! `(receiver_canonical, method)` in the workspace index. + +use super::super::canonical::CanonicalType; +use super::generics::turbofish_return_type; +use super::InferContext; +use crate::adapters::analyzers::architecture::call_parity_rule::bindings::canonicalise_type_segments; + +/// A bare `Expr::Path` in expression position is always either a local +/// variable or a const/static ref. Stage 1 resolves only locals. +/// Operation: lookup + clone. +pub(super) fn infer_path_expr(p: &syn::ExprPath, ctx: &InferContext<'_>) -> Option { + if p.path.segments.len() != 1 { + return None; + } + let ident = p.path.segments[0].ident.to_string(); + ctx.bindings.lookup(&ident) +} + +/// A call like `foo()` or `T::ctor(...)` or `crate::path::fn(...)`. +/// Resolve the func path to its canonical form, try the workspace +/// index in two ways (fn-style first, method-style as fallback), and +/// finally — for single-ident generic fns called with a turbofish +/// (`get::()`) — use the turbofish type argument as the +/// inferred return type. Stage 2 feature. +/// Integration: delegates to the three lookup helpers. +pub(super) fn infer_call(call: &syn::ExprCall, ctx: &InferContext<'_>) -> Option { + let syn::Expr::Path(p) = call.func.as_ref() else { + return None; + }; + let segs = path_segments(p); + if let Some(t) = infer_call_from_segments(&segs, ctx) { + return Some(t); + } + turbofish_return_type(&p.path, ctx) +} + +/// Receiver-type-driven method resolution: `expr.method(…)`. Recurses +/// into `expr` via the top-level `infer_type`, then looks the result up +/// in the workspace index. Integration. +pub(super) fn infer_method_call( + m: &syn::ExprMethodCall, + ctx: &InferContext<'_>, +) -> Option { + let receiver_type = super::infer_type(&m.receiver, ctx)?; + let method = m.method.to_string(); + lookup_method_on_type(&receiver_type, &method, ctx) +} + +/// Try `fn_returns` first, fall back to `method_returns` if path has +/// at least two segments. Operation: delegation via `.or_else`. +fn infer_call_from_segments(segs: &[String], ctx: &InferContext<'_>) -> Option { + if segs.is_empty() { + return None; + } + try_fn_return(segs, ctx).or_else(|| try_method_return(segs, ctx)) +} + +/// Canonicalise the full path and probe `fn_returns`. Operation. +fn try_fn_return(segs: &[String], ctx: &InferContext<'_>) -> Option { + let full = canonicalise_call_path(segs, ctx)?; + let key = full.join("::"); + ctx.workspace.fn_return(&key).cloned() +} + +/// Split the last segment off as method name, canonicalise the prefix +/// as a type path, and probe `method_returns`. Operation. +fn try_method_return(segs: &[String], ctx: &InferContext<'_>) -> Option { + if segs.len() < 2 { + return None; + } + let method = segs.last()?; + let type_segs = &segs[..segs.len() - 1]; + let type_full = canonicalise_call_path(type_segs, ctx)?; + let key = type_full.join("::"); + ctx.workspace.method_return(&key, method).cloned() +} + +/// Method lookup keyed on the receiver's canonical type. `Path` hits +/// the user-defined workspace index; stdlib wrappers +/// (`Result`/`Option`/`Future`) go through the combinator table; +/// `TraitBound` (Stage 2) picks the first matching trait-impl's +/// return type (all impls share the trait's signature). +/// `Slice`/`Map`/`Opaque` stay unresolved. +/// Operation: dispatch over wrapper kind. +fn lookup_method_on_type( + ty: &CanonicalType, + method: &str, + ctx: &InferContext<'_>, +) -> Option { + match ty { + CanonicalType::Path(segs) => { + let key = segs.join("::"); + ctx.workspace.method_return(&key, method).cloned() + } + CanonicalType::Result(_) | CanonicalType::Option(_) | CanonicalType::Future(_) => { + super::super::combinators::combinator_return(ty, method) + } + CanonicalType::TraitBound(segs) => lookup_trait_method_return(segs, method, ctx), + _ => None, + } +} + +/// For a `dyn Trait` receiver, find the first workspace impl that has +/// the method in question and use its return type. Valid Rust guarantees +/// all impls share the trait's method signature, so the first one wins. +/// Operation: index probe + lookup. +fn lookup_trait_method_return( + trait_segs: &[String], + method: &str, + ctx: &InferContext<'_>, +) -> Option { + let trait_canonical = trait_segs.join("::"); + if !ctx.workspace.trait_has_method(&trait_canonical, method) { + return None; + } + ctx.workspace + .impls_of_trait(&trait_canonical) + .iter() + .find_map(|impl_type| ctx.workspace.method_return(impl_type, method).cloned()) +} + +/// Canonicalise a path's segments for lookup, with `Self`-substitution +/// applied before the generic pipeline. Operation: substitution + +/// delegate. +fn canonicalise_call_path(segs: &[String], ctx: &InferContext<'_>) -> Option> { + if segs.is_empty() { + return None; + } + let expanded = substitute_self(segs, ctx.self_type.as_ref())?; + canonicalise_type_segments( + &expanded, + ctx.alias_map, + ctx.local_symbols, + ctx.crate_root_modules, + ctx.importing_file, + ) +} + +/// If the first segment is `Self`, replace it with the impl's self-type +/// canonical segments. Returns `None` when `Self` appears without a +/// self-type context (shouldn't happen syntactically, but keeps the +/// resolver honest). Operation. +fn substitute_self(segs: &[String], self_type: Option<&Vec>) -> Option> { + if segs.first().map(String::as_str) != Some("Self") { + return Some(segs.to_vec()); + } + let self_segs = self_type?; + let mut out: Vec = self_segs.clone(); + out.extend_from_slice(&segs[1..]); + Some(out) +} + +/// Flatten a `syn::ExprPath` to its segment idents. Operation. +fn path_segments(p: &syn::ExprPath) -> Vec { + p.path + .segments + .iter() + .map(|s| s.ident.to_string()) + .collect() +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/generics.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/generics.rs new file mode 100644 index 0000000..ded95bb --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/generics.rs @@ -0,0 +1,47 @@ +//! Stage 2 generic-fn handling — turbofish as return-type override. +//! +//! When a single-ident generic fn is called with a turbofish +//! (`get::()`) and the workspace index has no concrete return +//! type for it (because the fn's signature returns a generic `T` that +//! collapses to `Opaque`), the turbofish's first type argument serves +//! as the inferred return type. Narrow by design — it only fires for +//! single-segment paths so `Vec::::new()` (where the turbofish +//! sits on the type segment, not the method) doesn't over-approximate. + +use super::super::canonical::CanonicalType; +use super::super::resolve::{resolve_type, ResolveContext}; +use super::InferContext; + +/// Fallback after `fn_returns` / `method_returns` miss: use the +/// turbofish's first type argument as the return type. Returns `None` +/// for multi-segment paths, missing angle-bracketed args, or `Opaque` +/// turbofish types. Operation. +pub(super) fn turbofish_return_type( + path: &syn::Path, + ctx: &InferContext<'_>, +) -> Option { + if path.segments.len() != 1 { + return None; + } + let only = &path.segments[0]; + let syn::PathArguments::AngleBracketed(ab) = &only.arguments else { + return None; + }; + let first_ty = ab.args.iter().find_map(|arg| match arg { + syn::GenericArgument::Type(t) => Some(t), + _ => None, + })?; + let rctx = ResolveContext { + alias_map: ctx.alias_map, + local_symbols: ctx.local_symbols, + crate_root_modules: ctx.crate_root_modules, + importing_file: ctx.importing_file, + type_aliases: Some(&ctx.workspace.type_aliases), + transparent_wrappers: Some(&ctx.workspace.transparent_wrappers), + }; + let resolved = resolve_type(first_ty, &rctx); + if matches!(resolved, CanonicalType::Opaque) { + return None; + } + Some(resolved) +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/mod.rs new file mode 100644 index 0000000..f8fba84 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/mod.rs @@ -0,0 +1,102 @@ +//! Shallow type-inference engine — Task 1.3. +//! +//! Public API: `infer_type(expr, ctx) -> Option`. +//! +//! Dispatches over `syn::Expr` variants (see +//! `docs/rustqual-design-receiver-type-inference.md` §3). Each variant +//! delegates to `call` or `access` modules; transparent wrappers +//! (`Paren`, `Reference`, `Group`) recurse directly. +//! +//! What's NOT in Task 1.3: +//! - Stdlib Result/Option/Future combinators (`.unwrap()`, `.map_err()`, +//! …) — those arrive in Task 1.5. Until then, `.unwrap()` on a +//! `Result` resolves to `Opaque` because `Result::unwrap` isn't +//! in the workspace index. +//! - Pattern-binding scope construction — Task 1.4. The engine here +//! consumes bindings through the `BindingLookup` trait; callers are +//! responsible for populating them. + +pub mod access; +pub mod call; +pub mod generics; + +use super::canonical::CanonicalType; +use super::workspace_index::WorkspaceTypeIndex; +use std::collections::{HashMap, HashSet}; + +/// Look up a scoped variable name → inferred type. Implementations may +/// back this by a flat map (tests), a stack of maps (Task 1.4's +/// `BindingScope`), or an adapter over the collector's existing scope. +/// Returns an owned value so adapters can synthesize `CanonicalType`s +/// on the fly without lifetime gymnastics. +pub trait BindingLookup { + fn lookup(&self, ident: &str) -> Option; +} + +/// Simple flat-map `BindingLookup` impl. Used by unit tests and as a +/// starting point for downstream consumers who don't need scoped +/// push/pop semantics. +#[derive(Debug, Default)] +pub struct FlatBindings { + map: HashMap, +} + +impl FlatBindings { + // qual:api + pub fn new() -> Self { + Self::default() + } + + // qual:api + /// Record a binding. Replaces an existing entry for the same name. + /// Operation. + pub fn insert(&mut self, name: &str, ty: CanonicalType) { + self.map.insert(name.to_string(), ty); + } +} + +impl BindingLookup for FlatBindings { + fn lookup(&self, ident: &str) -> Option { + self.map.get(ident).cloned() + } +} + +/// Inputs to the inference engine. Bundles the workspace index, the +/// per-file resolution pipeline (alias map + local symbols + crate +/// roots + importing file path), the current binding scope, and the +/// enclosing impl's self-type (for `Self::xxx` path resolution). +pub struct InferContext<'a> { + pub workspace: &'a WorkspaceTypeIndex, + pub alias_map: &'a HashMap>, + pub local_symbols: &'a HashSet, + pub crate_root_modules: &'a HashSet, + pub importing_file: &'a str, + pub bindings: &'a dyn BindingLookup, + /// Canonical segments of the enclosing `impl T { ... }`'s self-type, + /// if we're currently inferring inside an impl body. `None` for + /// free-fn contexts. Used to resolve `Self::method(...)` calls. + pub self_type: Option>, +} + +// qual:api +/// Infer the canonical type of a `syn::Expr`. Integration: dispatches +/// over expression variants to the `call` / `access` sub-modules. +/// Returns `None` when the expression shape isn't supported or when +/// inference inputs are insufficient to pin down a concrete type. +// qual:recursive +pub fn infer_type(expr: &syn::Expr, ctx: &InferContext<'_>) -> Option { + match expr { + syn::Expr::Path(p) => call::infer_path_expr(p, ctx), + syn::Expr::Call(c) => call::infer_call(c, ctx), + syn::Expr::MethodCall(m) => call::infer_method_call(m, ctx), + syn::Expr::Field(f) => access::infer_field(f, ctx), + syn::Expr::Try(t) => access::infer_try(t, ctx), + syn::Expr::Await(a) => access::infer_await(a, ctx), + syn::Expr::Paren(p) => infer_type(&p.expr, ctx), + syn::Expr::Reference(r) => infer_type(&r.expr, ctx), + syn::Expr::Group(g) => infer_type(&g.expr, ctx), + syn::Expr::Cast(c) => access::infer_cast(c, ctx), + syn::Expr::Unary(u) => access::infer_unary(u, ctx), + _ => None, + } +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/mod.rs new file mode 100644 index 0000000..46ebb39 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/mod.rs @@ -0,0 +1,34 @@ +//! Shallow type-inference for call_parity receiver resolution. +//! +//! Stage 1 (v1.2.0) provides the workspace type index used by the +//! inference engine. The engine itself lands in Task 1.3; for now this +//! module exposes `WorkspaceTypeIndex` + its builder and the +//! `CanonicalType` vocabulary. +//! +//! Design reference: `docs/rustqual-design-receiver-type-inference.md`. +//! Plan: `~/.claude/plans/cached-noodling-frog.md`. + +pub mod canonical; +pub mod combinators; +pub mod infer; +pub mod patterns; +pub mod resolve; +pub mod workspace_index; + +// qual:api +pub use canonical::CanonicalType; + +// qual:api +pub use combinators::combinator_return; + +// qual:api +pub use infer::{infer_type, BindingLookup, FlatBindings, InferContext}; + +// qual:api +pub use patterns::{extract_bindings, extract_for_bindings}; + +// qual:api +pub use workspace_index::{build_workspace_type_index, WorkspaceTypeIndex}; + +#[cfg(test)] +mod tests; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs new file mode 100644 index 0000000..37d6e49 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs @@ -0,0 +1,206 @@ +//! Pattern-binding walker. Produces `(name, type)` pairs from a +//! `syn::Pat` given the type of the expression being matched. +//! +//! The walker is recursive (patterns nest — `Some(Ctx { id })` binds +//! `id` to the field type inside an `Option`) but Stage 1 keeps +//! the cases flat: each pattern variant has a dedicated handler and +//! the dispatch lives in `collect`. + +use super::super::canonical::CanonicalType; +use super::super::infer::InferContext; +use super::super::resolve::{resolve_type, ResolveContext}; + +// qual:api +/// Extract `(binding_name, canonical_type)` pairs from a pattern matched +/// against a value of `matched_type`. Integration: delegates to `collect` +/// which dispatches over pattern variants. +pub fn extract_bindings( + pat: &syn::Pat, + matched_type: &CanonicalType, + ctx: &InferContext<'_>, +) -> Vec<(String, CanonicalType)> { + let mut out = Vec::new(); + collect(pat, matched_type, ctx, &mut out); + out +} + +// qual:recursive +/// Dispatch on the pattern variant and delegate to the matching handler. +/// Integration: pure dispatch — each arm is a single delegation call. +fn collect( + pat: &syn::Pat, + matched_type: &CanonicalType, + ctx: &InferContext<'_>, + out: &mut Vec<(String, CanonicalType)>, +) { + match pat { + syn::Pat::Ident(pi) => bind_ident(pi, matched_type, out), + syn::Pat::Type(pt) => bind_annotated(pt, ctx, out), + syn::Pat::Reference(r) => collect(&r.pat, matched_type, ctx, out), + syn::Pat::Paren(p) => collect(&p.pat, matched_type, ctx, out), + syn::Pat::Tuple(t) => bind_tuple(t, ctx, out), + syn::Pat::TupleStruct(ts) => bind_tuple_struct(ts, matched_type, ctx, out), + syn::Pat::Struct(s) => bind_struct(s, matched_type, ctx, out), + syn::Pat::Slice(s) => bind_slice(s, matched_type, ctx, out), + syn::Pat::Or(o) => bind_or_first(o, matched_type, ctx, out), + _ => {} + } +} + +/// `Pat::Ident(x)` — the simplest case: bind the name to the matched +/// type. `Opaque` bindings are still recorded so shadowing works +/// correctly (a later `ctx.bindings.lookup(x)` sees `Some(Opaque)`, +/// which callers can treat as "known unresolvable"). +/// +/// Syn-level ambiguity: `None` as a pattern is represented as +/// `Pat::Ident("None")`, not a distinct variant pattern. We specifically +/// suppress binding when the ident is `None` and the matched type is +/// `Option<_>` — the only case where this disambiguation can be made +/// statically. Other uppercase idents (`Some` can't appear without a +/// payload, `Ok`/`Err` always carry args and thus parse as +/// `Pat::TupleStruct`) don't need special handling. Operation. +fn bind_ident( + pi: &syn::PatIdent, + matched_type: &CanonicalType, + out: &mut Vec<(String, CanonicalType)>, +) { + let name = pi.ident.to_string(); + if is_variant_like(&name, matched_type) { + return; + } + out.push((name, matched_type.clone())); +} + +/// True for identifiers that are unambiguously a stdlib enum-variant +/// pattern (not a binding) given the matched type. Operation. +fn is_variant_like(name: &str, matched_type: &CanonicalType) -> bool { + matches!((name, matched_type), ("None", CanonicalType::Option(_))) +} + +/// `Pat::Type(inner: T)` — the annotation overrides the inferred type. +/// `let x: Session = returns_opaque()` produces `x: Session`. +/// Operation: resolve the annotation, re-enter via closure. +fn bind_annotated( + pt: &syn::PatType, + ctx: &InferContext<'_>, + out: &mut Vec<(String, CanonicalType)>, +) { + let resolve = |ty: &syn::Type| { + let rctx = ResolveContext { + alias_map: ctx.alias_map, + local_symbols: ctx.local_symbols, + crate_root_modules: ctx.crate_root_modules, + importing_file: ctx.importing_file, + type_aliases: Some(&ctx.workspace.type_aliases), + transparent_wrappers: Some(&ctx.workspace.transparent_wrappers), + }; + resolve_type(ty, &rctx) + }; + let annotated = resolve(&pt.ty); + collect(&pt.pat, &annotated, ctx, out); +} + +/// `Pat::Tuple((a, b, c))` — Stage 1 doesn't track tuple types, so every +/// element receives `Opaque`. The per-element recursion may still bind +/// names (they'll be `Opaque`-typed). Operation. +fn bind_tuple(t: &syn::PatTuple, ctx: &InferContext<'_>, out: &mut Vec<(String, CanonicalType)>) { + for elem in &t.elems { + collect(elem, &CanonicalType::Opaque, ctx, out); + } +} + +/// `Pat::TupleStruct(Variant(sub, sub, …))` — for `Some`/`Ok`/`Err` we +/// know the inner type from the matched wrapper; for user-defined +/// variants we fall back to `Opaque`. Operation. +fn bind_tuple_struct( + ts: &syn::PatTupleStruct, + matched_type: &CanonicalType, + ctx: &InferContext<'_>, + out: &mut Vec<(String, CanonicalType)>, +) { + let variant = ts + .path + .segments + .last() + .map(|s| s.ident.to_string()) + .unwrap_or_default(); + let inner = variant_inner_type(&variant, matched_type); + for elem in &ts.elems { + collect(elem, &inner, ctx, out); + } +} + +/// Map a variant name + wrapper type to the inner payload type. Only +/// stdlib variants are recognised; user enums yield `Opaque`. +/// Operation: pure lookup. +fn variant_inner_type(variant: &str, matched_type: &CanonicalType) -> CanonicalType { + match (variant, matched_type) { + ("Some", CanonicalType::Option(inner)) => (**inner).clone(), + ("Ok", CanonicalType::Result(inner)) => (**inner).clone(), + // Err carries the E-side, which we erase in `CanonicalType::Result`. + ("Err", CanonicalType::Result(_)) => CanonicalType::Opaque, + _ => CanonicalType::Opaque, + } +} + +/// `Pat::Struct(T { field, field_alias: x, … })` — look up each named +/// field in the workspace struct-field index. Operation. +fn bind_struct( + s: &syn::PatStruct, + matched_type: &CanonicalType, + ctx: &InferContext<'_>, + out: &mut Vec<(String, CanonicalType)>, +) { + let CanonicalType::Path(segs) = matched_type else { + return; + }; + let struct_key = segs.join("::"); + for field in &s.fields { + let syn::Member::Named(ident) = &field.member else { + continue; + }; + let field_name = ident.to_string(); + let field_type = ctx + .workspace + .struct_field(&struct_key, &field_name) + .cloned() + .unwrap_or(CanonicalType::Opaque); + collect(&field.pat, &field_type, ctx, out); + } +} + +/// `Pat::Slice([a, b, rest @ ..])` — element type comes from the matched +/// `Slice(T)`; `Rest` patterns aren't bound as individual elements here +/// (a rest binding like `rest @ ..` would need `Slice(T)` itself, which +/// is structurally distinct and out of Stage 1 scope). Operation. +fn bind_slice( + s: &syn::PatSlice, + matched_type: &CanonicalType, + ctx: &InferContext<'_>, + out: &mut Vec<(String, CanonicalType)>, +) { + let elem_type = match matched_type { + CanonicalType::Slice(inner) => (**inner).clone(), + _ => CanonicalType::Opaque, + }; + for elem in &s.elems { + if matches!(elem, syn::Pat::Rest(_)) { + continue; + } + collect(elem, &elem_type, ctx, out); + } +} + +/// `Pat::Or(a | b | c)` — in valid Rust all branches bind the same +/// names, so recording the first branch is equivalent. Operation. +fn bind_or_first( + o: &syn::PatOr, + matched_type: &CanonicalType, + ctx: &InferContext<'_>, + out: &mut Vec<(String, CanonicalType)>, +) { + let Some(first) = o.cases.first() else { + return; + }; + collect(first, matched_type, ctx, out); +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/iterator.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/iterator.rs new file mode 100644 index 0000000..c59732b --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/iterator.rs @@ -0,0 +1,34 @@ +//! For-loop element-type extraction. +//! +//! `for pat in iter_expr { … }` binds `pat` against the element type of +//! `iter_expr`. Stage 1 recognises `Slice(T)` as `T` (covers `Vec`, +//! `&[T]`, `[T; N]` via `resolve_type`'s normalisation). Other iterables +//! — `HashMap::iter()` yielding `(&K, &V)`, user iterators — resolve to +//! `Opaque`; the pattern still walks but binds yield `Opaque`-typed +//! names. + +use super::super::canonical::CanonicalType; +use super::super::infer::InferContext; +use super::destructure::extract_bindings; + +// qual:api +/// Extract bindings from a for-loop's `pat in iter_expr`, using the +/// element type derived from `iter_type`. Integration: element-type +/// derivation + delegate to the general pattern walker. +pub fn extract_for_bindings( + pat: &syn::Pat, + iter_type: &CanonicalType, + ctx: &InferContext<'_>, +) -> Vec<(String, CanonicalType)> { + let elem = element_type_of(iter_type); + extract_bindings(pat, &elem, ctx) +} + +/// Map an iterable's canonical type to its element type. `Slice(T) → T`; +/// everything else collapses to `Opaque`. Operation. +fn element_type_of(iter_type: &CanonicalType) -> CanonicalType { + match iter_type { + CanonicalType::Slice(inner) => (**inner).clone(), + _ => CanonicalType::Opaque, + } +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/mod.rs new file mode 100644 index 0000000..6248a21 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/mod.rs @@ -0,0 +1,31 @@ +//! Pattern-binding extraction — Task 1.4. +//! +//! `extract_bindings(pat, matched_type, ctx)` walks a `syn::Pat` and +//! returns the `(name, canonical_type)` pairs a `let`/`if let`/`while +//! let`/`let … else`/`match`-arm introduces into scope. For `for` loops, +//! `extract_for_bindings(pat, iter_type, ctx)` extracts the element type +//! from the iterator first, then delegates to the general pattern walker. +//! +//! Supported patterns (Stage 1): +//! - `Pat::Ident(x)` / `Pat::Wild` — base cases +//! - `Pat::Type(_: T)` — annotation overrides matched type +//! - `Pat::Reference` / `Pat::Paren` — transparent wrappers +//! - `Pat::Tuple` — tuples are `Opaque` (no tuple type tracking) +//! - `Pat::TupleStruct(Some|Ok|Err)` — Option/Result variant unwrap +//! - `Pat::Struct(S { field, … })` — field-type lookup via index +//! - `Pat::Slice([a, b, ..])` — element-type from `Slice(T)` +//! - `Pat::Or` — takes first branch's bindings (all branches should bind same names) +//! +//! Scope limits: +//! - User-defined enum variants beyond `Option`/`Result` yield `Opaque`. +//! - Tuple-pair destructuring from `HashMap` iteration doesn't recover +//! K/V separately (we only track V). + +pub mod destructure; +pub mod iterator; + +// qual:api +pub use destructure::extract_bindings; + +// qual:api +pub use iterator::extract_for_bindings; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs new file mode 100644 index 0000000..72066bd --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs @@ -0,0 +1,247 @@ +//! `syn::Type` → `CanonicalType` conversion. +//! +//! Recognises stdlib wrappers (`Result`, `Option`, `Vec`, `HashMap`, +//! `BTreeMap`, `Arc`, `Box`, `Rc`, `Cow`, `RwLock`, `Mutex`, `RefCell`, +//! `Cell`) and projects their generic arguments into the matching +//! `CanonicalType` variant. Unknown-generic paths resolve through the +//! existing `bindings::canonicalise_type_segments` pipeline (alias map +//! + local symbols + crate roots). +//! +//! Shared between the workspace-index builder (Task 1.2) and the +//! inference engine (Task 1.3) — both turn `syn::Type`s into +//! `CanonicalType`s with identical semantics. + +use super::super::bindings::canonicalise_type_segments; +use super::canonical::CanonicalType; +use std::collections::{HashMap, HashSet}; + +/// Resolution inputs, bundled so the recursive calls don't drag a long +/// parameter list around. +pub(crate) struct ResolveContext<'a> { + pub alias_map: &'a HashMap>, + pub local_symbols: &'a HashSet, + pub crate_root_modules: &'a HashSet, + pub importing_file: &'a str, + /// Stage 3 workspace-wide type aliases. `None` means the caller + /// doesn't need alias expansion (the workspace-index build phase, + /// where the alias map is still being populated). Inference paths + /// pass `Some(&workspace.type_aliases)`. + pub type_aliases: Option<&'a HashMap>, + /// Stage 3 user-defined transparent wrappers — the last-ident + /// names (e.g. `"State"`, `"Extension"`, `"Data"`) that are peeled + /// just like `Arc` / `Box`. `None` means only stdlib wrappers are + /// peeled. + pub transparent_wrappers: Option<&'a HashSet>, +} + +/// Hard recursion cap for `resolve_type_with_depth`. Guards against +/// pathological types (`type A = Vec`, deeply nested wrappers, hostile +/// fixtures). Real-world types bottom out well under 16 levels. +const MAX_RESOLVE_DEPTH: u8 = 32; + +// qual:api +/// Convert a declared / inferred `syn::Type` into a `CanonicalType`. +/// References, parens, and the stdlib-wrapper set are peeled; type paths +/// go through the shared canonicalisation pipeline. Integration. +pub(crate) fn resolve_type(ty: &syn::Type, ctx: &ResolveContext<'_>) -> CanonicalType { + resolve_type_with_depth(ty, ctx, 0) +} + +/// Depth-tracked resolver. Collapses to `Opaque` past +/// `MAX_RESOLVE_DEPTH` so stack overflow can't be triggered by user +/// fixtures (defensive: tests build type aliases and wrapper chains +/// the collector walks unconditionally). Integration: dispatch after a +/// single depth guard — each arm is one-call delegation, own recursion +/// hidden behind closures for IOSP leniency. +// qual:recursive +fn resolve_type_with_depth(ty: &syn::Type, ctx: &ResolveContext<'_>, depth: u8) -> CanonicalType { + depth_guarded(depth, |next| dispatch_type(ty, ctx, next)) +} + +/// Run `body` only when the cap isn't exceeded, passing `depth + 1` so +/// callers don't hand-code the increment. Operation. +fn depth_guarded(depth: u8, body: F) -> CanonicalType +where + F: FnOnce(u8) -> CanonicalType, +{ + if depth >= MAX_RESOLVE_DEPTH { + return CanonicalType::Opaque; + } + body(depth + 1) +} + +/// Pure dispatch over the `syn::Type` variants. Every arm delegates +/// (closure-hidden own calls keep this classified as an Operation). +fn dispatch_type(ty: &syn::Type, ctx: &ResolveContext<'_>, next: u8) -> CanonicalType { + let recurse = |t: &syn::Type| resolve_type_with_depth(t, ctx, next); + let into_slice = |inner: CanonicalType| CanonicalType::Slice(Box::new(inner)); + match ty { + syn::Type::Reference(r) => recurse(&r.elem), + syn::Type::Paren(p) => recurse(&p.elem), + syn::Type::Path(tp) => resolve_path(&tp.path, ctx, next), + syn::Type::Array(a) => into_slice(recurse(&a.elem)), + syn::Type::Slice(s) => into_slice(recurse(&s.elem)), + syn::Type::TraitObject(tto) => resolve_trait_object(tto, ctx), + syn::Type::ImplTrait(_) => CanonicalType::Opaque, + _ => CanonicalType::Opaque, + } +} + +/// `dyn Trait + Send + 'static` → `TraitBound(["crate", "…", "Trait"])`. +/// Marker traits (`Send`, `Sync`, `Unpin`, `Copy`, `Clone`, etc.) and +/// lifetime bounds are skipped; the first non-marker trait wins. Yields +/// `Opaque` if no resolvable trait bound exists. Operation. +fn resolve_trait_object(tto: &syn::TypeTraitObject, ctx: &ResolveContext<'_>) -> CanonicalType { + for bound in &tto.bounds { + let syn::TypeParamBound::Trait(trait_bound) = bound else { + continue; + }; + if is_marker_trait(&trait_bound.path) { + continue; + } + let segs: Vec = trait_bound + .path + .segments + .iter() + .map(|s| s.ident.to_string()) + .collect(); + match canonicalise_type_segments( + &segs, + ctx.alias_map, + ctx.local_symbols, + ctx.crate_root_modules, + ctx.importing_file, + ) { + Some(resolved) => return CanonicalType::TraitBound(resolved), + None => return CanonicalType::Opaque, + } + } + CanonicalType::Opaque +} + +/// Marker traits (plus common auto-derive names) that are skipped when +/// picking the dispatch-relevant trait from a `dyn T1 + T2` bound set. +/// Kept as a const so the list is greppable and easy to extend. +const MARKER_TRAITS: &[&str] = &[ + "Send", "Sync", "Unpin", "Copy", "Clone", "Sized", "Debug", "Display", +]; + +/// Skip marker traits when picking the dispatch-relevant trait from +/// `dyn T1 + T2`. Operation: lookup table. +fn is_marker_trait(path: &syn::Path) -> bool { + let Some(last) = path.segments.last() else { + return false; + }; + let name = last.ident.to_string(); + MARKER_TRAITS.contains(&name.as_str()) +} + +/// Dispatch on the last path-segment's ident to recognise stdlib +/// wrappers. Falls through to `resolve_generic_path` for everything +/// else. Integration: closure-hidden own calls keep IOSP clean. +fn resolve_path(path: &syn::Path, ctx: &ResolveContext<'_>, depth: u8) -> CanonicalType { + let Some(last) = path.segments.last() else { + return CanonicalType::Opaque; + }; + let args = &last.arguments; + let wrap = |idx, ctor: fn(Box) -> CanonicalType| { + wrap_generic(args, idx, ctx, depth, ctor) + }; + let peel = || peel_single_generic(args, ctx, depth); + let fallback = || resolve_generic_path(path, ctx, depth); + let name = last.ident.to_string(); + match name.as_str() { + "Result" => wrap(0, CanonicalType::Result), + "Option" => wrap(0, CanonicalType::Option), + "Future" => wrap(0, CanonicalType::Future), + "Vec" => wrap(0, CanonicalType::Slice), + "HashMap" | "BTreeMap" => wrap(1, CanonicalType::Map), + "Arc" | "Box" | "Rc" | "Cow" | "RwLock" | "Mutex" | "RefCell" | "Cell" => peel(), + _ if is_user_transparent(&name, ctx) => peel(), + _ => fallback(), + } +} + +/// Stage 3 — check if `name` is a user-configured transparent wrapper. +/// Operation: set lookup with optional presence. +fn is_user_transparent(name: &str, ctx: &ResolveContext<'_>) -> bool { + ctx.transparent_wrappers + .is_some_and(|set| set.contains(name)) +} + +/// Build a wrapper variant from a recognized generic type at position +/// `idx`. If the argument is absent, returns `Opaque`. Operation: +/// closure-hidden recursion for IOSP leniency. +fn wrap_generic( + args: &syn::PathArguments, + idx: usize, + ctx: &ResolveContext<'_>, + depth: u8, + constructor: F, +) -> CanonicalType +where + F: FnOnce(Box) -> CanonicalType, +{ + let recurse = |t: &syn::Type| resolve_type_with_depth(t, ctx, depth + 1); + match generic_type_arg(args, idx) { + Some(inner) => constructor(Box::new(recurse(inner))), + None => CanonicalType::Opaque, + } +} + +/// Peel a transparent single-type-param wrapper (Arc / Box / Rc / Cow / +/// RwLock / Mutex / RefCell / Cell) by recursing into its first generic +/// argument. Operation. +fn peel_single_generic( + args: &syn::PathArguments, + ctx: &ResolveContext<'_>, + depth: u8, +) -> CanonicalType { + let recurse = |t: &syn::Type| resolve_type_with_depth(t, ctx, depth + 1); + match generic_type_arg(args, 0) { + Some(inner) => recurse(inner), + None => CanonicalType::Opaque, + } +} + +/// Resolve a non-wrapper path through the shared canonicalisation +/// pipeline (alias map / local symbols / crate roots). Returns `Opaque` +/// for unresolvable names (external, generic parameter, unknown). If +/// the canonicalised name matches a recorded workspace type-alias, the +/// alias target is recursively resolved. Operation: closure-hidden calls. +fn resolve_generic_path(path: &syn::Path, ctx: &ResolveContext<'_>, depth: u8) -> CanonicalType { + let recurse = |t: &syn::Type| resolve_type_with_depth(t, ctx, depth + 1); + let canonicalise = |segs: &[String]| { + canonicalise_type_segments( + segs, + ctx.alias_map, + ctx.local_symbols, + ctx.crate_root_modules, + ctx.importing_file, + ) + }; + let segments: Vec = path.segments.iter().map(|s| s.ident.to_string()).collect(); + let Some(resolved) = canonicalise(&segments) else { + return CanonicalType::Opaque; + }; + let key = resolved.join("::"); + if let Some(aliased) = ctx.type_aliases.and_then(|m| m.get(&key)) { + return recurse(aliased); + } + CanonicalType::Path(resolved) +} + +/// Extract the type at position `idx` from angle-bracketed generic args. +/// Lifetimes / const args are skipped; only type args count. +fn generic_type_arg(args: &syn::PathArguments, idx: usize) -> Option<&syn::Type> { + let syn::PathArguments::AngleBracketed(ab) = args else { + return None; + }; + ab.args + .iter() + .filter_map(|a| match a { + syn::GenericArgument::Type(t) => Some(t), + _ => None, + }) + .nth(idx) +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/canonical.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/canonical.rs new file mode 100644 index 0000000..3c3bc8f --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/canonical.rs @@ -0,0 +1,66 @@ +//! Unit tests for the `CanonicalType` vocabulary. + +use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::CanonicalType; + +#[test] +fn test_path_constructor_from_string_slices() { + let t = CanonicalType::path(["crate", "app", "Session"]); + assert_eq!( + t, + CanonicalType::Path(vec![ + "crate".to_string(), + "app".to_string(), + "Session".to_string() + ]) + ); +} + +#[test] +fn test_path_constructor_from_owned_strings() { + let t = CanonicalType::path(vec!["crate".to_string(), "foo".to_string()]); + assert!(matches!(t, CanonicalType::Path(_))); +} + +#[test] +fn test_is_opaque_detects_opaque() { + assert!(CanonicalType::Opaque.is_opaque()); + assert!(!CanonicalType::path(["crate", "X"]).is_opaque()); +} + +#[test] +fn test_happy_inner_unwraps_result() { + let inner = CanonicalType::path(["crate", "X"]); + let wrapped = CanonicalType::Result(Box::new(inner.clone())); + assert_eq!(wrapped.happy_inner(), Some(&inner)); +} + +#[test] +fn test_happy_inner_unwraps_option() { + let inner = CanonicalType::path(["crate", "X"]); + let wrapped = CanonicalType::Option(Box::new(inner.clone())); + assert_eq!(wrapped.happy_inner(), Some(&inner)); +} + +#[test] +fn test_happy_inner_unwraps_future() { + let inner = CanonicalType::path(["crate", "X"]); + let wrapped = CanonicalType::Future(Box::new(inner.clone())); + assert_eq!(wrapped.happy_inner(), Some(&inner)); +} + +#[test] +fn test_happy_inner_none_on_path() { + let t = CanonicalType::path(["crate", "X"]); + assert_eq!(t.happy_inner(), None); +} + +#[test] +fn test_happy_inner_none_on_opaque() { + assert_eq!(CanonicalType::Opaque.happy_inner(), None); +} + +#[test] +fn test_happy_inner_none_on_slice() { + let t = CanonicalType::Slice(Box::new(CanonicalType::path(["T"]))); + assert_eq!(t.happy_inner(), None); +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs new file mode 100644 index 0000000..a1c528d --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs @@ -0,0 +1,203 @@ +//! Tests for the stdlib-combinator return-type table. +//! +//! Each stdlib wrapper gets positive tests (method resolves to expected +//! return type) and negative tests (closure-dependent methods stay +//! unresolved). + +use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::{ + combinator_return, CanonicalType, +}; + +fn t() -> CanonicalType { + CanonicalType::path(["crate", "app", "T"]) +} + +// ── Result ───────────────────────────────────────────────── + +#[test] +fn test_result_unwrap_yields_t() { + let res = CanonicalType::Result(Box::new(t())); + assert_eq!(combinator_return(&res, "unwrap"), Some(t())); +} + +#[test] +fn test_result_expect_yields_t() { + let res = CanonicalType::Result(Box::new(t())); + assert_eq!(combinator_return(&res, "expect"), Some(t())); +} + +#[test] +fn test_result_unwrap_or_yields_t() { + let res = CanonicalType::Result(Box::new(t())); + assert_eq!(combinator_return(&res, "unwrap_or"), Some(t())); + assert_eq!(combinator_return(&res, "unwrap_or_else"), Some(t())); + assert_eq!(combinator_return(&res, "unwrap_or_default"), Some(t())); +} + +#[test] +fn test_result_ok_yields_option_t() { + let res = CanonicalType::Result(Box::new(t())); + assert_eq!( + combinator_return(&res, "ok"), + Some(CanonicalType::Option(Box::new(t()))) + ); +} + +#[test] +fn test_result_err_yields_option_opaque() { + let res = CanonicalType::Result(Box::new(t())); + assert_eq!( + combinator_return(&res, "err"), + Some(CanonicalType::Option(Box::new(CanonicalType::Opaque))) + ); +} + +#[test] +fn test_result_map_err_preserves_ok_type() { + let res = CanonicalType::Result(Box::new(t())); + assert_eq!( + combinator_return(&res, "map_err"), + Some(CanonicalType::Result(Box::new(t()))) + ); +} + +#[test] +fn test_result_or_else_preserves_ok_type() { + let res = CanonicalType::Result(Box::new(t())); + assert_eq!( + combinator_return(&res, "or_else"), + Some(CanonicalType::Result(Box::new(t()))) + ); +} + +#[test] +fn test_result_map_is_unresolved() { + // `.map(|x| ...)` depends on the closure — unresolved by design. + let res = CanonicalType::Result(Box::new(t())); + assert_eq!(combinator_return(&res, "map"), None); +} + +#[test] +fn test_result_and_then_is_unresolved() { + let res = CanonicalType::Result(Box::new(t())); + assert_eq!(combinator_return(&res, "and_then"), None); +} + +#[test] +fn test_result_unknown_method_is_none() { + let res = CanonicalType::Result(Box::new(t())); + assert_eq!(combinator_return(&res, "totally_made_up"), None); +} + +// ── Option ──────────────────────────────────────────────────── + +#[test] +fn test_option_unwrap_yields_t() { + let opt = CanonicalType::Option(Box::new(t())); + assert_eq!(combinator_return(&opt, "unwrap"), Some(t())); +} + +#[test] +fn test_option_unwrap_or_yields_t() { + let opt = CanonicalType::Option(Box::new(t())); + assert_eq!(combinator_return(&opt, "unwrap_or"), Some(t())); + assert_eq!(combinator_return(&opt, "unwrap_or_else"), Some(t())); + assert_eq!(combinator_return(&opt, "unwrap_or_default"), Some(t())); +} + +#[test] +fn test_option_ok_or_yields_result_t() { + let opt = CanonicalType::Option(Box::new(t())); + assert_eq!( + combinator_return(&opt, "ok_or"), + Some(CanonicalType::Result(Box::new(t()))) + ); + assert_eq!( + combinator_return(&opt, "ok_or_else"), + Some(CanonicalType::Result(Box::new(t()))) + ); +} + +#[test] +fn test_option_preserve_wrapper_methods() { + let opt = CanonicalType::Option(Box::new(t())); + for method in [ + "or", "or_else", "filter", "take", "replace", "as_ref", "as_mut", "cloned", "copied", + ] { + assert_eq!( + combinator_return(&opt, method), + Some(CanonicalType::Option(Box::new(t()))), + "method: {}", + method + ); + } +} + +#[test] +fn test_option_map_is_unresolved() { + let opt = CanonicalType::Option(Box::new(t())); + assert_eq!(combinator_return(&opt, "map"), None); +} + +#[test] +fn test_option_and_then_is_unresolved() { + let opt = CanonicalType::Option(Box::new(t())); + assert_eq!(combinator_return(&opt, "and_then"), None); +} + +// ── Non-wrapper receivers ──────────────────────────────────────── + +#[test] +fn test_path_receiver_is_none() { + // Non-wrapper type — combinator table doesn't apply. + assert_eq!(combinator_return(&t(), "unwrap"), None); +} + +#[test] +fn test_opaque_receiver_is_none() { + assert_eq!(combinator_return(&CanonicalType::Opaque, "unwrap"), None); +} + +#[test] +fn test_slice_receiver_is_none() { + let slice = CanonicalType::Slice(Box::new(t())); + assert_eq!(combinator_return(&slice, "iter"), None); +} + +// ── End-to-end: chain via Result combinator ────────────────────── + +#[test] +fn test_result_chain_unwrap_then_field() { + // Verifies that combinator lookup produces a `Path` the next layer + // of inference can index. This is the rlm-bug unblocking pattern. + use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::{ + infer_type, FlatBindings, InferContext, WorkspaceTypeIndex, + }; + use std::collections::{HashMap, HashSet}; + + let mut index = WorkspaceTypeIndex::new(); + index.struct_fields.insert( + ("crate::app::Session".to_string(), "id".to_string()), + CanonicalType::path(["crate", "app", "Id"]), + ); + let mut bindings = FlatBindings::new(); + bindings.insert( + "res", + CanonicalType::Result(Box::new(CanonicalType::path(["crate", "app", "Session"]))), + ); + let alias_map = HashMap::new(); + let local_symbols = HashSet::new(); + let crate_roots = HashSet::new(); + let ctx = InferContext { + workspace: &index, + alias_map: &alias_map, + local_symbols: &local_symbols, + crate_root_modules: &crate_roots, + importing_file: "src/app/test.rs", + bindings: &bindings, + self_type: None, + }; + let expr: syn::Expr = syn::parse_str("res.unwrap().id").expect("parse"); + let t = infer_type(&expr, &ctx).expect("chain resolved"); + assert_eq!(t, CanonicalType::path(["crate", "app", "Id"])); +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_access.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_access.rs new file mode 100644 index 0000000..f01822e --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_access.rs @@ -0,0 +1,226 @@ +//! Tests for `infer_field`, `infer_try`, `infer_await`, `infer_cast`, +//! `infer_unary`, and the transparent Paren/Reference/Group wrappers. + +use super::support::TypeInferFixture; +use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::{ + infer_type, CanonicalType, +}; + +fn infer(f: &TypeInferFixture, src: &str) -> Option { + let expr: syn::Expr = syn::parse_str(src).ok()?; + infer_type(&expr, &f.ctx()) +} + +// ── Field access ───────────────────────────────────────────────── + +#[test] +fn test_field_access_on_bound_struct() { + let mut f = TypeInferFixture::new(); + f.bindings + .insert("ctx", CanonicalType::path(["crate", "app", "Ctx"])); + f.index.struct_fields.insert( + ("crate::app::Ctx".to_string(), "session".to_string()), + CanonicalType::path(["crate", "app", "Session"]), + ); + let t = infer(&f, "ctx.session").expect("field resolved"); + assert_eq!(t, CanonicalType::path(["crate", "app", "Session"])); +} + +#[test] +fn test_nested_field_access() { + let mut f = TypeInferFixture::new(); + f.bindings + .insert("ctx", CanonicalType::path(["crate", "app", "Ctx"])); + f.index.struct_fields.insert( + ("crate::app::Ctx".to_string(), "session".to_string()), + CanonicalType::path(["crate", "app", "Session"]), + ); + f.index.struct_fields.insert( + ("crate::app::Session".to_string(), "id".to_string()), + CanonicalType::path(["crate", "app", "Id"]), + ); + let t = infer(&f, "ctx.session.id").expect("nested field resolved"); + assert_eq!(t, CanonicalType::path(["crate", "app", "Id"])); +} + +#[test] +fn test_field_access_unknown_field_is_none() { + let mut f = TypeInferFixture::new(); + f.bindings + .insert("ctx", CanonicalType::path(["crate", "app", "Ctx"])); + assert!(infer(&f, "ctx.missing").is_none()); +} + +#[test] +fn test_field_access_on_opaque_is_none() { + let mut f = TypeInferFixture::new(); + f.bindings.insert("x", CanonicalType::Opaque); + assert!(infer(&f, "x.field").is_none()); +} + +#[test] +fn test_tuple_field_is_none() { + let mut f = TypeInferFixture::new(); + f.bindings + .insert("x", CanonicalType::path(["crate", "app", "T"])); + // Unnamed (tuple) members aren't indexed. + assert!(infer(&f, "x.0").is_none()); +} + +// ── Try (?) ────────────────────────────────────────────────────── + +#[test] +fn test_try_on_result_unwraps_ok() { + let mut f = TypeInferFixture::new(); + f.bindings.insert( + "res", + CanonicalType::Result(Box::new(CanonicalType::path(["crate", "app", "T"]))), + ); + let t = infer(&f, "res?").expect("try resolved"); + assert_eq!(t, CanonicalType::path(["crate", "app", "T"])); +} + +#[test] +fn test_try_on_option_unwraps_some() { + let mut f = TypeInferFixture::new(); + f.bindings.insert( + "opt", + CanonicalType::Option(Box::new(CanonicalType::path(["crate", "app", "T"]))), + ); + let t = infer(&f, "opt?").expect("try on option"); + assert_eq!(t, CanonicalType::path(["crate", "app", "T"])); +} + +#[test] +fn test_try_on_non_wrapper_is_none() { + let mut f = TypeInferFixture::new(); + f.bindings + .insert("x", CanonicalType::path(["crate", "app", "T"])); + assert!(infer(&f, "x?").is_none()); +} + +// ── Await ──────────────────────────────────────────────────────── + +#[test] +fn test_await_on_future_unwraps_output() { + let mut f = TypeInferFixture::new(); + f.bindings.insert( + "fut", + CanonicalType::Future(Box::new(CanonicalType::path(["crate", "app", "T"]))), + ); + let t = infer(&f, "fut.await").expect("await resolved"); + assert_eq!(t, CanonicalType::path(["crate", "app", "T"])); +} + +#[test] +fn test_await_on_result_is_none() { + let mut f = TypeInferFixture::new(); + f.bindings.insert( + "res", + CanonicalType::Result(Box::new(CanonicalType::path(["crate", "app", "T"]))), + ); + // .await on Result is a compile error — resolver stays strict and + // returns None rather than unwrap it like ? would. + assert!(infer(&f, "res.await").is_none()); +} + +// ── Cast ───────────────────────────────────────────────────────── + +#[test] +fn test_cast_resolves_target_type() { + let mut f = TypeInferFixture::new(); + f.bindings + .insert("x", CanonicalType::path(["crate", "app", "Source"])); + f.local_symbols.insert("Target".to_string()); + let t = infer(&f, "x as Target").expect("cast resolved"); + assert_eq!(t, CanonicalType::path(["crate", "app", "test", "Target"])); +} + +#[test] +fn test_cast_to_unknown_is_none() { + let mut f = TypeInferFixture::new(); + f.bindings + .insert("x", CanonicalType::path(["crate", "app", "Source"])); + assert!(infer(&f, "x as external::Unknown").is_none()); +} + +// ── Unary ──────────────────────────────────────────────────────── + +#[test] +fn test_deref_is_transparent() { + let mut f = TypeInferFixture::new(); + f.bindings + .insert("x", CanonicalType::path(["crate", "app", "T"])); + let t = infer(&f, "*x").expect("deref resolved"); + assert_eq!(t, CanonicalType::path(["crate", "app", "T"])); +} + +#[test] +fn test_negation_returns_none() { + let mut f = TypeInferFixture::new(); + f.bindings + .insert("x", CanonicalType::path(["crate", "app", "T"])); + // `!x` yields bool, which we don't track. + assert!(infer(&f, "!x").is_none()); +} + +#[test] +fn test_numeric_negation_returns_none() { + let mut f = TypeInferFixture::new(); + f.bindings + .insert("x", CanonicalType::path(["crate", "app", "T"])); + // `-x` — numeric, not tracked. + assert!(infer(&f, "-x").is_none()); +} + +// ── Transparent wrappers: Paren, Reference, Group ──────────────── + +#[test] +fn test_paren_is_transparent() { + let mut f = TypeInferFixture::new(); + f.bindings + .insert("x", CanonicalType::path(["crate", "app", "T"])); + let t = infer(&f, "(x)").expect("paren resolved"); + assert_eq!(t, CanonicalType::path(["crate", "app", "T"])); +} + +#[test] +fn test_reference_strips() { + let mut f = TypeInferFixture::new(); + f.bindings + .insert("x", CanonicalType::path(["crate", "app", "T"])); + let t = infer(&f, "&x").expect("ref resolved"); + assert_eq!(t, CanonicalType::path(["crate", "app", "T"])); +} + +#[test] +fn test_mutable_reference_strips() { + let mut f = TypeInferFixture::new(); + f.bindings + .insert("x", CanonicalType::path(["crate", "app", "T"])); + let t = infer(&f, "&mut x").expect("mut ref resolved"); + assert_eq!(t, CanonicalType::path(["crate", "app", "T"])); +} + +// ── Unsupported expression forms return None ───────────────────── + +#[test] +fn test_closure_expression_is_none() { + let f = TypeInferFixture::new(); + // Closures aren't tracked in Stage 1. + assert!(infer(&f, "|x| x").is_none()); +} + +#[test] +fn test_if_expression_is_none() { + let f = TypeInferFixture::new(); + // Conditional expressions aren't tracked in Stage 1 (need unification). + assert!(infer(&f, "if true { 1 } else { 2 }").is_none()); +} + +#[test] +fn test_literal_expression_is_none() { + let f = TypeInferFixture::new(); + // Literals produce primitive types we don't track. + assert!(infer(&f, "42").is_none()); +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_call.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_call.rs new file mode 100644 index 0000000..0dcc772 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_call.rs @@ -0,0 +1,228 @@ +//! Tests for `infer_path_expr`, `infer_call`, and `infer_method_call`. + +use super::support::TypeInferFixture; +use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::{ + infer_type, CanonicalType, +}; + +fn infer(f: &TypeInferFixture, src: &str) -> Option { + let expr: syn::Expr = syn::parse_str(src).ok()?; + infer_type(&expr, &f.ctx()) +} + +// ── Path expressions (bare idents) ─────────────────────────────── + +#[test] +fn test_bare_ident_resolves_from_bindings() { + let mut f = TypeInferFixture::new(); + f.bindings + .insert("session", CanonicalType::path(["crate", "app", "Session"])); + let t = infer(&f, "session").expect("bound ident"); + assert_eq!(t, CanonicalType::path(["crate", "app", "Session"])); +} + +#[test] +fn test_bare_ident_not_in_bindings_is_none() { + let f = TypeInferFixture::new(); + assert!(infer(&f, "unknown").is_none()); +} + +#[test] +fn test_multi_segment_path_expr_is_none() { + let f = TypeInferFixture::new(); + // `crate::foo::BAR` as a standalone expression is a const/static ref + // which we don't track in Stage 1. + assert!(infer(&f, "crate::foo::BAR").is_none()); +} + +// ── Call: free fn ──────────────────────────────────────────────── + +#[test] +fn test_call_single_ident_resolves_via_fn_returns() { + let mut f = TypeInferFixture::new(); + f.local_symbols.insert("make_session".to_string()); + f.index.fn_returns.insert( + "crate::app::test::make_session".to_string(), + CanonicalType::path(["crate", "app", "Session"]), + ); + let t = infer(&f, "make_session()").expect("fn resolved"); + assert_eq!(t, CanonicalType::path(["crate", "app", "Session"])); +} + +#[test] +fn test_call_crate_path_resolves_via_fn_returns() { + let mut f = TypeInferFixture::new(); + f.index.fn_returns.insert( + "crate::app::make_session".to_string(), + CanonicalType::path(["crate", "app", "Session"]), + ); + let t = infer(&f, "crate::app::make_session()").expect("fn resolved"); + assert_eq!(t, CanonicalType::path(["crate", "app", "Session"])); +} + +#[test] +fn test_call_unknown_fn_is_none() { + let f = TypeInferFixture::new(); + assert!(infer(&f, "unknown_fn()").is_none()); +} + +// ── Call: associated fn (T::ctor) ───────────────────────────────── + +#[test] +fn test_call_type_ctor_resolves_via_method_returns() { + let mut f = TypeInferFixture::new(); + f.local_symbols.insert("Session".to_string()); + f.index.method_returns.insert( + ("crate::app::test::Session".to_string(), "open".to_string()), + CanonicalType::path(["crate", "app", "test", "Session"]), + ); + let t = infer(&f, "Session::open()").expect("assoc fn resolved"); + assert_eq!(t, CanonicalType::path(["crate", "app", "test", "Session"])); +} + +#[test] +fn test_call_ctor_via_alias() { + let mut f = TypeInferFixture::new(); + f.alias_map.insert( + "Session".to_string(), + vec![ + "crate".to_string(), + "app".to_string(), + "session".to_string(), + "Session".to_string(), + ], + ); + f.index.method_returns.insert( + ( + "crate::app::session::Session".to_string(), + "new".to_string(), + ), + CanonicalType::path(["crate", "app", "session", "Session"]), + ); + let t = infer(&f, "Session::new()").expect("alias resolved"); + assert_eq!( + t, + CanonicalType::path(["crate", "app", "session", "Session"]) + ); +} + +#[test] +fn test_call_ctor_returning_result() { + let mut f = TypeInferFixture::new(); + f.local_symbols.insert("Session".to_string()); + f.index.method_returns.insert( + ("crate::app::test::Session".to_string(), "open".to_string()), + CanonicalType::Result(Box::new(CanonicalType::path([ + "crate", "app", "test", "Session", + ]))), + ); + let t = infer(&f, "Session::open()").expect("ctor resolved"); + assert!(matches!(t, CanonicalType::Result(_))); +} + +// ── Call: Self:: substitution ──────────────────────────────────── + +#[test] +fn test_call_self_substitutes_to_impl_type() { + let mut f = TypeInferFixture::new(); + f.self_type = Some(vec![ + "crate".to_string(), + "app".to_string(), + "Session".to_string(), + ]); + f.index.method_returns.insert( + ("crate::app::Session".to_string(), "new".to_string()), + CanonicalType::path(["crate", "app", "Session"]), + ); + let t = infer(&f, "Self::new()").expect("Self::new resolved"); + assert_eq!(t, CanonicalType::path(["crate", "app", "Session"])); +} + +#[test] +fn test_call_self_without_self_type_is_none() { + let f = TypeInferFixture::new(); + assert!(infer(&f, "Self::new()").is_none()); +} + +// ── MethodCall ──────────────────────────────────────────────────── + +#[test] +fn test_method_call_with_bound_receiver() { + let mut f = TypeInferFixture::new(); + f.bindings + .insert("session", CanonicalType::path(["crate", "app", "Session"])); + f.index.method_returns.insert( + ("crate::app::Session".to_string(), "diff".to_string()), + CanonicalType::path(["crate", "app", "Response"]), + ); + let t = infer(&f, "session.diff()").expect("method resolved"); + assert_eq!(t, CanonicalType::path(["crate", "app", "Response"])); +} + +#[test] +fn test_method_call_chained_via_fn_return() { + let mut f = TypeInferFixture::new(); + f.local_symbols.insert("make_session".to_string()); + f.index.fn_returns.insert( + "crate::app::test::make_session".to_string(), + CanonicalType::path(["crate", "app", "Session"]), + ); + f.index.method_returns.insert( + ("crate::app::Session".to_string(), "diff".to_string()), + CanonicalType::path(["crate", "app", "Response"]), + ); + let t = infer(&f, "make_session().diff()").expect("chain resolved"); + assert_eq!(t, CanonicalType::path(["crate", "app", "Response"])); +} + +#[test] +fn test_method_call_stdlib_combinator_resolves() { + let mut f = TypeInferFixture::new(); + f.bindings.insert( + "res", + CanonicalType::Result(Box::new(CanonicalType::path(["crate", "app", "T"]))), + ); + // `.unwrap()` on Result → T via the combinator table. + let t = infer(&f, "res.unwrap()").expect("unwrap resolved"); + assert_eq!(t, CanonicalType::path(["crate", "app", "T"])); +} + +#[test] +fn test_method_call_closure_combinator_is_none() { + let mut f = TypeInferFixture::new(); + f.bindings.insert( + "res", + CanonicalType::Result(Box::new(CanonicalType::path(["crate", "app", "T"]))), + ); + // `.map(|x| ...)` depends on closure body — unresolved by design. + assert!(infer(&f, "res.map(|x| x)").is_none()); +} + +#[test] +fn test_method_call_on_opaque_receiver_is_none() { + let mut f = TypeInferFixture::new(); + f.bindings.insert("x", CanonicalType::Opaque); + assert!(infer(&f, "x.method()").is_none()); +} + +#[test] +fn test_method_call_unknown_method_is_none() { + let mut f = TypeInferFixture::new(); + f.bindings + .insert("x", CanonicalType::path(["crate", "app", "Session"])); + // No entry in method_returns for "bogus" on Session. + assert!(infer(&f, "x.bogus()").is_none()); +} + +#[test] +fn test_method_call_on_reference_strips_and_resolves() { + let mut f = TypeInferFixture::new(); + f.bindings + .insert("s", CanonicalType::path(["crate", "app", "Session"])); + f.index.method_returns.insert( + ("crate::app::Session".to_string(), "diff".to_string()), + CanonicalType::path(["crate", "app", "Response"]), + ); + let t = infer(&f, "(&s).diff()").expect("ref receiver resolved"); + assert_eq!(t, CanonicalType::path(["crate", "app", "Response"])); +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/mod.rs new file mode 100644 index 0000000..e190930 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/mod.rs @@ -0,0 +1,9 @@ +mod canonical; +mod combinators; +mod infer_access; +mod infer_call; +mod patterns_destructure; +mod patterns_iterator; +mod resolve; +mod support; +mod workspace_index; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs new file mode 100644 index 0000000..10eafd4 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs @@ -0,0 +1,234 @@ +//! Tests for `patterns::extract_bindings`. + +use super::support::{parse_pat, TypeInferFixture}; +use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::{ + extract_bindings, CanonicalType, +}; + +fn bindings( + f: &TypeInferFixture, + pat_src: &str, + matched: CanonicalType, +) -> Vec<(String, CanonicalType)> { + let pat = parse_pat(pat_src); + extract_bindings(&pat, &matched, &f.ctx()) +} + +// ── Pat::Ident ─────────────────────────────────────────────────── + +#[test] +fn test_ident_pattern_binds_full_type() { + let f = TypeInferFixture::new(); + let b = bindings(&f, "x", CanonicalType::path(["crate", "app", "T"])); + assert_eq!(b.len(), 1); + assert_eq!(b[0].0, "x"); + assert_eq!(b[0].1, CanonicalType::path(["crate", "app", "T"])); +} + +#[test] +fn test_wildcard_binds_nothing() { + let f = TypeInferFixture::new(); + assert!(bindings(&f, "_", CanonicalType::path(["crate", "T"])).is_empty()); +} + +// ── Pat::Type (explicit annotation) ────────────────────────────── + +#[test] +fn test_type_annotation_overrides_matched() { + let mut f = TypeInferFixture::new(); + f.local_symbols.insert("Session".to_string()); + // matched type says something else; annotation wins. + let b = bindings(&f, "x: Session", CanonicalType::Opaque); + assert_eq!(b.len(), 1); + assert_eq!(b[0].0, "x"); + assert_eq!( + b[0].1, + CanonicalType::path(["crate", "app", "test", "Session"]) + ); +} + +// ── Pat::Reference ─────────────────────────────────────────────── + +#[test] +fn test_reference_pattern_passes_type_through() { + let f = TypeInferFixture::new(); + let b = bindings(&f, "&x", CanonicalType::path(["crate", "app", "T"])); + assert_eq!(b.len(), 1); + assert_eq!(b[0].1, CanonicalType::path(["crate", "app", "T"])); +} + +#[test] +fn test_mutable_reference_pattern_passes_type_through() { + let f = TypeInferFixture::new(); + let b = bindings(&f, "&mut x", CanonicalType::path(["crate", "app", "T"])); + assert_eq!(b.len(), 1); + assert_eq!(b[0].0, "x"); +} + +// ── Pat::TupleStruct (Some / Ok / Err) ─────────────────────────── + +#[test] +fn test_some_pattern_unwraps_option() { + let f = TypeInferFixture::new(); + let opt = CanonicalType::Option(Box::new(CanonicalType::path(["crate", "app", "T"]))); + let b = bindings(&f, "Some(x)", opt); + assert_eq!(b.len(), 1); + assert_eq!(b[0].0, "x"); + assert_eq!(b[0].1, CanonicalType::path(["crate", "app", "T"])); +} + +#[test] +fn test_ok_pattern_unwraps_result() { + let f = TypeInferFixture::new(); + let res = CanonicalType::Result(Box::new(CanonicalType::path(["crate", "app", "T"]))); + let b = bindings(&f, "Ok(x)", res); + assert_eq!(b.len(), 1); + assert_eq!(b[0].0, "x"); + assert_eq!(b[0].1, CanonicalType::path(["crate", "app", "T"])); +} + +#[test] +fn test_err_pattern_binds_opaque() { + let f = TypeInferFixture::new(); + let res = CanonicalType::Result(Box::new(CanonicalType::path(["crate", "app", "T"]))); + let b = bindings(&f, "Err(e)", res); + assert_eq!(b.len(), 1); + assert_eq!(b[0].0, "e"); + // E-side is erased; binding exists but type is Opaque. + assert_eq!(b[0].1, CanonicalType::Opaque); +} + +#[test] +fn test_unknown_variant_binds_opaque() { + let f = TypeInferFixture::new(); + let matched = CanonicalType::path(["crate", "app", "MyEnum"]); + let b = bindings(&f, "MyVariant(x)", matched); + assert_eq!(b.len(), 1); + assert_eq!(b[0].1, CanonicalType::Opaque); +} + +#[test] +fn test_none_pattern_binds_nothing() { + let f = TypeInferFixture::new(); + let opt = CanonicalType::Option(Box::new(CanonicalType::path(["crate", "app", "T"]))); + assert!(bindings(&f, "None", opt).is_empty()); +} + +// ── Pat::Struct ────────────────────────────────────────────────── + +#[test] +fn test_struct_pattern_binds_field_by_name() { + let mut f = TypeInferFixture::new(); + f.index.struct_fields.insert( + ("crate::app::Ctx".to_string(), "session".to_string()), + CanonicalType::path(["crate", "app", "Session"]), + ); + let matched = CanonicalType::path(["crate", "app", "Ctx"]); + let b = bindings(&f, "Ctx { session }", matched); + assert_eq!(b.len(), 1); + assert_eq!(b[0].0, "session"); + assert_eq!(b[0].1, CanonicalType::path(["crate", "app", "Session"])); +} + +#[test] +fn test_struct_pattern_with_aliased_field() { + let mut f = TypeInferFixture::new(); + f.index.struct_fields.insert( + ("crate::app::Ctx".to_string(), "session".to_string()), + CanonicalType::path(["crate", "app", "Session"]), + ); + let matched = CanonicalType::path(["crate", "app", "Ctx"]); + let b = bindings(&f, "Ctx { session: s }", matched); + assert_eq!(b.len(), 1); + // The alias `s` is bound, not `session`. + assert_eq!(b[0].0, "s"); + assert_eq!(b[0].1, CanonicalType::path(["crate", "app", "Session"])); +} + +#[test] +fn test_struct_pattern_missing_field_binds_opaque() { + let mut f = TypeInferFixture::new(); + let matched = CanonicalType::path(["crate", "app", "Ctx"]); + // No entry for "unknown" — binding is still made but with Opaque. + let b = bindings(&f, "Ctx { unknown }", matched); + assert_eq!(b.len(), 1); + assert_eq!(b[0].1, CanonicalType::Opaque); + f.self_type = None; // silence unused mut warning +} + +#[test] +fn test_struct_pattern_with_rest() { + let mut f = TypeInferFixture::new(); + f.index.struct_fields.insert( + ("crate::app::Ctx".to_string(), "a".to_string()), + CanonicalType::path(["crate", "app", "A"]), + ); + let matched = CanonicalType::path(["crate", "app", "Ctx"]); + let b = bindings(&f, "Ctx { a, .. }", matched); + assert_eq!(b.len(), 1); + assert_eq!(b[0].0, "a"); +} + +// ── Pat::Tuple ─────────────────────────────────────────────────── + +#[test] +fn test_tuple_pattern_yields_opaque_bindings() { + let f = TypeInferFixture::new(); + // We don't track tuple types, so each element gets Opaque. + let b = bindings(&f, "(a, b)", CanonicalType::Opaque); + assert_eq!(b.len(), 2); + assert_eq!(b[0].0, "a"); + assert_eq!(b[0].1, CanonicalType::Opaque); + assert_eq!(b[1].0, "b"); +} + +// ── Pat::Slice ─────────────────────────────────────────────────── + +#[test] +fn test_slice_pattern_distributes_element_type() { + let f = TypeInferFixture::new(); + let vec_type = CanonicalType::Slice(Box::new(CanonicalType::path(["crate", "T"]))); + let b = bindings(&f, "[first, second]", vec_type); + assert_eq!(b.len(), 2); + assert_eq!(b[0].1, CanonicalType::path(["crate", "T"])); + assert_eq!(b[1].1, CanonicalType::path(["crate", "T"])); +} + +#[test] +fn test_slice_pattern_skips_rest() { + let f = TypeInferFixture::new(); + let vec_type = CanonicalType::Slice(Box::new(CanonicalType::path(["crate", "T"]))); + let b = bindings(&f, "[first, ..]", vec_type); + // Only `first` is bound; `..` rest is skipped. + assert_eq!(b.len(), 1); + assert_eq!(b[0].0, "first"); +} + +// ── Pat::Or ────────────────────────────────────────────────────── + +#[test] +fn test_or_pattern_uses_first_branch_bindings() { + let f = TypeInferFixture::new(); + // Conservatively take first branch's bindings. + let matched = CanonicalType::path(["crate", "T"]); + let b = bindings(&f, "a | b", matched); + assert_eq!(b.len(), 1); + assert_eq!(b[0].0, "a"); +} + +// ── Nested patterns ────────────────────────────────────────────── + +#[test] +fn test_nested_some_struct() { + let mut f = TypeInferFixture::new(); + f.index.struct_fields.insert( + ("crate::app::Ctx".to_string(), "id".to_string()), + CanonicalType::path(["crate", "app", "Id"]), + ); + // matched: Option; pattern unwraps Some to Ctx then binds id. + let opt = CanonicalType::Option(Box::new(CanonicalType::path(["crate", "app", "Ctx"]))); + let b = bindings(&f, "Some(Ctx { id })", opt); + assert_eq!(b.len(), 1); + assert_eq!(b[0].0, "id"); + assert_eq!(b[0].1, CanonicalType::path(["crate", "app", "Id"])); +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_iterator.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_iterator.rs new file mode 100644 index 0000000..c59560e --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_iterator.rs @@ -0,0 +1,66 @@ +//! Tests for `patterns::extract_for_bindings`. + +use super::support::{parse_pat, TypeInferFixture}; +use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::{ + extract_for_bindings, CanonicalType, +}; + +fn for_bindings( + f: &TypeInferFixture, + pat_src: &str, + iter: CanonicalType, +) -> Vec<(String, CanonicalType)> { + let pat = parse_pat(pat_src); + extract_for_bindings(&pat, &iter, &f.ctx()) +} + +#[test] +fn test_for_over_slice_binds_element_type() { + let f = TypeInferFixture::new(); + let vec_type = CanonicalType::Slice(Box::new(CanonicalType::path(["crate", "T"]))); + let b = for_bindings(&f, "x", vec_type); + assert_eq!(b.len(), 1); + assert_eq!(b[0].0, "x"); + assert_eq!(b[0].1, CanonicalType::path(["crate", "T"])); +} + +#[test] +fn test_for_over_map_yields_opaque_pair() { + let f = TypeInferFixture::new(); + let map_type = CanonicalType::Map(Box::new(CanonicalType::path(["crate", "V"]))); + // HashMap yields (&K, &V) — we don't track tuples, so destructuring + // gives Opaque (though tuple binding shape is preserved). + let b = for_bindings(&f, "(k, v)", map_type); + assert_eq!(b.len(), 2); + assert_eq!(b[0].1, CanonicalType::Opaque); + assert_eq!(b[1].1, CanonicalType::Opaque); +} + +#[test] +fn test_for_over_opaque_binds_opaque() { + let f = TypeInferFixture::new(); + let b = for_bindings(&f, "item", CanonicalType::Opaque); + assert_eq!(b.len(), 1); + assert_eq!(b[0].1, CanonicalType::Opaque); +} + +#[test] +fn test_for_with_destructuring_pattern() { + let mut f = TypeInferFixture::new(); + f.index.struct_fields.insert( + ("crate::app::Handler".to_string(), "id".to_string()), + CanonicalType::path(["crate", "app", "Id"]), + ); + let vec_type = CanonicalType::Slice(Box::new(CanonicalType::path(["crate", "app", "Handler"]))); + let b = for_bindings(&f, "Handler { id }", vec_type); + assert_eq!(b.len(), 1); + assert_eq!(b[0].0, "id"); + assert_eq!(b[0].1, CanonicalType::path(["crate", "app", "Id"])); +} + +#[test] +fn test_for_with_wildcard_binds_nothing() { + let f = TypeInferFixture::new(); + let vec_type = CanonicalType::Slice(Box::new(CanonicalType::path(["crate", "T"]))); + assert!(for_bindings(&f, "_", vec_type).is_empty()); +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs new file mode 100644 index 0000000..0cbc4a8 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs @@ -0,0 +1,229 @@ +//! Unit tests for `syn::Type` → `CanonicalType` conversion. +//! +//! The `resolve` module is `pub(super)` — these tests live in the same +//! crate and reach it via the in-crate module path. + +use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::canonical::CanonicalType; +use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::resolve::{ + resolve_type, ResolveContext, +}; +use std::collections::{HashMap, HashSet}; + +fn parse_type(src: &str) -> syn::Type { + syn::parse_str(src).expect("parse type") +} + +fn ctx<'a>( + alias_map: &'a HashMap>, + local_symbols: &'a HashSet, + crate_root_modules: &'a HashSet, + importing_file: &'a str, +) -> ResolveContext<'a> { + ResolveContext { + alias_map, + local_symbols, + crate_root_modules, + importing_file, + type_aliases: None, + transparent_wrappers: None, + } +} + +#[test] +fn test_bare_path_resolves_via_local_symbols() { + let alias_map = HashMap::new(); + let mut local = HashSet::new(); + local.insert("Session".to_string()); + let roots = HashSet::new(); + let ty = parse_type("Session"); + let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/app/session.rs")); + assert_eq!( + resolved, + CanonicalType::path(["crate", "app", "session", "Session"]) + ); +} + +#[test] +fn test_reference_type_strips_and_recurses() { + let alias_map = HashMap::new(); + let mut local = HashSet::new(); + local.insert("Session".to_string()); + let roots = HashSet::new(); + let ty = parse_type("&Session"); + let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/app/session.rs")); + assert_eq!( + resolved, + CanonicalType::path(["crate", "app", "session", "Session"]) + ); +} + +#[test] +fn test_result_wraps_inner() { + let alias_map = HashMap::new(); + let mut local = HashSet::new(); + local.insert("Session".to_string()); + let roots = HashSet::new(); + let ty = parse_type("Result"); + let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/app/session.rs")); + match resolved { + CanonicalType::Result(inner) => { + assert_eq!( + *inner, + CanonicalType::path(["crate", "app", "session", "Session"]) + ); + } + other => panic!("expected Result(_), got {:?}", other), + } +} + +#[test] +fn test_option_wraps_inner() { + let alias_map = HashMap::new(); + let mut local = HashSet::new(); + local.insert("T".to_string()); + let roots = HashSet::new(); + let ty = parse_type("Option"); + let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/foo.rs")); + assert!(matches!(resolved, CanonicalType::Option(_))); +} + +#[test] +fn test_arc_is_stripped() { + let alias_map = HashMap::new(); + let mut local = HashSet::new(); + local.insert("Session".to_string()); + let roots = HashSet::new(); + let ty = parse_type("Arc"); + let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/app/session.rs")); + assert_eq!( + resolved, + CanonicalType::path(["crate", "app", "session", "Session"]) + ); +} + +#[test] +fn test_nested_wrappers_strip_to_inner() { + let alias_map = HashMap::new(); + let mut local = HashSet::new(); + local.insert("Session".to_string()); + let roots = HashSet::new(); + let ty = parse_type("Arc>"); + let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/app/session.rs")); + assert_eq!( + resolved, + CanonicalType::path(["crate", "app", "session", "Session"]) + ); +} + +#[test] +fn test_vec_becomes_slice() { + let alias_map = HashMap::new(); + let mut local = HashSet::new(); + local.insert("Handler".to_string()); + let roots = HashSet::new(); + let ty = parse_type("Vec"); + let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/foo.rs")); + assert!(matches!(resolved, CanonicalType::Slice(_))); +} + +#[test] +fn test_hashmap_keeps_value_type() { + let alias_map = HashMap::new(); + let mut local = HashSet::new(); + local.insert("Handler".to_string()); + let roots = HashSet::new(); + let ty = parse_type("HashMap"); + let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/foo.rs")); + match resolved { + CanonicalType::Map(inner) => { + assert_eq!(*inner, CanonicalType::path(["crate", "foo", "Handler"])); + } + other => panic!("expected Map(_), got {:?}", other), + } +} + +#[test] +fn test_array_becomes_slice() { + let alias_map = HashMap::new(); + let mut local = HashSet::new(); + local.insert("T".to_string()); + let roots = HashSet::new(); + let ty = parse_type("[T; 4]"); + let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/foo.rs")); + assert!(matches!(resolved, CanonicalType::Slice(_))); +} + +#[test] +fn test_slice_type_becomes_slice() { + let alias_map = HashMap::new(); + let mut local = HashSet::new(); + local.insert("T".to_string()); + let roots = HashSet::new(); + let ty = parse_type("&[T]"); + let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/foo.rs")); + assert!(matches!(resolved, CanonicalType::Slice(_))); +} + +#[test] +fn test_trait_object_is_opaque() { + let alias_map = HashMap::new(); + let local = HashSet::new(); + let roots = HashSet::new(); + let ty = parse_type("Box"); + let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/foo.rs")); + // Box → strip Box → dyn T → Opaque + assert_eq!(resolved, CanonicalType::Opaque); +} + +#[test] +fn test_impl_trait_is_opaque() { + let alias_map = HashMap::new(); + let local = HashSet::new(); + let roots = HashSet::new(); + let ty = parse_type("impl Iterator"); + let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/foo.rs")); + assert_eq!(resolved, CanonicalType::Opaque); +} + +#[test] +fn test_unknown_external_path_is_opaque() { + let alias_map = HashMap::new(); + let local = HashSet::new(); + let roots = HashSet::new(); + let ty = parse_type("external_crate::UnknownType"); + let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/foo.rs")); + assert_eq!(resolved, CanonicalType::Opaque); +} + +#[test] +fn test_aliased_path_resolves_via_alias_map() { + let mut alias_map = HashMap::new(); + alias_map.insert( + "Session".to_string(), + vec![ + "crate".to_string(), + "app".to_string(), + "session".to_string(), + "Session".to_string(), + ], + ); + let local = HashSet::new(); + let roots = HashSet::new(); + let ty = parse_type("Session"); + let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/cli/handlers.rs")); + assert_eq!( + resolved, + CanonicalType::path(["crate", "app", "session", "Session"]) + ); +} + +#[test] +fn test_future_wraps_output() { + let alias_map = HashMap::new(); + let mut local = HashSet::new(); + local.insert("Response".to_string()); + let roots = HashSet::new(); + let ty = parse_type("Future"); + let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/foo.rs")); + assert!(matches!(resolved, CanonicalType::Future(_))); +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/support.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/support.rs new file mode 100644 index 0000000..2b1ed3e --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/support.rs @@ -0,0 +1,82 @@ +//! Shared test fixture for `infer_type` and friends. +//! +//! `TypeInferFixture` owns the borrowed inputs the inference engine +//! needs (workspace index, alias map, local symbols, crate roots, +//! bindings, file path, self-type). Tests mutate its public fields +//! directly — no `&mut self` helper methods, which keeps the struct +//! SRP-clean and NMS-compliant. + +use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::{ + BindingLookup, FlatBindings, InferContext, WorkspaceTypeIndex, +}; +use std::collections::{HashMap, HashSet}; + +/// Parse a Rust pattern source string into `syn::Pat`. Tries `let …` +/// first (supports `x: T` annotations) and falls back to a `match` arm +/// (supports refutable patterns like `None`, `a | b`). +pub(super) fn parse_pat(src: &str) -> syn::Pat { + if let Ok(pat) = parse_pat_as_let(src) { + return pat; + } + parse_pat_as_match_arm(src) +} + +fn parse_pat_as_let(src: &str) -> Result { + let wrapped = format!("fn __t() {{ let {} = _todo; }}", src); + let file: syn::File = syn::parse_str(&wrapped).map_err(|_| ())?; + let syn::Item::Fn(item_fn) = &file.items[0] else { + return Err(()); + }; + let syn::Stmt::Local(local) = &item_fn.block.stmts[0] else { + return Err(()); + }; + Ok(local.pat.clone()) +} + +fn parse_pat_as_match_arm(src: &str) -> syn::Pat { + let wrapped = format!("fn __t() {{ match _x {{ {} => () }} }}", src); + let file: syn::File = syn::parse_str(&wrapped).expect("parse wrapper"); + let syn::Item::Fn(item_fn) = &file.items[0] else { + panic!("expected fn") + }; + let syn::Stmt::Expr(syn::Expr::Match(m), _) = &item_fn.block.stmts[0] else { + panic!("expected match expr") + }; + m.arms[0].pat.clone() +} + +pub(super) struct TypeInferFixture { + pub index: WorkspaceTypeIndex, + pub alias_map: HashMap>, + pub local_symbols: HashSet, + pub crate_roots: HashSet, + pub bindings: FlatBindings, + pub file_path: String, + pub self_type: Option>, +} + +impl TypeInferFixture { + pub fn new() -> Self { + Self { + index: WorkspaceTypeIndex::new(), + alias_map: HashMap::new(), + local_symbols: HashSet::new(), + crate_roots: HashSet::new(), + bindings: FlatBindings::new(), + file_path: "src/app/test.rs".to_string(), + self_type: None, + } + } + + pub fn ctx(&self) -> InferContext<'_> { + InferContext { + workspace: &self.index, + alias_map: &self.alias_map, + local_symbols: &self.local_symbols, + crate_root_modules: &self.crate_roots, + importing_file: &self.file_path, + bindings: &self.bindings as &dyn BindingLookup, + self_type: self.self_type.clone(), + } + } +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs new file mode 100644 index 0000000..fc46219 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs @@ -0,0 +1,519 @@ +//! Integration tests for `WorkspaceTypeIndex` building. +//! +//! Covers struct-field, method-return, and free-fn-return collection +//! across single- and multi-file workspaces plus the cfg-test skip +//! behaviour. + +use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::{ + build_workspace_type_index, CanonicalType, +}; +use crate::adapters::shared::use_tree::gather_alias_map; +use std::collections::{HashMap, HashSet}; + +fn parse_file(src: &str) -> syn::File { + syn::parse_str(src).expect("parse file") +} + +struct WsFixture { + parsed: Vec<(String, syn::File)>, + aliases: HashMap>>, +} + +fn fixture(entries: &[(&str, &str)]) -> WsFixture { + let mut parsed = Vec::new(); + let mut aliases = HashMap::new(); + for (path, src) in entries { + let ast = parse_file(src); + aliases.insert(path.to_string(), gather_alias_map(&ast)); + parsed.push((path.to_string(), ast)); + } + WsFixture { parsed, aliases } +} + +fn borrowed(f: &WsFixture) -> Vec<(&str, &syn::File)> { + f.parsed.iter().map(|(p, a)| (p.as_str(), a)).collect() +} + +fn crate_roots(paths: &[&str]) -> HashSet { + paths + .iter() + .filter_map(|p| { + let rest = p.strip_prefix("src/")?; + let first = rest.split('/').next()?; + let name = first.strip_suffix(".rs").unwrap_or(first); + if matches!(name, "lib" | "main") { + None + } else { + Some(name.to_string()) + } + }) + .collect() +} + +// ── Empty / trivial ────────────────────────────────────────────── + +#[test] +fn test_empty_workspace_produces_empty_index() { + let fix = fixture(&[]); + let index = build_workspace_type_index( + &borrowed(&fix), + &fix.aliases, + &HashSet::new(), + &HashSet::new(), + &HashSet::new(), + ); + assert!(index.struct_fields.is_empty()); + assert!(index.method_returns.is_empty()); + assert!(index.fn_returns.is_empty()); +} + +// ── struct_fields ──────────────────────────────────────────────── + +#[test] +fn test_struct_with_named_field_is_indexed() { + // Field type must be a workspace-local type — stdlib `String` would + // resolve to `Opaque` (correct — stdlib isn't in our index) and get + // skipped by `record_field`. + let fix = fixture(&[( + "src/app/session.rs", + r#" + pub struct Id; + pub struct Session { pub id: Id } + "#, + )]); + let index = build_workspace_type_index( + &borrowed(&fix), + &fix.aliases, + &HashSet::new(), + &crate_roots(&["src/app/session.rs"]), + &HashSet::new(), + ); + let field = index.struct_field("crate::app::session::Session", "id"); + assert_eq!( + field, + Some(&CanonicalType::path(["crate", "app", "session", "Id"])) + ); +} + +#[test] +fn test_struct_field_with_arc_is_stripped() { + let fix = fixture(&[( + "src/app/context.rs", + r#" + pub struct Inner { pub v: u8 } + pub struct Ctx { pub inner: std::sync::Arc } + "#, + )]); + let index = build_workspace_type_index( + &borrowed(&fix), + &fix.aliases, + &HashSet::new(), + &crate_roots(&["src/app/context.rs"]), + &HashSet::new(), + ); + let field = index.struct_field("crate::app::context::Ctx", "inner"); + assert_eq!( + field, + Some(&CanonicalType::path(["crate", "app", "context", "Inner"])) + ); +} + +#[test] +fn test_tuple_struct_is_not_indexed() { + let fix = fixture(&[("src/app/foo.rs", "pub struct Id(pub String);")]); + let index = build_workspace_type_index( + &borrowed(&fix), + &fix.aliases, + &HashSet::new(), + &crate_roots(&["src/app/foo.rs"]), + &HashSet::new(), + ); + assert!(index.struct_fields.is_empty()); +} + +#[test] +fn test_struct_field_with_opaque_type_is_skipped() { + let fix = fixture(&[( + "src/app/foo.rs", + r#" + pub struct Ctx { pub x: external_crate::Unknown } + "#, + )]); + let index = build_workspace_type_index( + &borrowed(&fix), + &fix.aliases, + &HashSet::new(), + &crate_roots(&["src/app/foo.rs"]), + &HashSet::new(), + ); + assert!(index.struct_fields.is_empty()); +} + +// ── method_returns ─────────────────────────────────────────────── + +#[test] +fn test_inherent_method_with_concrete_return() { + let fix = fixture(&[( + "src/app/session.rs", + r#" + pub struct Session; + pub struct Response; + impl Session { + pub fn diff(&self) -> Response { Response } + } + "#, + )]); + let index = build_workspace_type_index( + &borrowed(&fix), + &fix.aliases, + &HashSet::new(), + &crate_roots(&["src/app/session.rs"]), + &HashSet::new(), + ); + let ret = index.method_return("crate::app::session::Session", "diff"); + assert_eq!( + ret, + Some(&CanonicalType::path([ + "crate", "app", "session", "Response" + ])) + ); +} + +#[test] +fn test_method_returning_result_wraps() { + let fix = fixture(&[( + "src/app/session.rs", + r#" + pub struct Session; + pub struct Response; + pub struct Error; + impl Session { + pub fn diff(&self) -> Result { unimplemented!() } + } + "#, + )]); + let index = build_workspace_type_index( + &borrowed(&fix), + &fix.aliases, + &HashSet::new(), + &crate_roots(&["src/app/session.rs"]), + &HashSet::new(), + ); + let ret = index + .method_return("crate::app::session::Session", "diff") + .expect("method indexed"); + match ret { + CanonicalType::Result(inner) => assert_eq!( + **inner, + CanonicalType::path(["crate", "app", "session", "Response"]) + ), + other => panic!("expected Result(_), got {:?}", other), + } +} + +#[test] +fn test_method_with_unit_return_is_not_indexed() { + let fix = fixture(&[( + "src/app/foo.rs", + r#" + pub struct S; + impl S { pub fn bump(&self) {} } + "#, + )]); + let index = build_workspace_type_index( + &borrowed(&fix), + &fix.aliases, + &HashSet::new(), + &crate_roots(&["src/app/foo.rs"]), + &HashSet::new(), + ); + assert!(index.method_returns.is_empty()); +} + +#[test] +fn test_method_with_impl_trait_return_is_not_indexed() { + let fix = fixture(&[( + "src/app/foo.rs", + r#" + pub struct S; + impl S { pub fn iter(&self) -> impl Iterator { std::iter::empty() } } + "#, + )]); + let index = build_workspace_type_index( + &borrowed(&fix), + &fix.aliases, + &HashSet::new(), + &crate_roots(&["src/app/foo.rs"]), + &HashSet::new(), + ); + assert!(index.method_returns.is_empty()); +} + +#[test] +fn test_trait_impl_method_is_indexed_by_receiver_type() { + let fix = fixture(&[( + "src/app/foo.rs", + r#" + pub struct S; + pub struct T; + pub trait Convert { fn to(&self) -> T; } + impl Convert for S { fn to(&self) -> T { T } } + "#, + )]); + let index = build_workspace_type_index( + &borrowed(&fix), + &fix.aliases, + &HashSet::new(), + &crate_roots(&["src/app/foo.rs"]), + &HashSet::new(), + ); + // Keyed by the concrete receiver type S, NOT by the trait. + let ret = index.method_return("crate::app::foo::S", "to"); + assert_eq!( + ret, + Some(&CanonicalType::path(["crate", "app", "foo", "T"])) + ); +} + +// ── fn_returns ─────────────────────────────────────────────────── + +#[test] +fn test_free_fn_return_is_indexed() { + let fix = fixture(&[( + "src/app/make.rs", + r#" + pub struct Session; + pub fn make_session() -> Session { Session } + "#, + )]); + let index = build_workspace_type_index( + &borrowed(&fix), + &fix.aliases, + &HashSet::new(), + &crate_roots(&["src/app/make.rs"]), + &HashSet::new(), + ); + let ret = index.fn_return("crate::app::make::make_session"); + assert_eq!( + ret, + Some(&CanonicalType::path(["crate", "app", "make", "Session"])) + ); +} + +#[test] +fn test_generic_return_type_is_opaque_and_not_indexed() { + let fix = fixture(&[( + "src/app/make.rs", + r#" + pub fn get() -> T { unimplemented!() } + "#, + )]); + let index = build_workspace_type_index( + &borrowed(&fix), + &fix.aliases, + &HashSet::new(), + &crate_roots(&["src/app/make.rs"]), + &HashSet::new(), + ); + // Generic T has no alias/local-symbol entry → Opaque → skipped. + assert!(index.fn_returns.is_empty()); +} + +#[test] +fn test_fn_with_unit_return_is_not_indexed() { + let fix = fixture(&[("src/app/foo.rs", "pub fn bump() {}")]); + let index = build_workspace_type_index( + &borrowed(&fix), + &fix.aliases, + &HashSet::new(), + &crate_roots(&["src/app/foo.rs"]), + &HashSet::new(), + ); + assert!(index.fn_returns.is_empty()); +} + +// ── cfg-test skip ──────────────────────────────────────────────── + +#[test] +fn test_cfg_test_file_is_skipped() { + let fix = fixture(&[( + "src/app/foo.rs", + r#" + pub struct S { pub x: u8 } + impl S { pub fn get(&self) -> u8 { self.x } } + pub fn build() -> S { S { x: 0 } } + "#, + )]); + let mut cfg_test = HashSet::new(); + cfg_test.insert("src/app/foo.rs".to_string()); + let index = build_workspace_type_index( + &borrowed(&fix), + &fix.aliases, + &cfg_test, + &crate_roots(&["src/app/foo.rs"]), + &HashSet::new(), + ); + assert!(index.struct_fields.is_empty()); + assert!(index.method_returns.is_empty()); + assert!(index.fn_returns.is_empty()); +} + +// ── multi-file ──────────────────────────────────────────────────── + +// ── trait_methods / trait_impls ─────────────────────────────────── + +#[test] +fn test_trait_declaration_methods_are_indexed() { + let fix = fixture(&[( + "src/app/ports.rs", + r#" + pub trait Handler { + fn handle(&self, msg: &str); + fn can_handle(&self, msg: &str) -> bool; + } + "#, + )]); + let index = build_workspace_type_index( + &borrowed(&fix), + &fix.aliases, + &HashSet::new(), + &crate_roots(&["src/app/ports.rs"]), + &HashSet::new(), + ); + assert!(index.trait_has_method("crate::app::ports::Handler", "handle")); + assert!(index.trait_has_method("crate::app::ports::Handler", "can_handle")); + assert!(!index.trait_has_method("crate::app::ports::Handler", "missing")); +} + +#[test] +fn test_trait_impl_is_indexed() { + let fix = fixture(&[( + "src/app/foo.rs", + r#" + pub struct MyImpl; + pub trait Handler { fn handle(&self); } + impl Handler for MyImpl { fn handle(&self) {} } + "#, + )]); + let index = build_workspace_type_index( + &borrowed(&fix), + &fix.aliases, + &HashSet::new(), + &crate_roots(&["src/app/foo.rs"]), + &HashSet::new(), + ); + let impls = index.impls_of_trait("crate::app::foo::Handler"); + assert!(impls.contains(&"crate::app::foo::MyImpl".to_string())); +} + +#[test] +fn test_multiple_impls_of_same_trait_all_indexed() { + let fix = fixture(&[( + "src/app/foo.rs", + r#" + pub trait Handler { fn handle(&self); } + pub struct A; + pub struct B; + pub struct C; + impl Handler for A { fn handle(&self) {} } + impl Handler for B { fn handle(&self) {} } + impl Handler for C { fn handle(&self) {} } + "#, + )]); + let index = build_workspace_type_index( + &borrowed(&fix), + &fix.aliases, + &HashSet::new(), + &crate_roots(&["src/app/foo.rs"]), + &HashSet::new(), + ); + let impls = index.impls_of_trait("crate::app::foo::Handler"); + assert_eq!(impls.len(), 3); +} + +#[test] +fn test_inherent_impl_does_not_populate_trait_impls() { + let fix = fixture(&[( + "src/app/foo.rs", + r#" + pub struct S; + impl S { pub fn method(&self) {} } + "#, + )]); + let index = build_workspace_type_index( + &borrowed(&fix), + &fix.aliases, + &HashSet::new(), + &crate_roots(&["src/app/foo.rs"]), + &HashSet::new(), + ); + // Inherent impl has no trait reference, so trait_impls stays empty. + assert!(index.trait_impls.is_empty()); +} + +#[test] +fn test_trait_in_one_file_impl_in_another() { + let fix = fixture(&[ + ( + "src/ports/handler.rs", + "pub trait Handler { fn handle(&self); }", + ), + ( + "src/app/session.rs", + r#" + use crate::ports::handler::Handler; + pub struct Session; + impl Handler for Session { fn handle(&self) {} } + "#, + ), + ]); + let index = build_workspace_type_index( + &borrowed(&fix), + &fix.aliases, + &HashSet::new(), + &crate_roots(&["src/ports/handler.rs", "src/app/session.rs"]), + &HashSet::new(), + ); + // Trait resolved via import alias. + let impls = index.impls_of_trait("crate::ports::handler::Handler"); + assert!(impls.contains(&"crate::app::session::Session".to_string())); +} + +#[test] +fn test_struct_in_one_file_impl_in_another() { + let fix = fixture(&[ + ( + "src/app/session.rs", + r#" + pub struct Id; + pub struct Session { pub id: Id } + "#, + ), + ( + "src/app/impls.rs", + r#" + use crate::app::session::{Session, Id}; + impl Session { + pub fn clone_id(&self) -> Id { Id } + } + "#, + ), + ]); + let index = build_workspace_type_index( + &borrowed(&fix), + &fix.aliases, + &HashSet::new(), + &crate_roots(&["src/app/session.rs", "src/app/impls.rs"]), + &HashSet::new(), + ); + // Struct indexed from its declaration file. + assert!(index + .struct_field("crate::app::session::Session", "id") + .is_some()); + // Method indexed from its impl file, keyed on the resolved + // self-type (`crate::app::session::Session` via alias map). + assert_eq!( + index.method_return("crate::app::session::Session", "clone_id"), + Some(&CanonicalType::path(["crate", "app", "session", "Id"])) + ); +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/aliases.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/aliases.rs new file mode 100644 index 0000000..7da7d7b --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/aliases.rs @@ -0,0 +1,48 @@ +//! Type-alias collection. +//! +//! For every top-level `type Alias = Target;` in the workspace, record +//! `canonical_Alias → Target` as a `syn::Type` clone. The inference +//! engine expands these on the fly when `resolve_type` encounters a +//! path whose canonical matches a recorded alias — useful for +//! `type Db = Arc>` style indirection that otherwise +//! leaves user handlers unresolved. + +use super::{canonical_type_key, BuildContext, WorkspaceTypeIndex}; +use crate::adapters::shared::cfg_test::has_cfg_test; +use syn::visit::Visit; + +/// Walk `ast` and populate `index.type_aliases`. Integration. +pub(super) fn collect_from_file( + index: &mut WorkspaceTypeIndex, + ctx: &BuildContext<'_>, + ast: &syn::File, +) { + let mut collector = AliasCollector { index, ctx }; + collector.visit_file(ast); +} + +struct AliasCollector<'i, 'c> { + index: &'i mut WorkspaceTypeIndex, + ctx: &'c BuildContext<'c>, +} + +impl<'ast, 'i, 'c> Visit<'ast> for AliasCollector<'i, 'c> { + fn visit_item_type(&mut self, node: &'ast syn::ItemType) { + let canonical = canonical_type_key(&[node.ident.to_string()], self.ctx); + self.index + .type_aliases + .insert(canonical, (*node.ty).clone()); + } + + fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) { + if has_cfg_test(&node.attrs) { + return; + } + syn::visit::visit_item_mod(self, node); + } + + fn visit_item_impl(&mut self, _: &'ast syn::ItemImpl) { + // Type aliases inside impl blocks are `ImplItem::Type`, not + // `Item::Type` — separate concern, not handled here. + } +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs new file mode 100644 index 0000000..7bfb563 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs @@ -0,0 +1,92 @@ +//! Struct-field-type collection. +//! +//! For every top-level `struct T { name: Type, … }` in the workspace, +//! record `(canonical_T, name) → CanonicalType(name's type)`. Tuple +//! structs, unit structs, and `Opaque` field types are skipped — they +//! contribute no value to later `self.field.method()` resolution. + +use super::super::canonical::CanonicalType; +use super::super::resolve::resolve_type; +use super::{resolve_ctx_from_build, BuildContext, WorkspaceTypeIndex}; +use crate::adapters::analyzers::architecture::forbidden_rule::file_to_module_segments; +use crate::adapters::shared::cfg_test::has_cfg_test; +use syn::visit::Visit; + +/// Walk `ast` and populate `index.struct_fields`. Uses `syn::visit::Visit` +/// so inline `#[cfg(test)]` modules are skipped but non-test inline mods +/// are traversed identically to the call-graph collector. +/// Integration: visitor delegates per-struct population. +pub(super) fn collect_from_file( + index: &mut WorkspaceTypeIndex, + ctx: &BuildContext<'_>, + ast: &syn::File, +) { + let mut collector = FieldCollector { index, ctx }; + collector.visit_file(ast); +} + +struct FieldCollector<'i, 'c> { + index: &'i mut WorkspaceTypeIndex, + ctx: &'c BuildContext<'c>, +} + +impl<'ast, 'i, 'c> Visit<'ast> for FieldCollector<'i, 'c> { + fn visit_item_struct(&mut self, node: &'ast syn::ItemStruct) { + record_struct(self.index, self.ctx, node); + } + + fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) { + if has_cfg_test(&node.attrs) { + return; + } + syn::visit::visit_item_mod(self, node); + } + + fn visit_item_impl(&mut self, _: &'ast syn::ItemImpl) { + // Structs inside impl blocks don't exist syntactically — skip the + // recursion so we don't waste walker cycles. + } +} + +/// Record every named field of `item`. Integration: canonicalisation + +/// per-field delegation. +fn record_struct(index: &mut WorkspaceTypeIndex, ctx: &BuildContext<'_>, item: &syn::ItemStruct) { + let canon = |name: &str| canonical_struct_name(name, ctx); + let canonical = canon(&item.ident.to_string()); + let syn::Fields::Named(named) = &item.fields else { + return; + }; + for field in &named.named { + record_field(index, &canonical, ctx, field); + } +} + +/// Insert one `(struct, field) → type` entry, dropping `Opaque` types. +/// Operation. Own call to `resolve_type` hidden in closure for IOSP. +fn record_field( + index: &mut WorkspaceTypeIndex, + canonical: &str, + ctx: &BuildContext<'_>, + field: &syn::Field, +) { + let resolve = |ty: &syn::Type| resolve_type(ty, &resolve_ctx_from_build(ctx)); + let Some(ident) = field.ident.as_ref() else { + return; + }; + let field_type = resolve(&field.ty); + if matches!(field_type, CanonicalType::Opaque) { + return; + } + index + .struct_fields + .insert((canonical.to_string(), ident.to_string()), field_type); +} + +/// Build `crate::::` from a file path + ident. +/// Operation: pure string construction. +fn canonical_struct_name(struct_ident: &str, ctx: &BuildContext<'_>) -> String { + let mut segs: Vec = vec!["crate".to_string()]; + segs.extend(file_to_module_segments(ctx.path)); + segs.push(struct_ident.to_string()); + segs.join("::") +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs new file mode 100644 index 0000000..41854c0 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs @@ -0,0 +1,71 @@ +//! Free-fn return-type collection. +//! +//! For every top-level `fn f(...) -> R` in the workspace (including +//! non-cfg-test inline modules), record `canonical_f → CanonicalType(R)`. +//! Methods (fns inside `impl` blocks) are indexed by `methods.rs`, not +//! here. Fns without an explicit return type, test fns, and `Opaque` +//! returns are skipped. + +use super::super::canonical::CanonicalType; +use super::super::resolve::resolve_type; +use super::{resolve_ctx_from_build, BuildContext, WorkspaceTypeIndex}; +use crate::adapters::analyzers::architecture::forbidden_rule::file_to_module_segments; +use crate::adapters::shared::cfg_test::has_cfg_test; +use syn::visit::Visit; + +/// Walk `ast` and populate `index.fn_returns`. Integration. +pub(super) fn collect_from_file( + index: &mut WorkspaceTypeIndex, + ctx: &BuildContext<'_>, + ast: &syn::File, +) { + let mut collector = FnCollector { index, ctx }; + collector.visit_file(ast); +} + +struct FnCollector<'i, 'c> { + index: &'i mut WorkspaceTypeIndex, + ctx: &'c BuildContext<'c>, +} + +impl<'ast, 'i, 'c> Visit<'ast> for FnCollector<'i, 'c> { + fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) { + if has_cfg_test(&node.attrs) { + return; + } + record_fn(self.index, self.ctx, node); + } + + fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) { + if has_cfg_test(&node.attrs) { + return; + } + syn::visit::visit_item_mod(self, node); + } + + fn visit_item_impl(&mut self, _: &'ast syn::ItemImpl) { + // Methods live in the methods.rs collector, not here. + } +} + +/// Record one free fn's return type. Operation. Own calls hidden. +fn record_fn(index: &mut WorkspaceTypeIndex, ctx: &BuildContext<'_>, node: &syn::ItemFn) { + let resolve = |ty: &syn::Type| resolve_type(ty, &resolve_ctx_from_build(ctx)); + let syn::ReturnType::Type(_, ret_ty) = &node.sig.output else { + return; + }; + let ret = resolve(ret_ty); + if matches!(ret, CanonicalType::Opaque) { + return; + } + let canonical = canonical_fn_name(&node.sig.ident.to_string(), ctx); + index.fn_returns.insert(canonical, ret); +} + +/// Build `crate::::`. Operation: string construction. +fn canonical_fn_name(fn_ident: &str, ctx: &BuildContext<'_>) -> String { + let mut segs: Vec = vec!["crate".to_string()]; + segs.extend(file_to_module_segments(ctx.path)); + segs.push(fn_ident.to_string()); + segs.join("::") +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs new file mode 100644 index 0000000..e733715 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs @@ -0,0 +1,100 @@ +//! Method-return-type collection. +//! +//! For every `impl T { fn method(...) -> R }` (inherent or trait impl) +//! in the workspace, record `(canonical_T, method_name) → CanonicalType(R)`. +//! +//! Canonical-T keys match what `resolve_type` produces for a `Path` +//! variant: `crate::::`. So when +//! inference later calls `index.method_return(&path.join("::"), "m")`, +//! the lookup hits. +//! +//! Methods without an explicit return type (`fn m()` → `()`) are not +//! indexed — `()` carries no resolution power. Test methods +//! (`#[cfg(test)]` / `#[test]`) are skipped. + +use super::super::canonical::CanonicalType; +use super::super::resolve::resolve_type; +use super::{canonical_type_key, resolve_ctx_from_build, BuildContext, WorkspaceTypeIndex}; +use crate::adapters::analyzers::architecture::call_parity_rule::workspace_graph::resolve_impl_self_type; +use crate::adapters::shared::cfg_test::has_cfg_test; +use syn::visit::Visit; + +/// Walk `ast` and populate `index.method_returns`. Integration: delegates +/// to the nested visitor. +pub(super) fn collect_from_file( + index: &mut WorkspaceTypeIndex, + ctx: &BuildContext<'_>, + ast: &syn::File, +) { + let mut collector = MethodCollector { + index, + ctx, + impl_stack: Vec::new(), + }; + collector.visit_file(ast); +} + +struct MethodCollector<'i, 'c> { + index: &'i mut WorkspaceTypeIndex, + ctx: &'c BuildContext<'c>, + /// Stack of enclosing impl-block canonical self-types. `None` for + /// unresolved (trait object, tuple receiver) — methods under those + /// impls aren't indexed because the receiver type can't be named. + impl_stack: Vec>>, +} + +impl<'ast, 'i, 'c> Visit<'ast> for MethodCollector<'i, 'c> { + fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) { + let resolved = resolve_impl_self_type( + &node.self_ty, + self.ctx.alias_map, + self.ctx.local_symbols, + self.ctx.crate_root_modules, + self.ctx.path, + ); + self.impl_stack.push(resolved); + syn::visit::visit_item_impl(self, node); + self.impl_stack.pop(); + } + + fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) { + if has_cfg_test(&node.attrs) { + return; + } + record_method(self.index, self.ctx, &self.impl_stack, node); + } + + fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) { + if has_cfg_test(&node.attrs) { + return; + } + syn::visit::visit_item_mod(self, node); + } +} + +/// Record a single method's return type, keyed on the enclosing impl's +/// canonical self-type. Operation. Own calls hidden in closures. +fn record_method( + index: &mut WorkspaceTypeIndex, + ctx: &BuildContext<'_>, + impl_stack: &[Option>], + node: &syn::ImplItemFn, +) { + let resolve = |ty: &syn::Type| resolve_type(ty, &resolve_ctx_from_build(ctx)); + let canon = |segs: &[String]| canonical_type_key(segs, ctx); + let Some(Some(impl_segs)) = impl_stack.last() else { + return; + }; + let syn::ReturnType::Type(_, ret_ty) = &node.sig.output else { + return; + }; + let ret = resolve(ret_ty); + if matches!(ret, CanonicalType::Opaque) { + return; + } + let receiver_canonical = canon(impl_segs); + let method_name = node.sig.ident.to_string(); + index + .method_returns + .insert((receiver_canonical, method_name), ret); +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs new file mode 100644 index 0000000..0662210 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs @@ -0,0 +1,179 @@ +//! `WorkspaceTypeIndex` — lookup tables the inference engine queries. +//! +//! Three maps are populated in one walk over every non-cfg-test file: +//! +//! - `struct_fields`: `(struct_canonical, field_name)` → field type +//! - `method_returns`: `(receiver_canonical, method_name)` → return type +//! - `fn_returns`: `canonical_free_fn_name` → return type +//! +//! Each sub-module owns one collector. They share a `BuildContext` with +//! the per-file resolution inputs; collectors don't talk to each other. + +pub mod aliases; +pub mod fields; +pub mod functions; +pub mod methods; +pub mod traits; + +use super::canonical::CanonicalType; +use crate::adapters::analyzers::architecture::call_parity_rule::workspace_graph::collect_local_symbols; +use crate::adapters::analyzers::architecture::forbidden_rule::file_to_module_segments; +use std::collections::{HashMap, HashSet}; + +/// Per-file resolution context passed to every collector. Owned by the +/// outer build loop, borrowed into each `collect_from_file` call. +pub(super) struct BuildContext<'a> { + pub path: &'a str, + pub alias_map: &'a HashMap>, + pub local_symbols: &'a HashSet, + pub crate_root_modules: &'a HashSet, + /// Stage 3 — user-wrapper names peeled during resolution. Shared + /// across the whole build. + pub transparent_wrappers: &'a HashSet, +} + +/// Build a canonical type-path key by prefixing the impl/trait segments +/// with `crate::::` unless they're already crate-rooted. +/// Shared by `methods` and `traits` collectors so they produce keys in +/// the same shape the call-graph (`canonical_fn_name`) uses. +/// Operation. +pub(super) fn canonical_type_key(segs: &[String], ctx: &BuildContext<'_>) -> String { + if segs.first().map(String::as_str) == Some("crate") { + return segs.join("::"); + } + let mut out: Vec = vec!["crate".to_string()]; + out.extend(file_to_module_segments(ctx.path)); + out.extend(segs.iter().cloned()); + out.join("::") +} + +/// Build a `ResolveContext` from the shared `BuildContext` inputs — +/// extracted so the per-field / per-method / per-free-fn collectors +/// don't each repeat the same 7-line construction. Inference-time +/// aliases aren't available during build (the alias map is itself +/// being built), so `type_aliases` is `None` here. +/// Operation. +pub(super) fn resolve_ctx_from_build<'a>( + ctx: &'a BuildContext<'a>, +) -> super::resolve::ResolveContext<'a> { + super::resolve::ResolveContext { + alias_map: ctx.alias_map, + local_symbols: ctx.local_symbols, + crate_root_modules: ctx.crate_root_modules, + importing_file: ctx.path, + type_aliases: None, + transparent_wrappers: Some(ctx.transparent_wrappers), + } +} + +/// Lookup tables populated from one walk over the workspace. +#[derive(Default)] +pub struct WorkspaceTypeIndex { + /// `(struct_canonical, field_name) → canonical field type`. + pub struct_fields: HashMap<(String, String), CanonicalType>, + /// `(receiver_type_canonical, method_name) → canonical return type`. + pub method_returns: HashMap<(String, String), CanonicalType>, + /// `canonical_free_fn_name → canonical return type`. + pub fn_returns: HashMap, + /// Stage 2 — `trait_canonical → [impl_type_canonical, …]`. Every + /// `impl Trait for X` in the workspace contributes one entry so + /// trait-dispatch can over-approximate edges to every impl. + pub trait_impls: HashMap>, + /// Stage 2 — `trait_canonical → {method_name, …}`. Gates + /// trait-dispatch so `dyn Trait.unrelated_method()` stays + /// unresolved. + pub trait_methods: HashMap>, + /// Stage 3 — `alias_canonical → target syn::Type`. Expanded on + /// the fly during inference; the clone here is cheap (typical + /// aliases are small trees). + pub type_aliases: HashMap, + /// Stage 3 — user-configured last-ident names to treat as + /// transparent single-type-param wrappers (framework extractors + /// like `State` / `Data`). Mirrored from the + /// `CompiledCallParity.transparent_wrappers` at build time. + pub transparent_wrappers: HashSet, +} + +impl WorkspaceTypeIndex { + pub fn new() -> Self { + Self::default() + } + + // qual:api + /// Look up a struct field's canonical type. Operation. + pub fn struct_field(&self, type_canonical: &str, field: &str) -> Option<&CanonicalType> { + self.struct_fields + .get(&(type_canonical.to_string(), field.to_string())) + } + + // qual:api + /// Look up a method's return type. Operation. + pub fn method_return(&self, receiver_canonical: &str, method: &str) -> Option<&CanonicalType> { + self.method_returns + .get(&(receiver_canonical.to_string(), method.to_string())) + } + + // qual:api + /// Look up a free-fn's return type. Operation. + pub fn fn_return(&self, fn_canonical: &str) -> Option<&CanonicalType> { + self.fn_returns.get(fn_canonical) + } + + // qual:api + /// Look up all workspace impls of a trait. Returns an empty slice + /// when the trait has no impls recorded. Operation. + pub fn impls_of_trait(&self, trait_canonical: &str) -> &[String] { + self.trait_impls + .get(trait_canonical) + .map(Vec::as_slice) + .unwrap_or(&[]) + } + + // qual:api + /// True iff `method_name` is declared on `trait_canonical`. + /// Operation. + pub fn trait_has_method(&self, trait_canonical: &str, method_name: &str) -> bool { + self.trait_methods + .get(trait_canonical) + .is_some_and(|methods| methods.contains(method_name)) + } +} + +// qual:api +/// Build the workspace type index from parsed files + their pre-computed +/// alias maps. Skips cfg-test files wholesale. `transparent_wrappers` +/// seeds the user-configured Stage-3 wrapper list onto the index so +/// downstream inference peels them just like `Arc` / `Box`. +/// Integration: orchestrates per-file walks across the collectors. +pub fn build_workspace_type_index( + files: &[(&str, &syn::File)], + aliases_per_file: &HashMap>>, + cfg_test_files: &HashSet, + crate_root_modules: &HashSet, + transparent_wrappers: &HashSet, +) -> WorkspaceTypeIndex { + let mut index = WorkspaceTypeIndex::new(); + index.transparent_wrappers = transparent_wrappers.clone(); + for (path, ast) in files { + if cfg_test_files.contains(*path) { + continue; + } + let Some(alias_map) = aliases_per_file.get(*path) else { + continue; + }; + let local_symbols = collect_local_symbols(ast); + let ctx = BuildContext { + path, + alias_map, + local_symbols: &local_symbols, + crate_root_modules, + transparent_wrappers, + }; + fields::collect_from_file(&mut index, &ctx, ast); + methods::collect_from_file(&mut index, &ctx, ast); + functions::collect_from_file(&mut index, &ctx, ast); + traits::collect_from_file(&mut index, &ctx, ast); + aliases::collect_from_file(&mut index, &ctx, ast); + } + index +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs new file mode 100644 index 0000000..323beca --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs @@ -0,0 +1,132 @@ +//! Trait-definition + trait-impl collection. +//! +//! Populates two maps on `WorkspaceTypeIndex`: +//! +//! - `trait_methods`: `trait_canonical → {method_name, …}` — the set +//! of methods each trait declares. Used so trait-dispatch resolution +//! only fires for methods that actually belong to the trait +//! (`dyn Trait.unrelated_method()` stays unresolved). +//! - `trait_impls`: `trait_canonical → [impl_type_canonical, …]` — +//! every workspace-local impl of a trait. Stage 2 trait-dispatch +//! over-approximates by recording an edge to every impl's method. + +use super::{canonical_type_key, BuildContext, WorkspaceTypeIndex}; +use crate::adapters::analyzers::architecture::call_parity_rule::bindings::canonicalise_type_segments; +use crate::adapters::analyzers::architecture::call_parity_rule::workspace_graph::resolve_impl_self_type; +use crate::adapters::analyzers::architecture::forbidden_rule::file_to_module_segments; +use crate::adapters::shared::cfg_test::has_cfg_test; +use std::collections::HashSet; +use syn::visit::Visit; + +/// Walk `ast` and populate both `trait_methods` and `trait_impls` on +/// `index`. Integration. +pub(super) fn collect_from_file( + index: &mut WorkspaceTypeIndex, + ctx: &BuildContext<'_>, + ast: &syn::File, +) { + let mut collector = TraitCollector { index, ctx }; + collector.visit_file(ast); +} + +struct TraitCollector<'i, 'c> { + index: &'i mut WorkspaceTypeIndex, + ctx: &'c BuildContext<'c>, +} + +impl<'ast, 'i, 'c> Visit<'ast> for TraitCollector<'i, 'c> { + fn visit_item_trait(&mut self, node: &'ast syn::ItemTrait) { + record_trait_methods(self.index, self.ctx, node); + } + + fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) { + record_trait_impl(self.index, self.ctx, node); + } + + fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) { + if has_cfg_test(&node.attrs) { + return; + } + syn::visit::visit_item_mod(self, node); + } +} + +/// For a `trait T { fn m(…); fn n(…); }` record +/// `trait_methods[canonical_T] = {"m", "n"}`. Operation. +fn record_trait_methods( + index: &mut WorkspaceTypeIndex, + ctx: &BuildContext<'_>, + node: &syn::ItemTrait, +) { + let canonical = canonical_name(&node.ident.to_string(), ctx); + let methods: HashSet = node + .items + .iter() + .filter_map(|item| match item { + syn::TraitItem::Fn(f) => Some(f.sig.ident.to_string()), + _ => None, + }) + .collect(); + if !methods.is_empty() { + index.trait_methods.insert(canonical, methods); + } +} + +/// For `impl Trait for X { … }` record `trait_impls[canonical_Trait]` +/// gaining `canonical_X`. Inherent impls (without `trait_`) are handled +/// by `methods.rs`, not here. Operation: delegated canonicalisation. +fn record_trait_impl(index: &mut WorkspaceTypeIndex, ctx: &BuildContext<'_>, node: &syn::ItemImpl) { + let Some((_, trait_path, _)) = &node.trait_ else { + return; + }; + let trait_canonical = resolve_trait_path(trait_path, ctx); + let Some(trait_canonical) = trait_canonical else { + return; + }; + let impl_type_canonical = resolve_impl_self_type( + &node.self_ty, + ctx.alias_map, + ctx.local_symbols, + ctx.crate_root_modules, + ctx.path, + ); + let Some(impl_segs) = impl_type_canonical else { + return; + }; + let impl_canonical = canonical_impl_type(&impl_segs, ctx); + index + .trait_impls + .entry(trait_canonical) + .or_default() + .push(impl_canonical); +} + +/// Resolve a trait path (the `T` in `impl T for X`) to its canonical +/// crate-rooted form via the shared canonicalisation pipeline. +/// Operation: flatten + delegate. +fn resolve_trait_path(path: &syn::Path, ctx: &BuildContext<'_>) -> Option { + let segs: Vec = path.segments.iter().map(|s| s.ident.to_string()).collect(); + let resolved = canonicalise_type_segments( + &segs, + ctx.alias_map, + ctx.local_symbols, + ctx.crate_root_modules, + ctx.path, + )?; + Some(resolved.join("::")) +} + +/// `crate::::`. Operation. +fn canonical_name(ident: &str, ctx: &BuildContext<'_>) -> String { + let mut segs: Vec = vec!["crate".to_string()]; + segs.extend(file_to_module_segments(ctx.path)); + segs.push(ident.to_string()); + segs.join("::") +} + +/// Same shape as methods.rs — prefix impl-type segs with `crate:: +/// ::` unless the impl path is already crate-rooted. +/// Operation: delegate to the shared `canonical_type_key`. +fn canonical_impl_type(impl_segs: &[String], ctx: &BuildContext<'_>) -> String { + canonical_type_key(impl_segs, ctx) +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs b/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs index 47a8a17..7ed2bc2 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs @@ -21,6 +21,7 @@ use super::bindings::canonicalise_type_segments; use super::calls::{collect_canonical_calls, FnContext}; +use super::type_infer::{build_workspace_type_index, WorkspaceTypeIndex}; use crate::adapters::analyzers::architecture::forbidden_rule::file_to_module_segments; use crate::adapters::analyzers::architecture::layer_rule::LayerDefinitions; use crate::adapters::shared::cfg_test::has_cfg_test; @@ -273,8 +274,18 @@ pub(crate) fn build_call_graph<'ast>( aliases_per_file: &HashMap>>, cfg_test_files: &HashSet, layers: &LayerDefinitions, + transparent_wrappers: &HashSet, ) -> CallGraph { let crate_root_modules = collect_crate_root_modules(files); + // Pre-build the workspace type index so `collect_canonical_calls` + // can run shallow inference on complex method-call receivers. + let type_index = build_workspace_type_index( + files, + aliases_per_file, + cfg_test_files, + &crate_root_modules, + transparent_wrappers, + ); let mut graph = CallGraph::new(); for (path, ast) in files { if cfg_test_files.contains(*path) { @@ -289,6 +300,7 @@ pub(crate) fn build_call_graph<'ast>( alias_map, local_symbols: &local_symbols, crate_root_modules: &crate_root_modules, + type_index: &type_index, impl_type_stack: Vec::new(), graph: &mut graph, }; @@ -318,6 +330,7 @@ struct FileFnCollector<'a> { alias_map: &'a HashMap>, local_symbols: &'a HashSet, crate_root_modules: &'a HashSet, + type_index: &'a WorkspaceTypeIndex, /// Stack of enclosing impl blocks' resolved self-types. `None` /// marks an unresolved self-type (trait object, `&T`, tuple) whose /// methods we must not record — their canonical would collapse to @@ -351,6 +364,7 @@ impl<'a> FileFnCollector<'a> { local_symbols: self.local_symbols, crate_root_modules: self.crate_root_modules, importing_file: self.path, + workspace_index: Some(self.type_index), }; let calls = collect_canonical_calls(&ctx); self.graph.add_node(&canonical); diff --git a/src/adapters/analyzers/architecture/compiled.rs b/src/adapters/analyzers/architecture/compiled.rs index 9a673ba..9e7e019 100644 --- a/src/adapters/analyzers/architecture/compiled.rs +++ b/src/adapters/analyzers/architecture/compiled.rs @@ -39,6 +39,18 @@ pub struct CompiledCallParity { pub target: String, pub call_depth: usize, pub exclude_targets: GlobSet, + /// Stage 3 — last path-segment names of user-defined wrappers whose + /// single type parameter is transparent for receiver resolution. + /// Built from `[architecture.call_parity]::transparent_wrappers`. + pub transparent_wrappers: HashSet, + /// Stage 3 — last path-segment names of attribute macros that + /// don't affect the call graph. The default set covers common + /// framework attributes (`instrument`, `async_trait`, `main`, + /// `test`); user config extends it. Consulted today only for + /// authorial intent; retained here so future macro-expansion + /// enhancements have the list available without a config-schema + /// break. + pub transparent_macros: HashSet, } /// Compile the raw config into `CompiledArchitecture`. @@ -110,14 +122,39 @@ fn compile_call_parity( } let exclude_targets = build_globset(&cp.exclude_targets) .map_err(|e| format!("call_parity.exclude_targets: {e}"))?; + let transparent_wrappers: HashSet = cp.transparent_wrappers.iter().cloned().collect(); + let transparent_macros = build_transparent_macros(&cp.transparent_macros); Ok(Some(CompiledCallParity { adapters: cp.adapters.clone(), target: cp.target.clone(), call_depth: cp.call_depth, exclude_targets, + transparent_wrappers, + transparent_macros, })) } +/// Stage 3 starter-pack: prepend these common framework attribute-macro +/// names so users don't need to list them in every rustqual.toml. The +/// full set is `defaults ∪ user`. Operation. +fn build_transparent_macros(user: &[String]) -> HashSet { + const DEFAULTS: &[&str] = &[ + "instrument", // tracing::instrument + "async_trait", // async_trait::async_trait + "main", // tokio::main, actix::main, async_std::main + "test", // tokio::test, #[test] + "rstest", // rstest::rstest + "test_case", // test_case::test_case + "pyfunction", // pyo3::pyfunction + "pymethods", // pyo3::pymethods + "wasm_bindgen", // wasm_bindgen + "cfg_attr", // conditional attribute + ]; + let mut set: HashSet = DEFAULTS.iter().map(|s| (*s).to_string()).collect(); + set.extend(user.iter().cloned()); + set +} + /// Compile `[[architecture.trait_contract]]` entries into runtime rules. /// Operation: per-entry glob compilation + field copy. fn compile_trait_contracts( diff --git a/src/adapters/analyzers/architecture/tests/compiled.rs b/src/adapters/analyzers/architecture/tests/compiled.rs index 8743c70..e519418 100644 --- a/src/adapters/analyzers/architecture/tests/compiled.rs +++ b/src/adapters/analyzers/architecture/tests/compiled.rs @@ -111,6 +111,8 @@ fn minimal_call_parity() -> CallParityConfig { target: "application".to_string(), call_depth: 3, exclude_targets: Vec::new(), + transparent_wrappers: Vec::new(), + transparent_macros: Vec::new(), } } diff --git a/src/adapters/analyzers/iosp/mod.rs b/src/adapters/analyzers/iosp/mod.rs index c3aff88..dc53ca1 100644 --- a/src/adapters/analyzers/iosp/mod.rs +++ b/src/adapters/analyzers/iosp/mod.rs @@ -149,11 +149,16 @@ impl<'a> Analyzer<'a> { .into_iter() .collect::>(), Item::Impl(i) => { - let test = crate::adapters::shared::cfg_test::has_cfg_test(&i.attrs); + let test = + file_in_test || crate::adapters::shared::cfg_test::has_cfg_test(&i.attrs); self.analyze_impl(i, file_path, test) } - Item::Trait(t) => self.analyze_trait(t, file_path, false), - Item::Mod(m) => self.analyze_mod(m, file_path, false), + Item::Trait(t) => { + let test = + file_in_test || crate::adapters::shared::cfg_test::has_cfg_test(&t.attrs); + self.analyze_trait(t, file_path, test) + } + Item::Mod(m) => self.analyze_mod(m, file_path, file_in_test), _ => vec![], }) .collect() diff --git a/src/adapters/config/architecture.rs b/src/adapters/config/architecture.rs index 9769aec..ccbe9ff 100644 --- a/src/adapters/config/architecture.rs +++ b/src/adapters/config/architecture.rs @@ -270,6 +270,28 @@ pub struct CallParityConfig { /// always use the on-disk module path. #[serde(default)] pub exclude_targets: Vec, + + /// Stage 3 — user-defined transparent wrapper types. These are + /// peeled during receiver-type resolution just like `Arc`, `Box`, + /// `Rc`, `Cow`. Typical candidates are framework extractor types: + /// Axum's `State` / `Extension` / `Json`, Actix's + /// `Data`, tower's `Router`. Without an entry here, + /// `fn h(State(db): State) { db.query() }` leaves `db` + /// unresolved. + #[serde(default)] + pub transparent_wrappers: Vec, + + /// Stage 3 — transparent attribute-macro names. These are + /// attribute macros whose expansion does not alter the fn body + /// semantically from a call-graph perspective + /// (`#[tracing::instrument]`, `#[async_trait]`, `#[tokio::main]`). + /// The default list covers the most common cases; user entries + /// extend it. Recorded here for authorial intent and future + /// extensions — the default syn-based AST walk already treats + /// attribute macros as transparent, so this config currently + /// documents rather than changes behaviour. + #[serde(default)] + pub transparent_macros: Vec, } pub(crate) fn default_call_depth() -> usize { From 7671be52a51f3dc18456702f23a322e61dced951 Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:37:02 +0200 Subject: [PATCH 02/30] fix: Copilot comments --- .../architecture/call_parity_rule/calls.rs | 234 ++++++++++++++---- .../call_parity_rule/type_infer/resolve.rs | 42 +++- .../type_infer/workspace_index/mod.rs | 87 +++++-- .../call_parity_rule/workspace_graph.rs | 30 ++- src/adapters/shared/cfg_test_files.rs | 6 +- 5 files changed, 324 insertions(+), 75 deletions(-) diff --git a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs index 8a8484c..56662e9 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs @@ -19,7 +19,8 @@ use super::bindings::{canonical_from_type, extract_let_binding, normalize_alias_expansion}; use super::type_infer::resolve::{resolve_type, ResolveContext}; use super::type_infer::{ - infer_type, BindingLookup, CanonicalType, InferContext, WorkspaceTypeIndex, + extract_bindings, extract_for_bindings, infer_type, BindingLookup, CanonicalType, InferContext, + WorkspaceTypeIndex, }; use crate::adapters::analyzers::architecture::forbidden_rule::{ file_to_module_segments, resolve_to_crate_absolute, @@ -97,12 +98,15 @@ struct CanonicalCallCollector<'a> { /// Inner-most scope is at the end; lookup walks from back to front. /// Always non-empty while a collection is in flight. bindings: Vec>>, - /// Flat signature-param scope for `dyn Trait` / `&dyn Trait` / - /// `Box` parameters — Stage 2 trait-dispatch receivers. - /// Kept separate because `bindings` stores concrete `Vec` - /// paths; trait-bound segments are semantically different (they - /// trigger fan-out to every impl instead of a single edge). - trait_bindings: HashMap>, + /// Flat signature-param scope for bindings whose inferred type + /// isn't a simple `Path` — trait bounds (`dyn Trait`) and stdlib + /// wrappers (`Result`, `Option`, `Future`, `Vec`, + /// `HashMap<_, V>`). Kept separate because the legacy `bindings` + /// stack stores concrete `Vec` paths; wrapper-typed + /// bindings carry enough structure that we need the full + /// `CanonicalType` (so `.unwrap()` / `.await` / `?` on signature + /// params can unwrap them correctly). + non_path_bindings: HashMap, calls: HashSet, /// Workspace type-index for shallow inference fallback. Mirrored /// from `FnContext` so the visitor doesn't need the full context @@ -132,7 +136,7 @@ impl<'a> CanonicalCallCollector<'a> { self_type_canonical, signature_params: ctx.signature_params.clone(), bindings: vec![HashMap::new()], - trait_bindings: HashMap::new(), + non_path_bindings: HashMap::new(), calls: HashSet::new(), workspace_index: ctx.workspace_index, } @@ -162,11 +166,11 @@ impl<'a> CanonicalCallCollector<'a> { } /// Install a signature-param binding using the full `resolve_type` - /// pipeline. `Path` variants go into the legacy `Vec` scope; - /// `TraitBound` variants go into the separate `trait_bindings` map - /// so trait-dispatch fires at the method-call site. Other variants - /// (Opaque, Slice, Map, stdlib wrappers) don't install a binding. - /// Operation: resolve + classify. + /// pipeline. `Path` → legacy `Vec` scope (simple and cheap + /// to look up). `Opaque` is dropped. Everything else — `TraitBound`, + /// `Result`/`Option`/`Future` wrappers, `Slice`/`Map` — lands in + /// `non_path_bindings` so `?` / `.await` / trait-dispatch fire + /// correctly on method-call sites. Operation. fn seed_param_via_resolver(&mut self, name: &str, ty: &syn::Type) { let rctx = ResolveContext { alias_map: self.alias_map, @@ -180,10 +184,10 @@ impl<'a> CanonicalCallCollector<'a> { CanonicalType::Path(segs) => { self.bindings[0].insert(name.to_string(), segs); } - CanonicalType::TraitBound(segs) => { - self.trait_bindings.insert(name.to_string(), segs); + CanonicalType::Opaque => {} + other => { + self.non_path_bindings.insert(name.to_string(), other); } - _ => {} } } @@ -333,7 +337,7 @@ impl<'a> CanonicalCallCollector<'a> { fn infer_receiver_type(&self, expr: &syn::Expr) -> Option { let adapter = CollectorBindings { scope: &self.bindings, - trait_scope: &self.trait_bindings, + non_path_scope: &self.non_path_bindings, }; let ctx = InferContext { workspace: self.workspace_index?, @@ -347,37 +351,115 @@ impl<'a> CanonicalCallCollector<'a> { infer_type(expr, &ctx) } - /// Shallow-inference fallback: run `type_infer::infer_type` on an - /// expression, returning the canonical-path segments iff the result - /// is a concrete `Path`. `Result`/`Option`/`Future`/`Slice`/`Map`/ - /// `TraitBound`/`Opaque` collapse to `None` — the legacy scope only - /// stores concrete type-path bindings. Trait-bound bindings would - /// need multi-target dispatch at the method-call site instead. - /// Operation: delegate to `infer_receiver_type` + variant probe. - fn try_infer_receiver(&self, expr: &syn::Expr) -> Option> { - match self.infer_receiver_type(expr)? { - CanonicalType::Path(segs) => Some(segs), - _ => None, + /// Install a `let x = expr` binding via shallow inference on the + /// initializer. `Path` results go into the legacy scope, non-Path + /// results (wrappers, trait bounds) into `non_path_bindings` so + /// downstream `?` / `.await` / trait-dispatch on `x` resolve + /// correctly. Only simple `Pat::Ident` patterns are handled here; + /// destructuring goes through Task 1.4's `patterns::extract_bindings` + /// which is wired up separately. Operation. + fn install_inferred_let_binding(&mut self, local: &syn::Local) { + let Some(init) = local.init.as_ref() else { + return; + }; + let Some(name) = extract_pat_ident_name(&local.pat) else { + return; + }; + let Some(inferred) = self.infer_receiver_type(&init.expr) else { + return; + }; + match inferred { + CanonicalType::Path(segs) => { + self.current_scope_mut().insert(name, segs); + } + CanonicalType::Opaque => {} + other => { + self.non_path_bindings.insert(name, other); + } } } - /// Extract a `(name, canonical_segments)` pair from a `let` - /// statement via shallow inference on its initializer. Only simple - /// `Pat::Ident` patterns are handled here; complex destructuring - /// (`let Some(x) = opt`) is out of scope for Task 1.6 MVP. - /// Operation: delegate to `try_infer_receiver` + pattern probe. - fn infer_let_binding(&self, local: &syn::Local) -> Option<(String, Vec)> { - let init = local.init.as_ref()?; - let name = extract_pat_ident_name(&local.pat)?; - let segs = self.try_infer_receiver(&init.expr)?; - Some((name, segs)) - } - fn collect_macro_body(&mut self, mac: &syn::Macro) { for expr in parse_macro_tokens(mac.tokens.clone()) { self.visit_expr(&expr); } } + + /// Extract pattern bindings from `pat` against a matched-type from + /// `matched_expr`, installing them into the current scope. Path + /// bindings go into the legacy scope, wrapper/trait-bound bindings + /// into `non_path_bindings`. Used by `let`-destructuring, `if let`, + /// `while let`, `match` arms. Integration. + fn install_destructure_bindings(&mut self, pat: &syn::Pat, matched_expr: &syn::Expr) { + let Some(matched) = self.infer_receiver_type(matched_expr) else { + return; + }; + let pairs = self.extract_pattern_pairs(pat, &matched, PatKind::Value); + self.install_binding_pairs(pairs); + } + + /// Extract for-loop element-type bindings from `pat` against + /// `iter_expr` (the thing being iterated over). Integration. + fn install_for_bindings(&mut self, pat: &syn::Pat, iter_expr: &syn::Expr) { + let Some(iter_type) = self.infer_receiver_type(iter_expr) else { + return; + }; + let pairs = self.extract_pattern_pairs(pat, &iter_type, PatKind::Iterator); + self.install_binding_pairs(pairs); + } + + /// Wrapper around `patterns::extract_bindings` / `extract_for_bindings` + /// that builds a fresh `InferContext`. Operation. + fn extract_pattern_pairs( + &self, + pat: &syn::Pat, + matched: &CanonicalType, + kind: PatKind, + ) -> Vec<(String, CanonicalType)> { + let Some(workspace) = self.workspace_index else { + return Vec::new(); + }; + let adapter = CollectorBindings { + scope: &self.bindings, + non_path_scope: &self.non_path_bindings, + }; + let ictx = InferContext { + workspace, + alias_map: self.alias_map, + local_symbols: self.local_symbols, + crate_root_modules: self.crate_root_modules, + importing_file: self.importing_file, + bindings: &adapter, + self_type: self.self_type_canonical.clone(), + }; + match kind { + PatKind::Value => extract_bindings(pat, matched, &ictx), + PatKind::Iterator => extract_for_bindings(pat, matched, &ictx), + } + } + + /// Dispatch each `(name, type)` pair into the right scope map. + /// Operation. + fn install_binding_pairs(&mut self, pairs: Vec<(String, CanonicalType)>) { + for (name, ty) in pairs { + match ty { + CanonicalType::Path(segs) => { + self.current_scope_mut().insert(name, segs); + } + CanonicalType::Opaque => {} + other => { + self.non_path_bindings.insert(name, other); + } + } + } + } +} + +/// Whether `extract_pattern_pairs` should use value-pattern +/// (`let` / `if let` / `match`) or for-loop element-type extraction. +enum PatKind { + Value, + Iterator, } /// Best-effort extraction of expressions from a macro token stream. @@ -471,7 +553,7 @@ fn trait_dispatch_edges( /// by the legacy `extract_let_binding`. struct CollectorBindings<'a> { scope: &'a [HashMap>], - trait_scope: &'a HashMap>, + non_path_scope: &'a HashMap, } impl BindingLookup for CollectorBindings<'_> { @@ -481,9 +563,7 @@ impl BindingLookup for CollectorBindings<'_> { return Some(CanonicalType::Path(segs.clone())); } } - self.trait_scope - .get(ident) - .map(|segs| CanonicalType::TraitBound(segs.clone())) + self.non_path_scope.get(ident).cloned() } } @@ -547,12 +627,68 @@ impl<'a, 'ast> Visit<'ast> for CanonicalCallCollector<'a> { self.current_scope_mut().insert(name, ty_canonical); return; } - // Inference fallback: method-chained ctors (`T::ctor().map_err()?`), - // free-fn returns (`make_session()`), builder patterns, etc. - // Requires workspace_index; silently skips when absent. - if let Some((name, segs)) = self.infer_let_binding(local) { - self.current_scope_mut().insert(name, segs); + // Simple-ident inference fallback (handles method chains + wrapper types). + if extract_pat_ident_name(&local.pat).is_some() { + self.install_inferred_let_binding(local); + return; + } + // Destructuring: `let Some(x) = opt`, `let Ctx { field } = …`, + // `let (a, b) = …`, `let Pat = expr else { return; }`. Install + // all pattern-extracted bindings into the current scope. + if let Some(init) = local.init.as_ref() { + self.install_destructure_bindings(&local.pat, &init.expr); + } + } + + fn visit_expr_if(&mut self, expr_if: &'ast syn::ExprIf) { + self.enter_scope(); + // `if let PAT = SCRUTINEE { THEN }` — extract bindings visible + // in the then-block only. Non-let conditions are visited via + // the default walker and don't introduce bindings. + if let syn::Expr::Let(let_expr) = expr_if.cond.as_ref() { + self.visit_expr(&let_expr.expr); + self.install_destructure_bindings(&let_expr.pat, &let_expr.expr); + } else { + self.visit_expr(&expr_if.cond); } + self.visit_block(&expr_if.then_branch); + self.exit_scope(); + if let Some((_, else_branch)) = &expr_if.else_branch { + self.visit_expr(else_branch); + } + } + + fn visit_expr_while(&mut self, expr_while: &'ast syn::ExprWhile) { + self.enter_scope(); + if let syn::Expr::Let(let_expr) = expr_while.cond.as_ref() { + self.visit_expr(&let_expr.expr); + self.install_destructure_bindings(&let_expr.pat, &let_expr.expr); + } else { + self.visit_expr(&expr_while.cond); + } + self.visit_block(&expr_while.body); + self.exit_scope(); + } + + fn visit_expr_match(&mut self, expr_match: &'ast syn::ExprMatch) { + self.visit_expr(&expr_match.expr); + for arm in &expr_match.arms { + self.enter_scope(); + self.install_destructure_bindings(&arm.pat, &expr_match.expr); + if let Some((_, guard)) = &arm.guard { + self.visit_expr(guard); + } + self.visit_expr(&arm.body); + self.exit_scope(); + } + } + + fn visit_expr_for_loop(&mut self, for_loop: &'ast syn::ExprForLoop) { + self.visit_expr(&for_loop.expr); + self.enter_scope(); + self.install_for_bindings(&for_loop.pat, &for_loop.expr); + self.visit_block(&for_loop.body); + self.exit_scope(); } fn visit_expr_call(&mut self, call: &'ast syn::ExprCall) { diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs index 72066bd..cccedb2 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs @@ -150,10 +150,13 @@ fn resolve_path(path: &syn::Path, ctx: &ResolveContext<'_>, depth: u8) -> Canoni let peel = || peel_single_generic(args, ctx, depth); let fallback = || resolve_generic_path(path, ctx, depth); let name = last.ident.to_string(); + let wrap_future = || wrap_future_output(args, ctx, depth); match name.as_str() { "Result" => wrap(0, CanonicalType::Result), "Option" => wrap(0, CanonicalType::Option), - "Future" => wrap(0, CanonicalType::Future), + // Future uses `Output = T` associated-type syntax, not a + // positional generic. Handle both forms in the dedicated helper. + "Future" => wrap_future(), "Vec" => wrap(0, CanonicalType::Slice), "HashMap" | "BTreeMap" => wrap(1, CanonicalType::Map), "Arc" | "Box" | "Rc" | "Cow" | "RwLock" | "Mutex" | "RefCell" | "Cell" => peel(), @@ -162,6 +165,35 @@ fn resolve_path(path: &syn::Path, ctx: &ResolveContext<'_>, depth: u8) -> Canoni } } +/// Future-specific wrapper: `std::future::Future` uses the +/// `Output = T` associated-type syntax. Accepts the positional form +/// `Future` too as a secondary fallback. Operation. +fn wrap_future_output( + args: &syn::PathArguments, + ctx: &ResolveContext<'_>, + depth: u8, +) -> CanonicalType { + let recurse = |t: &syn::Type| resolve_type_with_depth(t, ctx, depth); + match future_output_type(args) { + Some(inner) => CanonicalType::Future(Box::new(recurse(inner))), + None => CanonicalType::Opaque, + } +} + +/// Extract the `Output` type from `Future`; fall back to +/// the first positional generic arg for the rarer `Future` form. +/// Operation. +fn future_output_type(args: &syn::PathArguments) -> Option<&syn::Type> { + let syn::PathArguments::AngleBracketed(ab) = args else { + return None; + }; + let assoc = ab.args.iter().find_map(|arg| match arg { + syn::GenericArgument::AssocType(a) if a.ident == "Output" => Some(&a.ty), + _ => None, + }); + assoc.or_else(|| generic_type_arg(args, 0)) +} + /// Stage 3 — check if `name` is a user-configured transparent wrapper. /// Operation: set lookup with optional presence. fn is_user_transparent(name: &str, ctx: &ResolveContext<'_>) -> bool { @@ -182,7 +214,9 @@ fn wrap_generic( where F: FnOnce(Box) -> CanonicalType, { - let recurse = |t: &syn::Type| resolve_type_with_depth(t, ctx, depth + 1); + // `depth` already carries the +1 from `dispatch_type`'s guard — + // `resolve_type_with_depth` re-applies the guard, so pass through. + let recurse = |t: &syn::Type| resolve_type_with_depth(t, ctx, depth); match generic_type_arg(args, idx) { Some(inner) => constructor(Box::new(recurse(inner))), None => CanonicalType::Opaque, @@ -197,7 +231,7 @@ fn peel_single_generic( ctx: &ResolveContext<'_>, depth: u8, ) -> CanonicalType { - let recurse = |t: &syn::Type| resolve_type_with_depth(t, ctx, depth + 1); + let recurse = |t: &syn::Type| resolve_type_with_depth(t, ctx, depth); match generic_type_arg(args, 0) { Some(inner) => recurse(inner), None => CanonicalType::Opaque, @@ -210,7 +244,7 @@ fn peel_single_generic( /// the canonicalised name matches a recorded workspace type-alias, the /// alias target is recursively resolved. Operation: closure-hidden calls. fn resolve_generic_path(path: &syn::Path, ctx: &ResolveContext<'_>, depth: u8) -> CanonicalType { - let recurse = |t: &syn::Type| resolve_type_with_depth(t, ctx, depth + 1); + let recurse = |t: &syn::Type| resolve_type_with_depth(t, ctx, depth); let canonicalise = |segs: &[String]| { canonicalise_type_segments( segs, diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs index 0662210..7626a50 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs @@ -30,6 +30,12 @@ pub(super) struct BuildContext<'a> { /// Stage 3 — user-wrapper names peeled during resolution. Shared /// across the whole build. pub transparent_wrappers: &'a HashSet, + /// Stage 3 — type aliases already collected across the workspace. + /// `None` in pass 1 (the alias collector itself); `Some(&…)` in + /// pass 2 so fields/methods/functions/traits that reference an + /// alias are resolved through the alias target instead of caching + /// the raw alias name. + pub type_aliases: Option<&'a HashMap>, } /// Build a canonical type-path key by prefixing the impl/trait segments @@ -49,9 +55,9 @@ pub(super) fn canonical_type_key(segs: &[String], ctx: &BuildContext<'_>) -> Str /// Build a `ResolveContext` from the shared `BuildContext` inputs — /// extracted so the per-field / per-method / per-free-fn collectors -/// don't each repeat the same 7-line construction. Inference-time -/// aliases aren't available during build (the alias map is itself -/// being built), so `type_aliases` is `None` here. +/// don't each repeat the same construction. `type_aliases` propagates +/// through so pass-2 collectors (running after the alias-collector +/// populated them) resolve aliased types transparently. /// Operation. pub(super) fn resolve_ctx_from_build<'a>( ctx: &'a BuildContext<'a>, @@ -61,7 +67,7 @@ pub(super) fn resolve_ctx_from_build<'a>( local_symbols: ctx.local_symbols, crate_root_modules: ctx.crate_root_modules, importing_file: ctx.path, - type_aliases: None, + type_aliases: ctx.type_aliases, transparent_wrappers: Some(ctx.transparent_wrappers), } } @@ -144,7 +150,12 @@ impl WorkspaceTypeIndex { /// alias maps. Skips cfg-test files wholesale. `transparent_wrappers` /// seeds the user-configured Stage-3 wrapper list onto the index so /// downstream inference peels them just like `Arc` / `Box`. -/// Integration: orchestrates per-file walks across the collectors. +/// +/// Runs in two passes: first collects type aliases across every file, +/// then collects fields/methods/functions/traits with the alias map +/// populated so aliased return types (`fn foo() -> AppResult`) +/// resolve through to their targets instead of caching the raw alias +/// path. Integration. pub fn build_workspace_type_index( files: &[(&str, &syn::File)], aliases_per_file: &HashMap>>, @@ -154,11 +165,59 @@ pub fn build_workspace_type_index( ) -> WorkspaceTypeIndex { let mut index = WorkspaceTypeIndex::new(); index.transparent_wrappers = transparent_wrappers.clone(); - for (path, ast) in files { - if cfg_test_files.contains(*path) { + let shared = |type_aliases| WalkInputs { + files, + aliases_per_file, + cfg_test_files, + crate_root_modules, + transparent_wrappers, + type_aliases, + }; + // Pass 1: aliases across all files (no alias map yet). + walk_files(&shared(None), &mut index, |index, ctx, ast| { + aliases::collect_from_file(index, ctx, ast) + }); + // Pass 2: fields/methods/functions/traits with alias map visible. + // `mem::take` lets us borrow the alias map immutably while still + // mutating other fields of `index`; we restore at the end. + let collected_aliases = std::mem::take(&mut index.type_aliases); + walk_files( + &shared(Some(&collected_aliases)), + &mut index, + |index, ctx, ast| { + fields::collect_from_file(index, ctx, ast); + methods::collect_from_file(index, ctx, ast); + functions::collect_from_file(index, ctx, ast); + traits::collect_from_file(index, ctx, ast); + }, + ); + index.type_aliases = collected_aliases; + index +} + +/// Inputs common to both index-build passes. Bundled so `walk_files` +/// doesn't exceed the SRP parameter count. +struct WalkInputs<'a> { + files: &'a [(&'a str, &'a syn::File)], + aliases_per_file: &'a HashMap>>, + cfg_test_files: &'a HashSet, + crate_root_modules: &'a HashSet, + transparent_wrappers: &'a HashSet, + type_aliases: Option<&'a HashMap>, +} + +/// Shared file-walk scaffold for both index build passes. Skips +/// cfg-test files and files without a pre-computed alias map; hands +/// the per-file `BuildContext` to `visit`. Integration. +fn walk_files(inputs: &WalkInputs<'_>, index: &mut WorkspaceTypeIndex, mut visit: F) +where + F: FnMut(&mut WorkspaceTypeIndex, &BuildContext<'_>, &syn::File), +{ + for (path, ast) in inputs.files { + if inputs.cfg_test_files.contains(*path) { continue; } - let Some(alias_map) = aliases_per_file.get(*path) else { + let Some(alias_map) = inputs.aliases_per_file.get(*path) else { continue; }; let local_symbols = collect_local_symbols(ast); @@ -166,14 +225,10 @@ pub fn build_workspace_type_index( path, alias_map, local_symbols: &local_symbols, - crate_root_modules, - transparent_wrappers, + crate_root_modules: inputs.crate_root_modules, + transparent_wrappers: inputs.transparent_wrappers, + type_aliases: inputs.type_aliases, }; - fields::collect_from_file(&mut index, &ctx, ast); - methods::collect_from_file(&mut index, &ctx, ast); - functions::collect_from_file(&mut index, &ctx, ast); - traits::collect_from_file(&mut index, &ctx, ast); - aliases::collect_from_file(&mut index, &ctx, ast); + visit(index, &ctx, ast); } - index } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs b/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs index 7ed2bc2..7d3fba0 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs @@ -168,19 +168,41 @@ pub(crate) fn collect_local_symbols(ast: &syn::File) -> HashSet { /// Extract `(name, &Type)` pairs for every typed positional parameter /// of a fn signature. Shared by pub-fn collection and graph-build since /// both need the same `FnContext::signature_params` shape. +/// Framework-extractor patterns like `fn h(State(db): State)` +/// contribute `("db", State)` — the outer type still goes through +/// `resolve_type`, which peels the transparent wrapper to reach `Db` +/// when `State` is configured in `transparent_wrappers`. pub(crate) fn extract_signature_params(sig: &syn::Signature) -> Vec<(String, &syn::Type)> { sig.inputs .iter() .filter_map(|arg| match arg { - syn::FnArg::Typed(pt) => match pt.pat.as_ref() { - syn::Pat::Ident(pi) => Some((pi.ident.to_string(), pt.ty.as_ref())), - _ => None, - }, + syn::FnArg::Typed(pt) => { + param_name_from_pat(pt.pat.as_ref()).map(|n| (n, pt.ty.as_ref())) + } _ => None, }) .collect() } +/// Pull the bound identifier out of a fn-parameter pattern. Supports +/// `Pat::Ident` (the 99% case) and single-ident `Pat::TupleStruct` +/// destructuring (framework extractors: `State(db)`, `Extension(ext)`, +/// `Path(p)`, `Json(body)`, `Data(ctx)`). Returns `None` for deeper +/// destructuring that the resolver can't express yet. +/// Operation: pattern peel. +fn param_name_from_pat(pat: &syn::Pat) -> Option { + match pat { + syn::Pat::Ident(pi) => Some(pi.ident.to_string()), + syn::Pat::TupleStruct(ts) if ts.elems.len() == 1 => { + if let syn::Pat::Ident(pi) = &ts.elems[0] { + return Some(pi.ident.to_string()); + } + None + } + _ => None, + } +} + /// Canonicalise an impl block's self-type through the same alias / /// local-symbol / crate-root pipeline the call collector uses for type /// bindings. Returns a crate-rooted segment list when the type resolves diff --git a/src/adapters/shared/cfg_test_files.rs b/src/adapters/shared/cfg_test_files.rs index 626954f..123d07e 100644 --- a/src/adapters/shared/cfg_test_files.rs +++ b/src/adapters/shared/cfg_test_files.rs @@ -119,15 +119,17 @@ impl<'a> ChildPathResolver<'a> { } else { parent.with_extension("") }; + // Normalize backslashes to forward slashes on Windows so the + // candidates match `known_paths` (which always store /). let candidate_file = child_dir .join(format!("{mod_name}.rs")) .to_string_lossy() - .into_owned(); + .replace('\\', "/"); let candidate_dir = child_dir .join(mod_name) .join("mod.rs") .to_string_lossy() - .into_owned(); + .replace('\\', "/"); if self.known_paths.contains(candidate_file.as_str()) { Some(candidate_file) } else if self.known_paths.contains(candidate_dir.as_str()) { From 2f4503c55c55999516f29b885053511022d0aa46 Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:08:54 +0200 Subject: [PATCH 03/30] fix: Copilot comments --- CHANGELOG.md | 7 -- Cargo.toml | 2 +- README.md | 8 +- .../architecture/call_parity_rule/calls.rs | 94 +++++++++++++------ .../call_parity_rule/tests/regressions.rs | 3 +- .../call_parity_rule/tests/support.rs | 3 +- .../type_infer/alias_substitution.rs | 83 ++++++++++++++++ .../call_parity_rule/type_infer/infer/mod.rs | 25 +++-- .../call_parity_rule/type_infer/mod.rs | 1 + .../call_parity_rule/type_infer/resolve.rs | 20 ++-- .../type_infer/workspace_index/aliases.rs | 28 ++++-- .../type_infer/workspace_index/fields.rs | 3 + .../type_infer/workspace_index/functions.rs | 13 ++- .../type_infer/workspace_index/methods.rs | 16 +++- .../type_infer/workspace_index/mod.rs | 13 +-- .../type_infer/workspace_index/traits.rs | 6 ++ .../analyzers/architecture/compiled.rs | 13 ++- 17 files changed, 249 insertions(+), 89 deletions(-) create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/alias_substitution.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9db9701..138de88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -161,13 +161,6 @@ additive and the legacy fast-path stays intact as a safety net. ### Known Limits Patterns that intentionally stay unresolved and produce `:name` fallback markers rather than fabricate edges: -- `let (a, s) = make_pair(); s.m()` — tuple destructuring. Tuple - element types aren't tracked. -- `for item in xs { item.m() }` — for-loop pattern binding doesn't - flow into the method-call collector yet. `item` stays unbound. -- `match res { Ok(s) => s.m(), … }` — `match`-arm pattern bindings - aren't wired into the scope stack. Use `let` or `if let` as - workarounds. - `Session::open().map(|r| r.m())` — closure-body argument type is unknown. Inner method call stays `:m`. - `fn get() -> T { … }; let x = get(); x.m()` without annotation diff --git a/Cargo.toml b/Cargo.toml index de247b3..461b345 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ default-run = "rustqual" exclude = ["docs/*", "prd-*.md", ".github/*", ".claude/*"] [dependencies] -syn = { version = "2", features = ["full", "parsing", "visit"] } +syn = { version = "2", features = ["full", "parsing", "visit", "visit-mut"] } quote = "1" proc-macro2 = { version = "1", features = ["span-locations"] } walkdir = "2" diff --git a/README.md b/README.md index 89b818b..e55a393 100644 --- a/README.md +++ b/README.md @@ -910,11 +910,9 @@ See `examples/architecture/call_parity/` for a runnable 3-adapter fixture. Known limits (documented, with clear workarounds): -- **Tuple destructuring** `let (a, s) = setup(); s.m()` — `s` stays - `:m`. Workaround: separate `let`s. -- **`for` / `match` pattern bindings** — `for item in xs { item.m() }` - and `match res { Ok(s) => s.m(), … }` don't flow pattern bindings - into the method-call scope. Workaround: extract into `let` first. +- **Closure-body arg types** `Session::open().map(|r| r.m())` — the + closure arg's type isn't inferred. Inner method call stays + `:m`. Workaround: pull the method call out of the closure. - **Unannotated generics** `let x = get(); x.m()` where `get() -> T` — use turbofish `get::()` or `let x: T = get();`. - **`impl Trait`-returned inherent methods** — only trait methods diff --git a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs index 56662e9..3239cef 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs @@ -98,15 +98,15 @@ struct CanonicalCallCollector<'a> { /// Inner-most scope is at the end; lookup walks from back to front. /// Always non-empty while a collection is in flight. bindings: Vec>>, - /// Flat signature-param scope for bindings whose inferred type - /// isn't a simple `Path` — trait bounds (`dyn Trait`) and stdlib - /// wrappers (`Result`, `Option`, `Future`, `Vec`, - /// `HashMap<_, V>`). Kept separate because the legacy `bindings` - /// stack stores concrete `Vec` paths; wrapper-typed - /// bindings carry enough structure that we need the full - /// `CanonicalType` (so `.unwrap()` / `.await` / `?` on signature - /// params can unwrap them correctly). - non_path_bindings: HashMap, + /// Parallel scope stack for bindings whose inferred type isn't a + /// simple `Path` — trait bounds (`dyn Trait`) and stdlib wrappers + /// (`Result`, `Option`, `Future`, `Vec`, + /// `HashMap<_, V>`). Pushed/popped in lockstep with `bindings` so + /// non-path bindings respect lexical scope just like path ones. + /// Kept parallel (not merged into a single `CanonicalType` stack) + /// because the legacy fast-path reads from `bindings` by segment + /// vector directly — migrating that is a separate refactor. + non_path_bindings: Vec>, calls: HashSet, /// Workspace type-index for shallow inference fallback. Mirrored /// from `FnContext` so the visitor doesn't need the full context @@ -136,7 +136,7 @@ impl<'a> CanonicalCallCollector<'a> { self_type_canonical, signature_params: ctx.signature_params.clone(), bindings: vec![HashMap::new()], - non_path_bindings: HashMap::new(), + non_path_bindings: vec![HashMap::new()], calls: HashSet::new(), workspace_index: ctx.workspace_index, } @@ -186,17 +186,20 @@ impl<'a> CanonicalCallCollector<'a> { } CanonicalType::Opaque => {} other => { - self.non_path_bindings.insert(name.to_string(), other); + // Signature params always seed the outermost scope (frame 0). + self.non_path_bindings[0].insert(name.to_string(), other); } } } fn enter_scope(&mut self) { self.bindings.push(HashMap::new()); + self.non_path_bindings.push(HashMap::new()); } fn exit_scope(&mut self) { self.bindings.pop(); + self.non_path_bindings.pop(); } /// Return the innermost binding scope. The stack is seeded non-empty @@ -211,6 +214,33 @@ impl<'a> CanonicalCallCollector<'a> { &mut self.bindings[last] } + /// Parallel accessor for the non-path scope stack. Same invariants + /// and fallback semantics as `current_scope_mut`. + fn current_non_path_scope_mut(&mut self) -> &mut HashMap { + if self.non_path_bindings.is_empty() { + self.non_path_bindings.push(HashMap::new()); + } + let last = self.non_path_bindings.len() - 1; + &mut self.non_path_bindings[last] + } + + /// Install a binding in the path-scope and evict any stale entry + /// for the same name in the non-path scope (a `let` that shadows a + /// previous wrapper-typed binding with a plain Path binding). + /// Operation. + fn install_path_binding(&mut self, name: String, segs: Vec) { + self.current_non_path_scope_mut().remove(&name); + self.current_scope_mut().insert(name, segs); + } + + /// Install a wrapper / trait-bound binding in the non-path scope + /// and evict any stale Path binding for the same name (shadowing + /// the other way). Operation. + fn install_non_path_binding(&mut self, name: String, ty: CanonicalType) { + self.current_scope_mut().remove(&name); + self.current_non_path_scope_mut().insert(name, ty); + } + fn resolve_binding(&self, ident: &str) -> Option<&Vec> { for scope in self.bindings.iter().rev() { if let Some(v) = scope.get(ident) { @@ -356,8 +386,8 @@ impl<'a> CanonicalCallCollector<'a> { /// results (wrappers, trait bounds) into `non_path_bindings` so /// downstream `?` / `.await` / trait-dispatch on `x` resolve /// correctly. Only simple `Pat::Ident` patterns are handled here; - /// destructuring goes through Task 1.4's `patterns::extract_bindings` - /// which is wired up separately. Operation. + /// destructuring is handled separately via `patterns::extract_bindings`. + /// Operation. fn install_inferred_let_binding(&mut self, local: &syn::Local) { let Some(init) = local.init.as_ref() else { return; @@ -369,13 +399,9 @@ impl<'a> CanonicalCallCollector<'a> { return; }; match inferred { - CanonicalType::Path(segs) => { - self.current_scope_mut().insert(name, segs); - } + CanonicalType::Path(segs) => self.install_path_binding(name, segs), CanonicalType::Opaque => {} - other => { - self.non_path_bindings.insert(name, other); - } + other => self.install_non_path_binding(name, other), } } @@ -443,13 +469,9 @@ impl<'a> CanonicalCallCollector<'a> { fn install_binding_pairs(&mut self, pairs: Vec<(String, CanonicalType)>) { for (name, ty) in pairs { match ty { - CanonicalType::Path(segs) => { - self.current_scope_mut().insert(name, segs); - } + CanonicalType::Path(segs) => self.install_path_binding(name, segs), CanonicalType::Opaque => {} - other => { - self.non_path_bindings.insert(name, other); - } + other => self.install_non_path_binding(name, other), } } } @@ -553,24 +575,34 @@ fn trait_dispatch_edges( /// by the legacy `extract_let_binding`. struct CollectorBindings<'a> { scope: &'a [HashMap>], - non_path_scope: &'a HashMap, + non_path_scope: &'a [HashMap], } impl BindingLookup for CollectorBindings<'_> { fn lookup(&self, ident: &str) -> Option { - for frame in self.scope.iter().rev() { - if let Some(segs) = frame.get(ident) { + // Walk both stacks in lockstep from innermost to outermost so + // shadowing works across kinds (a wrapper-typed `let` hides an + // outer path-typed `let` with the same name and vice versa). + // Install helpers evict the sibling entry at the same level, so + // at most one map hits per frame. + for (path_frame, non_path_frame) in + self.scope.iter().rev().zip(self.non_path_scope.iter().rev()) + { + if let Some(ty) = non_path_frame.get(ident) { + return Some(ty.clone()); + } + if let Some(segs) = path_frame.get(ident) { return Some(CanonicalType::Path(segs.clone())); } } - self.non_path_scope.get(ident).cloned() + None } } /// Peel `Pat::Type` wrappers to reach a `Pat::Ident` and return its /// identifier. Returns `None` for destructuring / tuple / struct -/// patterns — those flow through `patterns::extract_bindings` in a -/// future Task 1.6 extension. Operation: recursive pattern peel. +/// patterns — those flow through `patterns::extract_bindings`. +/// Operation: recursive pattern peel. // qual:recursive fn extract_pat_ident_name(pat: &syn::Pat) -> Option { match pat { diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs index 2d63e61..33ab0dc 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs @@ -648,9 +648,10 @@ fn type_alias_expands_to_target_via_signature_param() { // Pre-populate the alias: `crate::cli::handlers::DbRef` → syn::Type // for `std::sync::Arc`. let aliased: syn::Type = syn::parse_str("std::sync::Arc").expect("parse alias target"); + // Non-generic alias — no params to substitute. index .type_aliases - .insert("crate::cli::handlers::DbRef".to_string(), aliased); + .insert("crate::cli::handlers::DbRef".to_string(), (Vec::new(), aliased)); // Store::read() method. index.method_returns.insert( ( diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/support.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/support.rs index 77a0543..343f097 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/support.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/support.rs @@ -71,13 +71,12 @@ pub(super) fn run_check( ) -> Vec { let borrowed = borrowed_files(ws); let pub_fns = collect_pub_fns_by_layer(&borrowed, &ws.aliases_per_file, layers, cfg_test); - let empty_wrappers = HashSet::new(); let graph = build_call_graph( &borrowed, &ws.aliases_per_file, cfg_test, layers, - &empty_wrappers, + &cp.transparent_wrappers, ); match which { Check::A => check_no_delegation(&pub_fns, &graph, layers, cp), diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/alias_substitution.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/alias_substitution.rs new file mode 100644 index 0000000..3f54cfc --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/alias_substitution.rs @@ -0,0 +1,83 @@ +//! Generic-alias argument substitution. +//! +//! Turns `Alias` at a use site plus a stored alias +//! definition `type Alias = Target` into the concrete `Target` +//! with `P1 → ArgA` and `P2 → ArgB` applied. Called from +//! `resolve::resolve_generic_path` when a path canonicalises to a +//! recorded type alias — without this, generic aliases like +//! `type AppResult = Result` would cache `Result` with `T` unbound and `.unwrap()` would yield `Opaque`. + +use std::collections::HashMap; +use syn::visit_mut::VisitMut; + +// qual:api +/// Substitute an alias's generic parameters with the use-site's type +/// arguments, cloning the stored target and rewriting each `Type::Path` +/// whose single ident matches a param name. Falls back to an +/// unmodified clone when the counts don't line up — the target's +/// remaining unbound params will resolve to `Opaque` downstream, +/// matching the pre-Stage-3 behaviour. Operation. +pub(super) fn substitute_alias_args( + target: &syn::Type, + params: &[String], + use_site: &syn::Path, +) -> syn::Type { + let mut expanded = target.clone(); + if params.is_empty() { + return expanded; + } + let args = use_site_type_args(use_site); + if args.len() != params.len() { + return expanded; + } + let subs: HashMap<&str, &syn::Type> = params + .iter() + .map(String::as_str) + .zip(args) + .collect(); + AliasSubstitutor { subs: &subs }.visit_type_mut(&mut expanded); + expanded +} + +/// Extract the use-site type arguments from the last segment of a +/// path. Lifetime/const args are skipped; only `Type` args count. +/// Operation. +fn use_site_type_args(path: &syn::Path) -> Vec<&syn::Type> { + let Some(last) = path.segments.last() else { + return Vec::new(); + }; + let syn::PathArguments::AngleBracketed(ab) = &last.arguments else { + return Vec::new(); + }; + ab.args + .iter() + .filter_map(|a| match a { + syn::GenericArgument::Type(t) => Some(t), + _ => None, + }) + .collect() +} + +/// `VisitMut` adapter that replaces single-segment type idents matching +/// an alias param with the corresponding use-site type. +struct AliasSubstitutor<'a> { + subs: &'a HashMap<&'a str, &'a syn::Type>, +} + +impl<'a> VisitMut for AliasSubstitutor<'a> { + fn visit_type_mut(&mut self, ty: &mut syn::Type) { + if let syn::Type::Path(tp) = ty { + if tp.qself.is_none() && tp.path.segments.len() == 1 { + let seg = &tp.path.segments[0]; + if matches!(seg.arguments, syn::PathArguments::None) { + if let Some(replacement) = self.subs.get(seg.ident.to_string().as_str()) { + *ty = (*replacement).clone(); + return; + } + } + } + } + syn::visit_mut::visit_type_mut(self, ty); + } +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/mod.rs index f8fba84..981f9a8 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/mod.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/mod.rs @@ -1,20 +1,17 @@ -//! Shallow type-inference engine — Task 1.3. +//! Shallow type-inference engine. //! //! Public API: `infer_type(expr, ctx) -> Option`. //! //! Dispatches over `syn::Expr` variants (see //! `docs/rustqual-design-receiver-type-inference.md` §3). Each variant //! delegates to `call` or `access` modules; transparent wrappers -//! (`Paren`, `Reference`, `Group`) recurse directly. +//! (`Paren`, `Reference`, `Group`) recurse directly. Stdlib +//! Result/Option/Future combinators (`.unwrap()`, `.map_err()`, `.await`, +//! …) are handled via the `combinators` table. //! -//! What's NOT in Task 1.3: -//! - Stdlib Result/Option/Future combinators (`.unwrap()`, `.map_err()`, -//! …) — those arrive in Task 1.5. Until then, `.unwrap()` on a -//! `Result` resolves to `Opaque` because `Result::unwrap` isn't -//! in the workspace index. -//! - Pattern-binding scope construction — Task 1.4. The engine here -//! consumes bindings through the `BindingLookup` trait; callers are -//! responsible for populating them. +//! The engine consumes bindings through the `BindingLookup` trait — +//! callers (the call-graph collector, pattern-binding helpers) are +//! responsible for populating the scope before delegating here. pub mod access; pub mod call; @@ -25,10 +22,10 @@ use super::workspace_index::WorkspaceTypeIndex; use std::collections::{HashMap, HashSet}; /// Look up a scoped variable name → inferred type. Implementations may -/// back this by a flat map (tests), a stack of maps (Task 1.4's -/// `BindingScope`), or an adapter over the collector's existing scope. -/// Returns an owned value so adapters can synthesize `CanonicalType`s -/// on the fly without lifetime gymnastics. +/// back this by a flat map (tests), a stack of maps, or an adapter over +/// the collector's existing scope. Returns an owned value so adapters +/// can synthesize `CanonicalType`s on the fly without lifetime +/// gymnastics. pub trait BindingLookup { fn lookup(&self, ident: &str) -> Option; } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/mod.rs index 46ebb39..77e3f20 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/mod.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/mod.rs @@ -8,6 +8,7 @@ //! Design reference: `docs/rustqual-design-receiver-type-inference.md`. //! Plan: `~/.claude/plans/cached-noodling-frog.md`. +pub(crate) mod alias_substitution; pub mod canonical; pub mod combinators; pub mod infer; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs index cccedb2..10ceeec 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs @@ -11,6 +11,7 @@ //! inference engine (Task 1.3) — both turn `syn::Type`s into //! `CanonicalType`s with identical semantics. +use super::alias_substitution::substitute_alias_args; use super::super::bindings::canonicalise_type_segments; use super::canonical::CanonicalType; use std::collections::{HashMap, HashSet}; @@ -25,8 +26,10 @@ pub(crate) struct ResolveContext<'a> { /// Stage 3 workspace-wide type aliases. `None` means the caller /// doesn't need alias expansion (the workspace-index build phase, /// where the alias map is still being populated). Inference paths - /// pass `Some(&workspace.type_aliases)`. - pub type_aliases: Option<&'a HashMap>, + /// pass `Some(&workspace.type_aliases)`. The stored tuple carries + /// the alias's generic-param names plus its target — use-site args + /// are substituted into the target before recursion. + pub type_aliases: Option<&'a HashMap, syn::Type)>>, /// Stage 3 user-defined transparent wrappers — the last-ident /// names (e.g. `"State"`, `"Extension"`, `"Data"`) that are peeled /// just like `Arc` / `Box`. `None` means only stdlib wrappers are @@ -239,10 +242,10 @@ fn peel_single_generic( } /// Resolve a non-wrapper path through the shared canonicalisation -/// pipeline (alias map / local symbols / crate roots). Returns `Opaque` -/// for unresolvable names (external, generic parameter, unknown). If -/// the canonicalised name matches a recorded workspace type-alias, the -/// alias target is recursively resolved. Operation: closure-hidden calls. +/// pipeline (alias map / local symbols / crate roots). If the canonical +/// matches a recorded workspace type-alias, the alias target is +/// substituted with use-site generic args and recursively resolved. +/// Operation: closure-hidden calls + alias dispatch. fn resolve_generic_path(path: &syn::Path, ctx: &ResolveContext<'_>, depth: u8) -> CanonicalType { let recurse = |t: &syn::Type| resolve_type_with_depth(t, ctx, depth); let canonicalise = |segs: &[String]| { @@ -259,8 +262,9 @@ fn resolve_generic_path(path: &syn::Path, ctx: &ResolveContext<'_>, depth: u8) - return CanonicalType::Opaque; }; let key = resolved.join("::"); - if let Some(aliased) = ctx.type_aliases.and_then(|m| m.get(&key)) { - return recurse(aliased); + if let Some((params, target)) = ctx.type_aliases.and_then(|m| m.get(&key)) { + let expanded = substitute_alias_args(target, params, path); + return recurse(&expanded); } CanonicalType::Path(resolved) } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/aliases.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/aliases.rs index 7da7d7b..5938447 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/aliases.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/aliases.rs @@ -1,11 +1,13 @@ //! Type-alias collection. //! -//! For every top-level `type Alias = Target;` in the workspace, record -//! `canonical_Alias → Target` as a `syn::Type` clone. The inference -//! engine expands these on the fly when `resolve_type` encounters a -//! path whose canonical matches a recorded alias — useful for -//! `type Db = Arc>` style indirection that otherwise -//! leaves user handlers unresolved. +//! For every top-level `type Alias = Target;` in the +//! workspace, record `canonical_Alias → (params, Target)` as +//! `(Vec, syn::Type)`. The generic parameter names are kept +//! so use-sites like `Alias` can substitute them into +//! `Target` before resolution — without that, generic aliases like +//! `type AppResult = Result` would cache `Result` with `T` unbound and downstream `.unwrap()` would return +//! `Opaque`. use super::{canonical_type_key, BuildContext, WorkspaceTypeIndex}; use crate::adapters::shared::cfg_test::has_cfg_test; @@ -28,10 +30,22 @@ struct AliasCollector<'i, 'c> { impl<'ast, 'i, 'c> Visit<'ast> for AliasCollector<'i, 'c> { fn visit_item_type(&mut self, node: &'ast syn::ItemType) { + if has_cfg_test(&node.attrs) { + return; + } let canonical = canonical_type_key(&[node.ident.to_string()], self.ctx); + let params: Vec = node + .generics + .params + .iter() + .filter_map(|p| match p { + syn::GenericParam::Type(t) => Some(t.ident.to_string()), + _ => None, + }) + .collect(); self.index .type_aliases - .insert(canonical, (*node.ty).clone()); + .insert(canonical, (params, (*node.ty).clone())); } fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) { diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs index 7bfb563..0998e97 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs @@ -32,6 +32,9 @@ struct FieldCollector<'i, 'c> { impl<'ast, 'i, 'c> Visit<'ast> for FieldCollector<'i, 'c> { fn visit_item_struct(&mut self, node: &'ast syn::ItemStruct) { + if has_cfg_test(&node.attrs) { + return; + } record_struct(self.index, self.ctx, node); } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs index 41854c0..cc82642 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs @@ -48,16 +48,23 @@ impl<'ast, 'i, 'c> Visit<'ast> for FnCollector<'i, 'c> { } } -/// Record one free fn's return type. Operation. Own calls hidden. +/// Record one free fn's return type. `async fn foo() -> T` is treated +/// as returning `Future` to match rustc's desugaring so +/// downstream `.await` unwraps correctly. Operation. fn record_fn(index: &mut WorkspaceTypeIndex, ctx: &BuildContext<'_>, node: &syn::ItemFn) { let resolve = |ty: &syn::Type| resolve_type(ty, &resolve_ctx_from_build(ctx)); let syn::ReturnType::Type(_, ret_ty) = &node.sig.output else { return; }; - let ret = resolve(ret_ty); - if matches!(ret, CanonicalType::Opaque) { + let inner = resolve(ret_ty); + if matches!(inner, CanonicalType::Opaque) { return; } + let ret = if node.sig.asyncness.is_some() { + CanonicalType::Future(Box::new(inner)) + } else { + inner + }; let canonical = canonical_fn_name(&node.sig.ident.to_string(), ctx); index.fn_returns.insert(canonical, ret); } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs index e733715..f408764 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs @@ -45,6 +45,9 @@ struct MethodCollector<'i, 'c> { impl<'ast, 'i, 'c> Visit<'ast> for MethodCollector<'i, 'c> { fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) { + if has_cfg_test(&node.attrs) { + return; + } let resolved = resolve_impl_self_type( &node.self_ty, self.ctx.alias_map, @@ -73,7 +76,9 @@ impl<'ast, 'i, 'c> Visit<'ast> for MethodCollector<'i, 'c> { } /// Record a single method's return type, keyed on the enclosing impl's -/// canonical self-type. Operation. Own calls hidden in closures. +/// canonical self-type. `async fn m() -> T` is treated as returning +/// `Future` to match rustc's desugaring. +/// Operation. Own calls hidden in closures. fn record_method( index: &mut WorkspaceTypeIndex, ctx: &BuildContext<'_>, @@ -88,10 +93,15 @@ fn record_method( let syn::ReturnType::Type(_, ret_ty) = &node.sig.output else { return; }; - let ret = resolve(ret_ty); - if matches!(ret, CanonicalType::Opaque) { + let inner = resolve(ret_ty); + if matches!(inner, CanonicalType::Opaque) { return; } + let ret = if node.sig.asyncness.is_some() { + CanonicalType::Future(Box::new(inner)) + } else { + inner + }; let receiver_canonical = canon(impl_segs); let method_name = node.sig.ident.to_string(); index diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs index 7626a50..0a4392b 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs @@ -35,7 +35,7 @@ pub(super) struct BuildContext<'a> { /// pass 2 so fields/methods/functions/traits that reference an /// alias are resolved through the alias target instead of caching /// the raw alias name. - pub type_aliases: Option<&'a HashMap>, + pub type_aliases: Option<&'a HashMap, syn::Type)>>, } /// Build a canonical type-path key by prefixing the impl/trait segments @@ -89,10 +89,11 @@ pub struct WorkspaceTypeIndex { /// trait-dispatch so `dyn Trait.unrelated_method()` stays /// unresolved. pub trait_methods: HashMap>, - /// Stage 3 — `alias_canonical → target syn::Type`. Expanded on - /// the fly during inference; the clone here is cheap (typical - /// aliases are small trees). - pub type_aliases: HashMap, + /// Stage 3 — `alias_canonical → (generic_param_names, target)`. + /// Params are captured so use-sites like `Alias` can + /// substitute the params' idents in `target` before resolution. + /// Aliases without generics just have an empty `Vec`. + pub type_aliases: HashMap, syn::Type)>, /// Stage 3 — user-configured last-ident names to treat as /// transparent single-type-param wrappers (framework extractors /// like `State` / `Data`). Mirrored from the @@ -203,7 +204,7 @@ struct WalkInputs<'a> { cfg_test_files: &'a HashSet, crate_root_modules: &'a HashSet, transparent_wrappers: &'a HashSet, - type_aliases: Option<&'a HashMap>, + type_aliases: Option<&'a HashMap, syn::Type)>>, } /// Shared file-walk scaffold for both index build passes. Skips diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs index 323beca..7af27d5 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs @@ -36,10 +36,16 @@ struct TraitCollector<'i, 'c> { impl<'ast, 'i, 'c> Visit<'ast> for TraitCollector<'i, 'c> { fn visit_item_trait(&mut self, node: &'ast syn::ItemTrait) { + if has_cfg_test(&node.attrs) { + return; + } record_trait_methods(self.index, self.ctx, node); } fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) { + if has_cfg_test(&node.attrs) { + return; + } record_trait_impl(self.index, self.ctx, node); } diff --git a/src/adapters/analyzers/architecture/compiled.rs b/src/adapters/analyzers/architecture/compiled.rs index 9e7e019..7b73dfe 100644 --- a/src/adapters/analyzers/architecture/compiled.rs +++ b/src/adapters/analyzers/architecture/compiled.rs @@ -122,7 +122,11 @@ fn compile_call_parity( } let exclude_targets = build_globset(&cp.exclude_targets) .map_err(|e| format!("call_parity.exclude_targets: {e}"))?; - let transparent_wrappers: HashSet = cp.transparent_wrappers.iter().cloned().collect(); + let transparent_wrappers: HashSet = cp + .transparent_wrappers + .iter() + .map(|w| last_path_segment(w).to_string()) + .collect(); let transparent_macros = build_transparent_macros(&cp.transparent_macros); Ok(Some(CompiledCallParity { adapters: cp.adapters.clone(), @@ -134,6 +138,13 @@ fn compile_call_parity( })) } +/// Return the last `::`-separated segment of a path-like wrapper entry +/// so users can write `axum::extract::State` or just `State` and both +/// match resolver lookups keyed on the type's last ident. Operation. +fn last_path_segment(path: &str) -> &str { + path.rsplit("::").next().unwrap_or(path) +} + /// Stage 3 starter-pack: prepend these common framework attribute-macro /// names so users don't need to list them in every rustqual.toml. The /// full set is `defaults ∪ user`. Operation. From 94bfc4ba2077df5feb8cd8b293f1dabc2626be5a Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:10:15 +0200 Subject: [PATCH 04/30] fix: linting --- .../analyzers/architecture/call_parity_rule/calls.rs | 7 +++++-- .../architecture/call_parity_rule/tests/regressions.rs | 7 ++++--- .../call_parity_rule/type_infer/alias_substitution.rs | 6 +----- .../architecture/call_parity_rule/type_infer/resolve.rs | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs index 3239cef..06ba1ef 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs @@ -585,8 +585,11 @@ impl BindingLookup for CollectorBindings<'_> { // outer path-typed `let` with the same name and vice versa). // Install helpers evict the sibling entry at the same level, so // at most one map hits per frame. - for (path_frame, non_path_frame) in - self.scope.iter().rev().zip(self.non_path_scope.iter().rev()) + for (path_frame, non_path_frame) in self + .scope + .iter() + .rev() + .zip(self.non_path_scope.iter().rev()) { if let Some(ty) = non_path_frame.get(ident) { return Some(ty.clone()); diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs index 33ab0dc..7aced58 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs @@ -649,9 +649,10 @@ fn type_alias_expands_to_target_via_signature_param() { // for `std::sync::Arc`. let aliased: syn::Type = syn::parse_str("std::sync::Arc").expect("parse alias target"); // Non-generic alias — no params to substitute. - index - .type_aliases - .insert("crate::cli::handlers::DbRef".to_string(), (Vec::new(), aliased)); + index.type_aliases.insert( + "crate::cli::handlers::DbRef".to_string(), + (Vec::new(), aliased), + ); // Store::read() method. index.method_returns.insert( ( diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/alias_substitution.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/alias_substitution.rs index 3f54cfc..cd94697 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/alias_substitution.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/alias_substitution.rs @@ -31,11 +31,7 @@ pub(super) fn substitute_alias_args( if args.len() != params.len() { return expanded; } - let subs: HashMap<&str, &syn::Type> = params - .iter() - .map(String::as_str) - .zip(args) - .collect(); + let subs: HashMap<&str, &syn::Type> = params.iter().map(String::as_str).zip(args).collect(); AliasSubstitutor { subs: &subs }.visit_type_mut(&mut expanded); expanded } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs index 10ceeec..4147d3c 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs @@ -11,8 +11,8 @@ //! inference engine (Task 1.3) — both turn `syn::Type`s into //! `CanonicalType`s with identical semantics. -use super::alias_substitution::substitute_alias_args; use super::super::bindings::canonicalise_type_segments; +use super::alias_substitution::substitute_alias_args; use super::canonical::CanonicalType; use std::collections::{HashMap, HashSet}; From 805229fe0ac54d3143f9ba6540a5b4272e174e42 Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:26:02 +0200 Subject: [PATCH 05/30] fix: copilot comments --- CHANGELOG.md | 5 +- README.md | 3 +- .../architecture/call_parity_rule/calls.rs | 50 +++++++++++++++++-- .../call_parity_rule/type_infer/resolve.rs | 22 +++++--- .../type_infer/tests/resolve.rs | 46 +++++++++++++++-- .../analyzers/architecture/compiled.rs | 2 +- 6 files changed, 109 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 138de88..20f0731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -166,8 +166,9 @@ fallback markers rather than fabricate edges: - `fn get() -> T { … }; let x = get(); x.m()` without annotation or turbofish. Use `let x: T = get();` or `get::()`. - `fn make() -> impl Trait { … }; make().inherent_method()` — - `impl Trait` hides the concrete type by design. Only trait methods - are resolvable (via trait-dispatch over-approximation). + `impl Trait` hides the concrete type by design. Methods declared on + the trait resolve via trait-dispatch over-approximation; inherent + methods stay `:name`. - Arbitrary proc-macros that alter the call graph without being in `transparent_macros` config. User-annotate via `// qual:allow(architecture)` on the enclosing fn. diff --git a/README.md b/README.md index e55a393..ed4c4b2 100644 --- a/README.md +++ b/README.md @@ -915,8 +915,7 @@ Known limits (documented, with clear workarounds): `:m`. Workaround: pull the method call out of the closure. - **Unannotated generics** `let x = get(); x.m()` where `get() -> T` — use turbofish `get::()` or `let x: T = get();`. -- **`impl Trait`-returned inherent methods** — only trait methods - resolve (via trait-dispatch over-approximation). +- **`impl Trait` inherent methods** — `fn make() -> impl Handler; make().trait_method()` resolves to every workspace impl of `Handler::trait_method` via over-approximation, but an inherent method not declared on `Handler` can't be reached (the concrete type is hidden by design). - **Arbitrary proc-macros** not listed in `transparent_macros` — `// qual:allow(architecture)` on the enclosing fn is the escape. diff --git a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs index 06ba1ef..5690fcb 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs @@ -381,6 +381,40 @@ impl<'a> CanonicalCallCollector<'a> { infer_type(expr, &ctx) } + /// `let x: T = …` — route the annotation through the full resolver + /// when a workspace index is available so alias expansion + wrapper + /// peeling + trait-bound extraction all apply. Returns `true` when + /// a binding was installed (or the annotation resolved to `Opaque` + /// and should be dropped entirely). Returns `false` when there's no + /// annotation or no workspace index, handing off to the legacy + /// fast-path. Operation: closure-hidden resolution. + fn try_install_annotated_binding(&mut self, local: &syn::Local) -> bool { + let Some(wi) = self.workspace_index else { + return false; + }; + let syn::Pat::Type(pt) = &local.pat else { + return false; + }; + let syn::Pat::Ident(pi) = pt.pat.as_ref() else { + return false; + }; + let rctx = ResolveContext { + alias_map: self.alias_map, + local_symbols: self.local_symbols, + crate_root_modules: self.crate_root_modules, + importing_file: self.importing_file, + type_aliases: Some(&wi.type_aliases), + transparent_wrappers: Some(&wi.transparent_wrappers), + }; + let name = pi.ident.to_string(); + match resolve_type(pt.ty.as_ref(), &rctx) { + CanonicalType::Path(segs) => self.install_path_binding(name, segs), + CanonicalType::Opaque => {} + other => self.install_non_path_binding(name, other), + } + true + } + /// Install a `let x = expr` binding via shallow inference on the /// initializer. `Path` results go into the legacy scope, non-Path /// results (wrappers, trait bounds) into `non_path_bindings` so @@ -649,9 +683,17 @@ impl<'a, 'ast> Visit<'ast> for CanonicalCallCollector<'a> { self.visit_expr(else_expr); } } - // Fast-path: direct `let s = T::ctor()` / `let s: T = …` — - // the legacy prefix-based extractor resolves without needing a - // populated workspace index, so unit-test fixtures work. + // `let x: T = …` with a workspace index — route the annotation + // through the full resolver so Stage-3 alias expansion + wrapper + // peeling + trait-bound extraction apply. Without this, `let r: + // AppResult = …` would cache the raw alias path and + // later `r.unwrap().m()` would miss the method-return edge. + if self.try_install_annotated_binding(local) { + return; + } + // Fast-path: direct `let s = T::ctor()` — the legacy prefix-based + // extractor resolves without needing a populated workspace index, + // so unit-test fixtures work. if let Some((name, ty_canonical)) = extract_let_binding( local, self.alias_map, @@ -659,7 +701,7 @@ impl<'a, 'ast> Visit<'ast> for CanonicalCallCollector<'a> { self.crate_root_modules, self.importing_file, ) { - self.current_scope_mut().insert(name, ty_canonical); + self.install_path_binding(name, ty_canonical); return; } // Simple-ident inference fallback (handles method chains + wrapper types). diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs index 4147d3c..e8f1afa 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs @@ -84,18 +84,26 @@ fn dispatch_type(ty: &syn::Type, ctx: &ResolveContext<'_>, next: u8) -> Canonica syn::Type::Path(tp) => resolve_path(&tp.path, ctx, next), syn::Type::Array(a) => into_slice(recurse(&a.elem)), syn::Type::Slice(s) => into_slice(recurse(&s.elem)), - syn::Type::TraitObject(tto) => resolve_trait_object(tto, ctx), - syn::Type::ImplTrait(_) => CanonicalType::Opaque, + syn::Type::TraitObject(tto) => resolve_bound_list(&tto.bounds, ctx), + // `impl Trait` return type — the concrete type is hidden by the + // compiler, but we can still extract the first non-marker trait + // bound and treat the result like `dyn Trait` so trait-dispatch + // over-approximation fires on the method call. + syn::Type::ImplTrait(iti) => resolve_bound_list(&iti.bounds, ctx), _ => CanonicalType::Opaque, } } -/// `dyn Trait + Send + 'static` → `TraitBound(["crate", "…", "Trait"])`. +/// Extract the first non-marker trait bound from a `dyn T1 + T2` or +/// `impl T1 + T2` bound list and canonicalise it to `TraitBound(path)`. /// Marker traits (`Send`, `Sync`, `Unpin`, `Copy`, `Clone`, etc.) and -/// lifetime bounds are skipped; the first non-marker trait wins. Yields -/// `Opaque` if no resolvable trait bound exists. Operation. -fn resolve_trait_object(tto: &syn::TypeTraitObject, ctx: &ResolveContext<'_>) -> CanonicalType { - for bound in &tto.bounds { +/// lifetime bounds are skipped. Yields `Opaque` if no resolvable trait +/// bound exists. Operation. +fn resolve_bound_list( + bounds: &syn::punctuated::Punctuated, + ctx: &ResolveContext<'_>, +) -> CanonicalType { + for bound in bounds { let syn::TypeParamBound::Trait(trait_bound) = bound else { continue; }; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs index 0cbc4a8..9e6bd1c 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs @@ -165,26 +165,66 @@ fn test_slice_type_becomes_slice() { } #[test] -fn test_trait_object_is_opaque() { +fn test_trait_object_unresolved_is_opaque() { let alias_map = HashMap::new(); let local = HashSet::new(); let roots = HashSet::new(); let ty = parse_type("Box"); let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/foo.rs")); - // Box → strip Box → dyn T → Opaque + // Box → strip Box → dyn T — when T isn't resolvable (not in + // local symbols / alias map / crate roots), stays Opaque. assert_eq!(resolved, CanonicalType::Opaque); } #[test] -fn test_impl_trait_is_opaque() { +fn test_trait_object_resolves_via_local_symbols() { + let alias_map = HashMap::new(); + let mut local = HashSet::new(); + local.insert("Handler".to_string()); + let roots = HashSet::new(); + let ty = parse_type("Box"); + let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/app/mod.rs")); + assert_eq!( + resolved, + CanonicalType::TraitBound(vec![ + "crate".to_string(), + "app".to_string(), + "Handler".to_string(), + ]) + ); +} + +#[test] +fn test_impl_trait_unresolved_is_opaque() { let alias_map = HashMap::new(); let local = HashSet::new(); let roots = HashSet::new(); + // `Iterator` isn't in local symbols / alias map — stays Opaque. let ty = parse_type("impl Iterator"); let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/foo.rs")); assert_eq!(resolved, CanonicalType::Opaque); } +#[test] +fn test_impl_trait_resolves_to_trait_bound() { + let alias_map = HashMap::new(); + let mut local = HashSet::new(); + local.insert("Handler".to_string()); + let roots = HashSet::new(); + // `impl Handler` return-type resolves to `TraitBound(Handler)` so + // trait-dispatch over-approximation can fire on the method call. + let ty = parse_type("impl Handler + Send"); + let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/app/mod.rs")); + assert_eq!( + resolved, + CanonicalType::TraitBound(vec![ + "crate".to_string(), + "app".to_string(), + "Handler".to_string(), + ]) + ); +} + #[test] fn test_unknown_external_path_is_opaque() { let alias_map = HashMap::new(); diff --git a/src/adapters/analyzers/architecture/compiled.rs b/src/adapters/analyzers/architecture/compiled.rs index 7b73dfe..9321d0f 100644 --- a/src/adapters/analyzers/architecture/compiled.rs +++ b/src/adapters/analyzers/architecture/compiled.rs @@ -162,7 +162,7 @@ fn build_transparent_macros(user: &[String]) -> HashSet { "cfg_attr", // conditional attribute ]; let mut set: HashSet = DEFAULTS.iter().map(|s| (*s).to_string()).collect(); - set.extend(user.iter().cloned()); + set.extend(user.iter().map(|s| last_path_segment(s.trim()).to_string())); set } From bb17bb08d5c9640f43eff7a7d5d012fe89ec8b02 Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:51:26 +0200 Subject: [PATCH 06/30] fix: copilot comments --- .../architecture/call_parity_rule/calls.rs | 46 +++++++++------ .../call_parity_rule/type_infer/resolve.rs | 26 ++++++--- .../type_infer/tests/resolve.rs | 19 +++++- .../type_infer/tests/workspace_index.rs | 58 +++++++++++++++++++ .../type_infer/workspace_index/aliases.rs | 11 +++- .../type_infer/workspace_index/fields.rs | 31 +++++++--- .../type_infer/workspace_index/functions.rs | 26 +++++++-- .../type_infer/workspace_index/methods.rs | 18 +++++- .../type_infer/workspace_index/mod.rs | 15 +++-- .../type_infer/workspace_index/traits.rs | 43 ++++++++++---- .../analyzers/architecture/compiled.rs | 3 +- 11 files changed, 232 insertions(+), 64 deletions(-) diff --git a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs index 5690fcb..96bae82 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs @@ -241,15 +241,6 @@ impl<'a> CanonicalCallCollector<'a> { self.current_non_path_scope_mut().insert(name, ty); } - fn resolve_binding(&self, ident: &str) -> Option<&Vec> { - for scope in self.bindings.iter().rev() { - if let Some(v) = scope.get(ident) { - return Some(v); - } - } - None - } - /// Turn a path-segment list into the canonical String used for all /// call-target comparisons in the call-parity check. fn canonicalise_path(&self, segments: &[String]) -> String { @@ -330,7 +321,11 @@ impl<'a> CanonicalCallCollector<'a> { } /// Fast-path: receiver is a bare ident with a concrete binding in - /// the legacy scope. Operation. + /// the legacy path scope. Walks both scope stacks from innermost to + /// outermost so a non-path shadow (`let r: Result<_,_> = …` + /// shadowing an outer `let r: Session = …`) aborts the fast-path + /// and hands off to inference, instead of producing a stale concrete + /// edge. Operation. fn try_fast_path_receiver(&self, receiver: &syn::Expr, method_name: &str) -> Option { let syn::Expr::Path(p) = receiver else { return None; @@ -339,10 +334,22 @@ impl<'a> CanonicalCallCollector<'a> { return None; } let ident = p.path.segments[0].ident.to_string(); - let binding = self.resolve_binding(&ident)?; - let mut full = binding.clone(); - full.push(method_name.to_string()); - Some(full.join("::")) + for (path_scope, non_path_scope) in self + .bindings + .iter() + .rev() + .zip(self.non_path_bindings.iter().rev()) + { + if non_path_scope.contains_key(&ident) { + return None; + } + if let Some(binding) = path_scope.get(&ident) { + let mut full = binding.clone(); + full.push(method_name.to_string()); + return Some(full.join("::")); + } + } + None } /// Inference fallback: run shallow type inference over the receiver @@ -384,10 +391,11 @@ impl<'a> CanonicalCallCollector<'a> { /// `let x: T = …` — route the annotation through the full resolver /// when a workspace index is available so alias expansion + wrapper /// peeling + trait-bound extraction all apply. Returns `true` when - /// a binding was installed (or the annotation resolved to `Opaque` - /// and should be dropped entirely). Returns `false` when there's no - /// annotation or no workspace index, handing off to the legacy - /// fast-path. Operation: closure-hidden resolution. + /// a binding was actually installed. Returns `false` when there's + /// no annotation, no workspace index, or the annotation resolves to + /// `Opaque` (e.g. `let s: _ = …` where `_` is the inference + /// placeholder) — the caller then falls through to initializer- + /// based inference. Operation: closure-hidden resolution. fn try_install_annotated_binding(&mut self, local: &syn::Local) -> bool { let Some(wi) = self.workspace_index else { return false; @@ -409,7 +417,7 @@ impl<'a> CanonicalCallCollector<'a> { let name = pi.ident.to_string(); match resolve_type(pt.ty.as_ref(), &rctx) { CanonicalType::Path(segs) => self.install_path_binding(name, segs), - CanonicalType::Opaque => {} + CanonicalType::Opaque => return false, other => self.install_non_path_binding(name, other), } true diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs index e8f1afa..feaf589 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs @@ -94,11 +94,13 @@ fn dispatch_type(ty: &syn::Type, ctx: &ResolveContext<'_>, next: u8) -> Canonica } } -/// Extract the first non-marker trait bound from a `dyn T1 + T2` or -/// `impl T1 + T2` bound list and canonicalise it to `TraitBound(path)`. -/// Marker traits (`Send`, `Sync`, `Unpin`, `Copy`, `Clone`, etc.) and -/// lifetime bounds are skipped. Yields `Opaque` if no resolvable trait -/// bound exists. Operation. +/// Extract the first resolvable non-marker trait bound from a +/// `dyn T1 + T2` or `impl T1 + T2` list and canonicalise it to +/// `TraitBound(path)`. Marker traits (`Send`, `Sync`, `Unpin`, `Copy`, +/// `Clone`, etc.) and lifetime bounds are skipped, as are bounds that +/// can't be canonicalised (external crates not in the workspace) — so +/// `dyn ExternalTrait + LocalTrait` still dispatches via `LocalTrait`. +/// Yields `Opaque` if no resolvable trait bound exists. Operation. fn resolve_bound_list( bounds: &syn::punctuated::Punctuated, ctx: &ResolveContext<'_>, @@ -116,15 +118,14 @@ fn resolve_bound_list( .iter() .map(|s| s.ident.to_string()) .collect(); - match canonicalise_type_segments( + if let Some(resolved) = canonicalise_type_segments( &segs, ctx.alias_map, ctx.local_symbols, ctx.crate_root_modules, ctx.importing_file, ) { - Some(resolved) => return CanonicalType::TraitBound(resolved), - None => return CanonicalType::Opaque, + return CanonicalType::TraitBound(resolved); } } CanonicalType::Opaque @@ -170,7 +171,14 @@ fn resolve_path(path: &syn::Path, ctx: &ResolveContext<'_>, depth: u8) -> Canoni "Future" => wrap_future(), "Vec" => wrap(0, CanonicalType::Slice), "HashMap" | "BTreeMap" => wrap(1, CanonicalType::Map), - "Arc" | "Box" | "Rc" | "Cow" | "RwLock" | "Mutex" | "RefCell" | "Cell" => peel(), + // Only peel smart pointers whose `Deref` makes inner methods + // reachable directly on the wrapper. `RwLock` / `Mutex` / + // `RefCell` / `Cell` intentionally do NOT deref to their inner + // value — `db.read()` is `RwLock::read`, not `Inner::read` — + // so peeling them would synthesize bogus edges to the inner + // type. Users can opt back in via `transparent_wrappers` for + // domain-specific deref-like wrappers. + "Arc" | "Box" | "Rc" | "Cow" => peel(), _ if is_user_transparent(&name, ctx) => peel(), _ => fallback(), } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs index 9e6bd1c..fe72225 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs @@ -102,12 +102,14 @@ fn test_arc_is_stripped() { } #[test] -fn test_nested_wrappers_strip_to_inner() { +fn test_nested_smart_pointers_strip_to_inner() { let alias_map = HashMap::new(); let mut local = HashSet::new(); local.insert("Session".to_string()); let roots = HashSet::new(); - let ty = parse_type("Arc>"); + // Only smart-pointer wrappers (`Arc` / `Box` / `Rc` / `Cow`) are + // Deref-transparent, so nesting them still reaches the inner type. + let ty = parse_type("Arc>"); let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/app/session.rs")); assert_eq!( resolved, @@ -115,6 +117,19 @@ fn test_nested_wrappers_strip_to_inner() { ); } +#[test] +fn test_rwlock_is_not_peeled() { + let alias_map = HashMap::new(); + let mut local = HashSet::new(); + local.insert("Session".to_string()); + let roots = HashSet::new(); + // `RwLock::read()` returns a guard, not the inner value — peeling + // it would synthesize bogus `Session::read` edges. Stays `Opaque`. + let ty = parse_type("Arc>"); + let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/app/session.rs")); + assert_eq!(resolved, CanonicalType::Opaque); +} + #[test] fn test_vec_becomes_slice() { let alias_map = HashMap::new(); diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs index fc46219..b57e42b 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs @@ -319,6 +319,64 @@ fn test_generic_return_type_is_opaque_and_not_indexed() { assert!(index.fn_returns.is_empty()); } +#[test] +fn test_fn_inside_inline_mod_keys_include_mod_name() { + let fix = fixture(&[( + "src/app/mod.rs", + r#" + pub struct Session; + pub mod inner { + use super::Session; + pub fn make_session() -> Session { Session } + } + "#, + )]); + let index = build_workspace_type_index( + &borrowed(&fix), + &fix.aliases, + &HashSet::new(), + &crate_roots(&["src/app/mod.rs"]), + &HashSet::new(), + ); + // With inline-mod tracking the key is `crate::app::inner::make_session`, + // matching how `inner::make_session()` canonicalises at a call site. + assert!( + index.fn_return("crate::app::inner::make_session").is_some(), + "fn_returns = {:?}", + index.fn_returns.keys().collect::>() + ); + // And the pre-fix key is absent — no duplicate shadow-registration. + assert!(index.fn_return("crate::app::make_session").is_none()); +} + +#[test] +fn test_struct_field_inside_inline_mod_keys_include_mod_name() { + let fix = fixture(&[( + "src/app/mod.rs", + r#" + pub struct Session; + pub mod inner { + use super::Session; + pub struct Ctx { pub session: Session } + } + "#, + )]); + let index = build_workspace_type_index( + &borrowed(&fix), + &fix.aliases, + &HashSet::new(), + &crate_roots(&["src/app/mod.rs"]), + &HashSet::new(), + ); + assert!( + index + .struct_field("crate::app::inner::Ctx", "session") + .is_some(), + "struct_fields = {:?}", + index.struct_fields.keys().collect::>() + ); +} + #[test] fn test_fn_with_unit_return_is_not_indexed() { let fix = fixture(&[("src/app/foo.rs", "pub fn bump() {}")]); diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/aliases.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/aliases.rs index 5938447..3d45a1a 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/aliases.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/aliases.rs @@ -19,13 +19,18 @@ pub(super) fn collect_from_file( ctx: &BuildContext<'_>, ast: &syn::File, ) { - let mut collector = AliasCollector { index, ctx }; + let mut collector = AliasCollector { + index, + ctx, + mod_stack: Vec::new(), + }; collector.visit_file(ast); } struct AliasCollector<'i, 'c> { index: &'i mut WorkspaceTypeIndex, ctx: &'c BuildContext<'c>, + mod_stack: Vec, } impl<'ast, 'i, 'c> Visit<'ast> for AliasCollector<'i, 'c> { @@ -33,7 +38,7 @@ impl<'ast, 'i, 'c> Visit<'ast> for AliasCollector<'i, 'c> { if has_cfg_test(&node.attrs) { return; } - let canonical = canonical_type_key(&[node.ident.to_string()], self.ctx); + let canonical = canonical_type_key(&[node.ident.to_string()], self.ctx, &self.mod_stack); let params: Vec = node .generics .params @@ -52,7 +57,9 @@ impl<'ast, 'i, 'c> Visit<'ast> for AliasCollector<'i, 'c> { if has_cfg_test(&node.attrs) { return; } + self.mod_stack.push(node.ident.to_string()); syn::visit::visit_item_mod(self, node); + self.mod_stack.pop(); } fn visit_item_impl(&mut self, _: &'ast syn::ItemImpl) { diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs index 0998e97..67f5ed9 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs @@ -21,13 +21,18 @@ pub(super) fn collect_from_file( ctx: &BuildContext<'_>, ast: &syn::File, ) { - let mut collector = FieldCollector { index, ctx }; + let mut collector = FieldCollector { + index, + ctx, + mod_stack: Vec::new(), + }; collector.visit_file(ast); } struct FieldCollector<'i, 'c> { index: &'i mut WorkspaceTypeIndex, ctx: &'c BuildContext<'c>, + mod_stack: Vec, } impl<'ast, 'i, 'c> Visit<'ast> for FieldCollector<'i, 'c> { @@ -35,14 +40,16 @@ impl<'ast, 'i, 'c> Visit<'ast> for FieldCollector<'i, 'c> { if has_cfg_test(&node.attrs) { return; } - record_struct(self.index, self.ctx, node); + record_struct(self.index, self.ctx, &self.mod_stack, node); } fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) { if has_cfg_test(&node.attrs) { return; } + self.mod_stack.push(node.ident.to_string()); syn::visit::visit_item_mod(self, node); + self.mod_stack.pop(); } fn visit_item_impl(&mut self, _: &'ast syn::ItemImpl) { @@ -53,8 +60,13 @@ impl<'ast, 'i, 'c> Visit<'ast> for FieldCollector<'i, 'c> { /// Record every named field of `item`. Integration: canonicalisation + /// per-field delegation. -fn record_struct(index: &mut WorkspaceTypeIndex, ctx: &BuildContext<'_>, item: &syn::ItemStruct) { - let canon = |name: &str| canonical_struct_name(name, ctx); +fn record_struct( + index: &mut WorkspaceTypeIndex, + ctx: &BuildContext<'_>, + mod_stack: &[String], + item: &syn::ItemStruct, +) { + let canon = |name: &str| canonical_struct_name(name, ctx, mod_stack); let canonical = canon(&item.ident.to_string()); let syn::Fields::Named(named) = &item.fields else { return; @@ -85,11 +97,16 @@ fn record_field( .insert((canonical.to_string(), ident.to_string()), field_type); } -/// Build `crate::::` from a file path + ident. -/// Operation: pure string construction. -fn canonical_struct_name(struct_ident: &str, ctx: &BuildContext<'_>) -> String { +/// Build `crate::::::` from a +/// file path, mod stack, and ident. Operation: pure string construction. +fn canonical_struct_name( + struct_ident: &str, + ctx: &BuildContext<'_>, + mod_stack: &[String], +) -> String { let mut segs: Vec = vec!["crate".to_string()]; segs.extend(file_to_module_segments(ctx.path)); + segs.extend(mod_stack.iter().cloned()); segs.push(struct_ident.to_string()); segs.join("::") } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs index cc82642..ef43caf 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs @@ -19,13 +19,18 @@ pub(super) fn collect_from_file( ctx: &BuildContext<'_>, ast: &syn::File, ) { - let mut collector = FnCollector { index, ctx }; + let mut collector = FnCollector { + index, + ctx, + mod_stack: Vec::new(), + }; collector.visit_file(ast); } struct FnCollector<'i, 'c> { index: &'i mut WorkspaceTypeIndex, ctx: &'c BuildContext<'c>, + mod_stack: Vec, } impl<'ast, 'i, 'c> Visit<'ast> for FnCollector<'i, 'c> { @@ -33,14 +38,16 @@ impl<'ast, 'i, 'c> Visit<'ast> for FnCollector<'i, 'c> { if has_cfg_test(&node.attrs) { return; } - record_fn(self.index, self.ctx, node); + record_fn(self.index, self.ctx, &self.mod_stack, node); } fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) { if has_cfg_test(&node.attrs) { return; } + self.mod_stack.push(node.ident.to_string()); syn::visit::visit_item_mod(self, node); + self.mod_stack.pop(); } fn visit_item_impl(&mut self, _: &'ast syn::ItemImpl) { @@ -51,7 +58,12 @@ impl<'ast, 'i, 'c> Visit<'ast> for FnCollector<'i, 'c> { /// Record one free fn's return type. `async fn foo() -> T` is treated /// as returning `Future` to match rustc's desugaring so /// downstream `.await` unwraps correctly. Operation. -fn record_fn(index: &mut WorkspaceTypeIndex, ctx: &BuildContext<'_>, node: &syn::ItemFn) { +fn record_fn( + index: &mut WorkspaceTypeIndex, + ctx: &BuildContext<'_>, + mod_stack: &[String], + node: &syn::ItemFn, +) { let resolve = |ty: &syn::Type| resolve_type(ty, &resolve_ctx_from_build(ctx)); let syn::ReturnType::Type(_, ret_ty) = &node.sig.output else { return; @@ -65,14 +77,16 @@ fn record_fn(index: &mut WorkspaceTypeIndex, ctx: &BuildContext<'_>, node: &syn: } else { inner }; - let canonical = canonical_fn_name(&node.sig.ident.to_string(), ctx); + let canonical = canonical_fn_name(&node.sig.ident.to_string(), ctx, mod_stack); index.fn_returns.insert(canonical, ret); } -/// Build `crate::::`. Operation: string construction. -fn canonical_fn_name(fn_ident: &str, ctx: &BuildContext<'_>) -> String { +/// Build `crate::::::`. Operation: +/// string construction. +fn canonical_fn_name(fn_ident: &str, ctx: &BuildContext<'_>, mod_stack: &[String]) -> String { let mut segs: Vec = vec!["crate".to_string()]; segs.extend(file_to_module_segments(ctx.path)); + segs.extend(mod_stack.iter().cloned()); segs.push(fn_ident.to_string()); segs.join("::") } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs index f408764..ce85167 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs @@ -30,6 +30,7 @@ pub(super) fn collect_from_file( index, ctx, impl_stack: Vec::new(), + mod_stack: Vec::new(), }; collector.visit_file(ast); } @@ -41,6 +42,10 @@ struct MethodCollector<'i, 'c> { /// unresolved (trait object, tuple receiver) — methods under those /// impls aren't indexed because the receiver type can't be named. impl_stack: Vec>>, + /// Stack of enclosing inline `mod inner { ... }` block names so + /// methods declared inside them key as + /// `crate::::inner::Type::method`. + mod_stack: Vec, } impl<'ast, 'i, 'c> Visit<'ast> for MethodCollector<'i, 'c> { @@ -64,14 +69,22 @@ impl<'ast, 'i, 'c> Visit<'ast> for MethodCollector<'i, 'c> { if has_cfg_test(&node.attrs) { return; } - record_method(self.index, self.ctx, &self.impl_stack, node); + record_method( + self.index, + self.ctx, + &self.impl_stack, + &self.mod_stack, + node, + ); } fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) { if has_cfg_test(&node.attrs) { return; } + self.mod_stack.push(node.ident.to_string()); syn::visit::visit_item_mod(self, node); + self.mod_stack.pop(); } } @@ -83,10 +96,11 @@ fn record_method( index: &mut WorkspaceTypeIndex, ctx: &BuildContext<'_>, impl_stack: &[Option>], + mod_stack: &[String], node: &syn::ImplItemFn, ) { let resolve = |ty: &syn::Type| resolve_type(ty, &resolve_ctx_from_build(ctx)); - let canon = |segs: &[String]| canonical_type_key(segs, ctx); + let canon = |segs: &[String]| canonical_type_key(segs, ctx, mod_stack); let Some(Some(impl_segs)) = impl_stack.last() else { return; }; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs index 0a4392b..ad4f219 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs @@ -39,16 +39,23 @@ pub(super) struct BuildContext<'a> { } /// Build a canonical type-path key by prefixing the impl/trait segments -/// with `crate::::` unless they're already crate-rooted. -/// Shared by `methods` and `traits` collectors so they produce keys in -/// the same shape the call-graph (`canonical_fn_name`) uses. +/// with `crate::::::` unless they're already +/// crate-rooted. `mod_stack` carries the names of enclosing inline +/// `mod inner { ... }` blocks so items declared inside them key as +/// `crate::::inner::X`, matching the path a call-site like +/// `inner::X` canonicalises to. /// Operation. -pub(super) fn canonical_type_key(segs: &[String], ctx: &BuildContext<'_>) -> String { +pub(super) fn canonical_type_key( + segs: &[String], + ctx: &BuildContext<'_>, + mod_stack: &[String], +) -> String { if segs.first().map(String::as_str) == Some("crate") { return segs.join("::"); } let mut out: Vec = vec!["crate".to_string()]; out.extend(file_to_module_segments(ctx.path)); + out.extend(mod_stack.iter().cloned()); out.extend(segs.iter().cloned()); out.join("::") } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs index 7af27d5..a8aadbd 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs @@ -25,13 +25,18 @@ pub(super) fn collect_from_file( ctx: &BuildContext<'_>, ast: &syn::File, ) { - let mut collector = TraitCollector { index, ctx }; + let mut collector = TraitCollector { + index, + ctx, + mod_stack: Vec::new(), + }; collector.visit_file(ast); } struct TraitCollector<'i, 'c> { index: &'i mut WorkspaceTypeIndex, ctx: &'c BuildContext<'c>, + mod_stack: Vec, } impl<'ast, 'i, 'c> Visit<'ast> for TraitCollector<'i, 'c> { @@ -39,21 +44,23 @@ impl<'ast, 'i, 'c> Visit<'ast> for TraitCollector<'i, 'c> { if has_cfg_test(&node.attrs) { return; } - record_trait_methods(self.index, self.ctx, node); + record_trait_methods(self.index, self.ctx, &self.mod_stack, node); } fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) { if has_cfg_test(&node.attrs) { return; } - record_trait_impl(self.index, self.ctx, node); + record_trait_impl(self.index, self.ctx, &self.mod_stack, node); } fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) { if has_cfg_test(&node.attrs) { return; } + self.mod_stack.push(node.ident.to_string()); syn::visit::visit_item_mod(self, node); + self.mod_stack.pop(); } } @@ -62,9 +69,10 @@ impl<'ast, 'i, 'c> Visit<'ast> for TraitCollector<'i, 'c> { fn record_trait_methods( index: &mut WorkspaceTypeIndex, ctx: &BuildContext<'_>, + mod_stack: &[String], node: &syn::ItemTrait, ) { - let canonical = canonical_name(&node.ident.to_string(), ctx); + let canonical = canonical_name(&node.ident.to_string(), ctx, mod_stack); let methods: HashSet = node .items .iter() @@ -81,7 +89,12 @@ fn record_trait_methods( /// For `impl Trait for X { … }` record `trait_impls[canonical_Trait]` /// gaining `canonical_X`. Inherent impls (without `trait_`) are handled /// by `methods.rs`, not here. Operation: delegated canonicalisation. -fn record_trait_impl(index: &mut WorkspaceTypeIndex, ctx: &BuildContext<'_>, node: &syn::ItemImpl) { +fn record_trait_impl( + index: &mut WorkspaceTypeIndex, + ctx: &BuildContext<'_>, + mod_stack: &[String], + node: &syn::ItemImpl, +) { let Some((_, trait_path, _)) = &node.trait_ else { return; }; @@ -99,7 +112,7 @@ fn record_trait_impl(index: &mut WorkspaceTypeIndex, ctx: &BuildContext<'_>, nod let Some(impl_segs) = impl_type_canonical else { return; }; - let impl_canonical = canonical_impl_type(&impl_segs, ctx); + let impl_canonical = canonical_impl_type(&impl_segs, ctx, mod_stack); index .trait_impls .entry(trait_canonical) @@ -122,17 +135,23 @@ fn resolve_trait_path(path: &syn::Path, ctx: &BuildContext<'_>) -> Option::`. Operation. -fn canonical_name(ident: &str, ctx: &BuildContext<'_>) -> String { +/// `crate::::::`. +/// Operation. +fn canonical_name(ident: &str, ctx: &BuildContext<'_>, mod_stack: &[String]) -> String { let mut segs: Vec = vec!["crate".to_string()]; segs.extend(file_to_module_segments(ctx.path)); + segs.extend(mod_stack.iter().cloned()); segs.push(ident.to_string()); segs.join("::") } /// Same shape as methods.rs — prefix impl-type segs with `crate:: -/// ::` unless the impl path is already crate-rooted. -/// Operation: delegate to the shared `canonical_type_key`. -fn canonical_impl_type(impl_segs: &[String], ctx: &BuildContext<'_>) -> String { - canonical_type_key(impl_segs, ctx) +/// ::::` unless the impl path is already +/// crate-rooted. Operation: delegate to the shared `canonical_type_key`. +fn canonical_impl_type( + impl_segs: &[String], + ctx: &BuildContext<'_>, + mod_stack: &[String], +) -> String { + canonical_type_key(impl_segs, ctx, mod_stack) } diff --git a/src/adapters/analyzers/architecture/compiled.rs b/src/adapters/analyzers/architecture/compiled.rs index 9321d0f..91fbace 100644 --- a/src/adapters/analyzers/architecture/compiled.rs +++ b/src/adapters/analyzers/architecture/compiled.rs @@ -125,7 +125,8 @@ fn compile_call_parity( let transparent_wrappers: HashSet = cp .transparent_wrappers .iter() - .map(|w| last_path_segment(w).to_string()) + .map(|w| last_path_segment(w.trim()).to_string()) + .filter(|s| !s.is_empty()) .collect(); let transparent_macros = build_transparent_macros(&cp.transparent_macros); Ok(Some(CompiledCallParity { From 08b9ef73fd52deb9516018b6e0780b3ac403feca Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:49:45 +0200 Subject: [PATCH 07/30] fix: copilot comments --- CHANGELOG.md | 13 +- README.md | 18 ++- .../architecture/call_parity_rule/calls.rs | 125 +++++++++++++----- .../architecture/call_parity_rule/mod.rs | 1 + .../architecture/call_parity_rule/pub_fns.rs | 16 ++- .../call_parity_rule/signature_params.rs | 43 ++++++ .../call_parity_rule/type_infer/resolve.rs | 31 +++-- .../call_parity_rule/workspace_graph.rs | 71 ++++------ .../analyzers/architecture/compiled.rs | 6 +- src/adapters/shared/cfg_test_files.rs | 33 +++-- 10 files changed, 247 insertions(+), 110 deletions(-) create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/signature_params.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 20f0731..e8398e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -134,10 +134,15 @@ additive and the legacy fast-path stays intact as a safety net. over-approximated. ### Added — Framework & Config Layer (Stage 3) -- **Type-alias expansion.** `type Db = Arc>;` recorded - in the workspace index; `fn h(db: Db) { db.read() }` expands `Db` - → `Arc>` → `Store` (Arc/RwLock peeled by the stdlib - wrapper rules) and resolves `read` against Store's method index. +- **Type-alias expansion.** `type Repo = Arc>;` recorded in + the workspace index; `fn h(r: Repo) { r.insert(..) }` expands `Repo` + → `Arc>` → `Store` (Arc/Box are Deref-transparent) and + resolves `insert` against Store's method index. Aliases wrapping + non-Deref types like `RwLock` / `Mutex` / `RefCell` / `Cell` still + expand the alias itself, but those wrappers aren't peeled by default + (their `read` / `lock` / `borrow` methods don't live on the inner + type) — list them in `transparent_wrappers` if your codebase genuinely + treats them as Deref-transparent. - **User-configurable transparent wrappers** via `[architecture.call_parity]::transparent_wrappers`: ```toml diff --git a/README.md b/README.md index ed4c4b2..ff33450 100644 --- a/README.md +++ b/README.md @@ -870,8 +870,13 @@ Session/Service/Context-pattern idioms out of the box: `as_ref` etc. Closure-dependent combinators (`map`, `and_then`) intentionally stay unresolved rather than fabricate an edge. - **Wrapper stripping:** `Arc`, `Box`, `Rc`, `Cow<'_, T>`, - `RwLock`, `Mutex`, `RefCell`, `Cell`, `&T`, `&mut T` all - transparent. `Vec` / `HashMap<_, V>` preserve the element/value type. + `&T`, `&mut T` — the Deref-transparent smart pointers — strip to the + inner type. `RwLock` / `Mutex` / `RefCell` / `Cell` do + **not** strip by default (their `read` / `lock` / `borrow` / `get` + methods don't exist on the inner type — stripping would synthesize + bogus edges). Opt in per-wrapper via `transparent_wrappers` if your + codebase uses a genuinely Deref-transparent domain wrapper. `Vec` + / `HashMap<_, V>` preserve the element/value type. - **`Self::xxx`** in impl-method contexts substitutes to the enclosing type. - **`if let Some(s) = opt`** binds `s: T` when `opt: Option`, same @@ -884,9 +889,12 @@ Session/Service/Context-pattern idioms out of the box: - **Turbofish return types**: `get::()` for generic fns — the turbofish arg is used as the return type when the workspace index has no concrete return for `get`. Only single-ident paths trigger. -- **Type aliases**: `type Db = Arc>;` is recorded and - expanded during receiver resolution, so `fn h(db: Db) { db.read() }` - reaches `Store::read`. +- **Type aliases**: `type Repo = Arc>;` is recorded and + expanded during receiver resolution, so `fn h(r: Repo) { r.insert(..) }` + reaches `Store::insert` through the peeled smart-pointer chain. + Aliases wrapping non-Deref types (`type Db = Arc>`) still + expand, but the `RwLock` stops peeling — methods on the inner `Store` + aren't reached unless `RwLock` is listed in `transparent_wrappers`. For framework codebases you can extend the wrapper and macro lists: diff --git a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs index 96bae82..62c86a9 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs @@ -391,11 +391,13 @@ impl<'a> CanonicalCallCollector<'a> { /// `let x: T = …` — route the annotation through the full resolver /// when a workspace index is available so alias expansion + wrapper /// peeling + trait-bound extraction all apply. Returns `true` when - /// a binding was actually installed. Returns `false` when there's - /// no annotation, no workspace index, or the annotation resolves to - /// `Opaque` (e.g. `let s: _ = …` where `_` is the inference - /// placeholder) — the caller then falls through to initializer- - /// based inference. Operation: closure-hidden resolution. + /// a binding was installed. Returns `false` only when there's no + /// workspace index, the pattern isn't a typed ident, or the + /// annotation is the explicit `_` inference placeholder — the caller + /// then falls through to initializer-based inference (which is what + /// rustc does). An annotation that names an unresolvable type still + /// installs an `Opaque` tombstone so outer path bindings with the + /// same name don't leak back in. Operation. fn try_install_annotated_binding(&mut self, local: &syn::Local) -> bool { let Some(wi) = self.workspace_index else { return false; @@ -406,6 +408,9 @@ impl<'a> CanonicalCallCollector<'a> { let syn::Pat::Ident(pi) = pt.pat.as_ref() else { return false; }; + if matches!(pt.ty.as_ref(), syn::Type::Infer(_)) { + return false; + } let rctx = ResolveContext { alias_map: self.alias_map, local_symbols: self.local_symbols, @@ -417,7 +422,6 @@ impl<'a> CanonicalCallCollector<'a> { let name = pi.ident.to_string(); match resolve_type(pt.ty.as_ref(), &rctx) { CanonicalType::Path(segs) => self.install_path_binding(name, segs), - CanonicalType::Opaque => return false, other => self.install_non_path_binding(name, other), } true @@ -425,24 +429,24 @@ impl<'a> CanonicalCallCollector<'a> { /// Install a `let x = expr` binding via shallow inference on the /// initializer. `Path` results go into the legacy scope, non-Path - /// results (wrappers, trait bounds) into `non_path_bindings` so - /// downstream `?` / `.await` / trait-dispatch on `x` resolve - /// correctly. Only simple `Pat::Ident` patterns are handled here; - /// destructuring is handled separately via `patterns::extract_bindings`. + /// results (wrappers, trait bounds) into `non_path_bindings`, and + /// unresolvable initializers (`let s = external()` where we can't + /// name the return type) into `non_path_bindings` as an `Opaque` + /// tombstone so an outer `s: Session` doesn't leak back in when + /// `s.method()` is resolved. Only simple `Pat::Ident` patterns are + /// handled here; destructuring flows through `install_destructure_bindings`. /// Operation. fn install_inferred_let_binding(&mut self, local: &syn::Local) { - let Some(init) = local.init.as_ref() else { - return; - }; let Some(name) = extract_pat_ident_name(&local.pat) else { return; }; - let Some(inferred) = self.infer_receiver_type(&init.expr) else { - return; - }; + let inferred = local + .init + .as_ref() + .and_then(|init| self.infer_receiver_type(&init.expr)) + .unwrap_or(CanonicalType::Opaque); match inferred { CanonicalType::Path(segs) => self.install_path_binding(name, segs), - CanonicalType::Opaque => {} other => self.install_non_path_binding(name, other), } } @@ -456,24 +460,29 @@ impl<'a> CanonicalCallCollector<'a> { /// Extract pattern bindings from `pat` against a matched-type from /// `matched_expr`, installing them into the current scope. Path /// bindings go into the legacy scope, wrapper/trait-bound bindings - /// into `non_path_bindings`. Used by `let`-destructuring, `if let`, - /// `while let`, `match` arms. Integration. + /// into `non_path_bindings`. If the matched expression is itself + /// unresolvable, every syntactic binding in the pattern gets an + /// `Opaque` tombstone so outer same-name bindings can't leak back + /// in at a later `.method()` call. Used by `let`-destructuring, + /// `if let`, `while let`, `match` arms. Integration. fn install_destructure_bindings(&mut self, pat: &syn::Pat, matched_expr: &syn::Expr) { - let Some(matched) = self.infer_receiver_type(matched_expr) else { - return; - }; + let matched = self + .infer_receiver_type(matched_expr) + .unwrap_or(CanonicalType::Opaque); let pairs = self.extract_pattern_pairs(pat, &matched, PatKind::Value); - self.install_binding_pairs(pairs); + self.install_binding_pairs_with_tombstones(pat, pairs); } /// Extract for-loop element-type bindings from `pat` against - /// `iter_expr` (the thing being iterated over). Integration. + /// `iter_expr` (the thing being iterated over). Unresolvable + /// iterators tombstone their pattern idents, same as + /// `install_destructure_bindings`. Integration. fn install_for_bindings(&mut self, pat: &syn::Pat, iter_expr: &syn::Expr) { - let Some(iter_type) = self.infer_receiver_type(iter_expr) else { - return; - }; + let iter_type = self + .infer_receiver_type(iter_expr) + .unwrap_or(CanonicalType::Opaque); let pairs = self.extract_pattern_pairs(pat, &iter_type, PatKind::Iterator); - self.install_binding_pairs(pairs); + self.install_binding_pairs_with_tombstones(pat, pairs); } /// Wrapper around `patterns::extract_bindings` / `extract_for_bindings` @@ -506,16 +515,32 @@ impl<'a> CanonicalCallCollector<'a> { } } - /// Dispatch each `(name, type)` pair into the right scope map. + /// Dispatch each `(name, type)` pair into the right scope map, then + /// walk `pat` and install `Opaque` tombstones for every syntactic + /// ident the resolver didn't reach. This keeps an unresolvable + /// `let (_, s) = external()` or `for s in opaque_iter` from letting + /// an outer `s: Session` leak back in at `s.method()` time. /// Operation. - fn install_binding_pairs(&mut self, pairs: Vec<(String, CanonicalType)>) { + fn install_binding_pairs_with_tombstones( + &mut self, + pat: &syn::Pat, + pairs: Vec<(String, CanonicalType)>, + ) { + let mut resolved: HashSet = HashSet::new(); for (name, ty) in pairs { + resolved.insert(name.clone()); match ty { CanonicalType::Path(segs) => self.install_path_binding(name, segs), - CanonicalType::Opaque => {} other => self.install_non_path_binding(name, other), } } + let mut idents = Vec::new(); + collect_pattern_idents(pat, &mut idents); + for name in idents { + if !resolved.contains(&name) { + self.install_non_path_binding(name, CanonicalType::Opaque); + } + } } } @@ -657,6 +682,44 @@ fn extract_pat_ident_name(pat: &syn::Pat) -> Option { } } +/// Collect every binding ident introduced by `pat` (ignoring subpatterns +/// that don't bind names — `_`, literals, ref subslices without idents). +/// Used to install `Opaque` tombstones for syntactic bindings whose +/// matched type couldn't be inferred. Integration: dispatch over pat +/// variants, each arm delegates to a recursive helper. +// qual:recursive +fn collect_pattern_idents(pat: &syn::Pat, out: &mut Vec) { + match pat { + syn::Pat::Ident(pi) => push_pat_ident(pi, out), + syn::Pat::Type(pt) => collect_pattern_idents(&pt.pat, out), + syn::Pat::Reference(r) => collect_pattern_idents(&r.pat, out), + syn::Pat::Paren(p) => collect_pattern_idents(&p.pat, out), + syn::Pat::Tuple(t) => walk_each(t.elems.iter(), out), + syn::Pat::TupleStruct(ts) => walk_each(ts.elems.iter(), out), + syn::Pat::Struct(s) => walk_each(s.fields.iter().map(|f| f.pat.as_ref()), out), + syn::Pat::Slice(s) => walk_each(s.elems.iter(), out), + syn::Pat::Or(o) => walk_each(o.cases.iter().take(1), out), + _ => {} + } +} + +/// Recurse into every pattern in `iter`. Operation: closure-free fn +/// keeps lifetime inference simple when called from the main walker. +fn walk_each<'p, I: Iterator>(iter: I, out: &mut Vec) { + for p in iter { + collect_pattern_idents(p, out); + } +} + +/// Push a `Pat::Ident`'s name and recurse into its optional subpattern +/// (`x @ Some(inner)`). Operation: closure-hidden recursion. +fn push_pat_ident(pi: &syn::PatIdent, out: &mut Vec) { + out.push(pi.ident.to_string()); + if let Some((_, sub)) = &pi.subpat { + collect_pattern_idents(sub, out); + } +} + /// Prefix an unresolved single-ident or segment path with the layer-unknown /// `:` marker. Centralised so the BP-010 format-repetition detector /// sees exactly one format string, and so the marker can evolve together. diff --git a/src/adapters/analyzers/architecture/call_parity_rule/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/mod.rs index 0200bd3..e21aac9 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/mod.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/mod.rs @@ -18,6 +18,7 @@ pub mod calls; pub mod check_a; pub mod check_b; pub mod pub_fns; +pub(crate) mod signature_params; pub mod type_infer; pub mod workspace_graph; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs index b6ab17d..9a95028 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs @@ -17,9 +17,9 @@ //! //! See Task 2 in the v1.1.0 plan for the full test list. +use super::signature_params::extract_signature_params; use super::workspace_graph::{ - collect_crate_root_modules, collect_local_symbols, extract_signature_params, - impl_self_ty_segments, resolve_impl_self_type, + collect_crate_root_modules, collect_local_symbols, impl_self_ty_segments, resolve_impl_self_type, }; use crate::adapters::analyzers::architecture::layer_rule::LayerDefinitions; use crate::adapters::shared::cfg_test::{has_cfg_test, has_test_attr}; @@ -40,6 +40,10 @@ pub(crate) struct PubFnInfo<'ast> { /// Type-name path of the enclosing `impl`, if any. Forms the /// `Self::method` resolution context for the call collector. pub self_type: Option>, + /// Names of enclosing inline `mod inner { ... }` blocks, outer-most + /// first. Feeds the canonical-name builder so nested-mod items key + /// under `crate::::inner::…` to match the graph + type index. + pub mod_stack: Vec, } // qual:api @@ -81,6 +85,7 @@ pub(crate) fn collect_pub_fns_by_layer<'ast>( local_symbols: &local_symbols, crate_root_modules: &crate_root_modules, impl_stack: Vec::new(), + mod_stack: Vec::new(), }; collector.visit_file(ast); out.entry(layer).or_default().extend(collector.found); @@ -153,6 +158,10 @@ struct PubFnCollector<'ast, 'vis> { /// Stack of enclosing `impl` blocks: `(self-type segments, is-visible)`. /// Merged so the two halves can't drift out of sync. impl_stack: Vec<(Vec, bool)>, + /// Names of enclosing inline `mod inner { ... }` blocks. Feeds + /// `PubFnInfo.mod_stack` so Check B canonical names align with the + /// graph keys / type index (both of which now prefix inline mods). + mod_stack: Vec, } impl<'ast, 'vis> PubFnCollector<'ast, 'vis> { @@ -178,6 +187,7 @@ impl<'ast, 'vis> PubFnCollector<'ast, 'vis> { body, signature_params: extract_signature_params(sig), self_type: self.current_self_type(), + mod_stack: self.mod_stack.clone(), }); } } @@ -254,6 +264,8 @@ impl<'ast, 'vis> Visit<'ast> for PubFnCollector<'ast, 'vis> { if has_cfg_test(&node.attrs) { return; } + self.mod_stack.push(node.ident.to_string()); syn::visit::visit_item_mod(self, node); + self.mod_stack.pop(); } } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/signature_params.rs b/src/adapters/analyzers/architecture/call_parity_rule/signature_params.rs new file mode 100644 index 0000000..bdd2d02 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/signature_params.rs @@ -0,0 +1,43 @@ +//! Fn-signature parameter extraction. +//! +//! Shared by `pub_fns` (Check B pub-fn collection) and `workspace_graph` +//! (graph-build) — both need the same `(name, &Type)` pairs that the +//! `CanonicalCallCollector` seeds into its binding scope. + +// qual:api +/// Extract `(name, &Type)` pairs for every typed positional parameter +/// of a fn signature. Framework-extractor patterns like +/// `fn h(State(db): State)` contribute `("db", State)` — the +/// outer type still goes through `resolve_type`, which peels the +/// transparent wrapper to reach `Db` when `State` is configured in +/// `transparent_wrappers`. +pub(crate) fn extract_signature_params(sig: &syn::Signature) -> Vec<(String, &syn::Type)> { + sig.inputs + .iter() + .filter_map(|arg| match arg { + syn::FnArg::Typed(pt) => { + param_name_from_pat(pt.pat.as_ref()).map(|n| (n, pt.ty.as_ref())) + } + _ => None, + }) + .collect() +} + +/// Pull the bound identifier out of a fn-parameter pattern. Supports +/// `Pat::Ident` (the 99% case) and single-ident `Pat::TupleStruct` +/// destructuring (framework extractors: `State(db)`, `Extension(ext)`, +/// `Path(p)`, `Json(body)`, `Data(ctx)`). Returns `None` for deeper +/// destructuring that the resolver can't express yet. +/// Operation: pattern peel. +fn param_name_from_pat(pat: &syn::Pat) -> Option { + match pat { + syn::Pat::Ident(pi) => Some(pi.ident.to_string()), + syn::Pat::TupleStruct(ts) if ts.elems.len() == 1 => { + if let syn::Pat::Ident(pi) = &ts.elems[0] { + return Some(pi.ident.to_string()); + } + None + } + _ => None, + } +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs index feaf589..acc6ea2 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs @@ -1,15 +1,22 @@ //! `syn::Type` → `CanonicalType` conversion. //! -//! Recognises stdlib wrappers (`Result`, `Option`, `Vec`, `HashMap`, -//! `BTreeMap`, `Arc`, `Box`, `Rc`, `Cow`, `RwLock`, `Mutex`, `RefCell`, -//! `Cell`) and projects their generic arguments into the matching -//! `CanonicalType` variant. Unknown-generic paths resolve through the -//! existing `bindings::canonicalise_type_segments` pipeline (alias map -//! + local symbols + crate roots). +//! Recognises `Result` / `Option` / `Future` / `Vec` / `HashMap` / +//! `BTreeMap` and the Deref-transparent smart pointers `Arc` / `Box` / +//! `Rc` / `Cow`, projecting their generic arguments into the matching +//! `CanonicalType` variant. `RwLock` / `Mutex` / `RefCell` / `Cell` are +//! intentionally *not* peeled — their methods (`read`, `lock`, +//! `borrow`, `get`) don't exist on the inner type, and peeling them +//! would synthesize false-positive call-graph edges. Users can opt back +//! in per-wrapper via `[architecture.call_parity]::transparent_wrappers` +//! when a domain-specific wrapper genuinely Derefs to its inner value. //! -//! Shared between the workspace-index builder (Task 1.2) and the -//! inference engine (Task 1.3) — both turn `syn::Type`s into -//! `CanonicalType`s with identical semantics. +//! Unknown-generic paths resolve through the existing +//! `bindings::canonicalise_type_segments` pipeline (alias map + local +//! symbols + crate roots). +//! +//! Shared between the workspace-index builder and the inference engine +//! — both turn `syn::Type`s into `CanonicalType`s with identical +//! semantics. use super::super::bindings::canonicalise_type_segments; use super::alias_substitution::substitute_alias_args; @@ -242,9 +249,9 @@ where } } -/// Peel a transparent single-type-param wrapper (Arc / Box / Rc / Cow / -/// RwLock / Mutex / RefCell / Cell) by recursing into its first generic -/// argument. Operation. +/// Peel a transparent single-type-param wrapper (Arc / Box / Rc / Cow +/// plus any user-configured `transparent_wrappers`) by recursing into +/// its first generic argument. Operation. fn peel_single_generic( args: &syn::PathArguments, ctx: &ResolveContext<'_>, diff --git a/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs b/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs index 7d3fba0..b5e2a83 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs @@ -21,6 +21,7 @@ use super::bindings::canonicalise_type_segments; use super::calls::{collect_canonical_calls, FnContext}; +use super::signature_params::extract_signature_params; use super::type_infer::{build_workspace_type_index, WorkspaceTypeIndex}; use crate::adapters::analyzers::architecture::forbidden_rule::file_to_module_segments; use crate::adapters::analyzers::architecture::layer_rule::LayerDefinitions; @@ -81,7 +82,12 @@ impl CallGraph { /// The format matches what the graph stores as node keys so lookups /// via `graph.forward` / `graph.reverse` / `graph.node_file` work. pub(crate) fn canonical_name_for_pub_fn(info: &super::pub_fns::PubFnInfo<'_>) -> String { - canonical_fn_name(&info.file, info.self_type.as_deref(), &info.fn_name) + canonical_fn_name( + &info.file, + info.self_type.as_deref(), + &info.mod_stack, + &info.fn_name, + ) } /// Lower-level primitive for constructing canonical fn names. Shared @@ -94,7 +100,12 @@ pub(crate) fn canonical_name_for_pub_fn(info: &super::pub_fns::PubFnInfo<'_>) -> /// header, we must not prepend the file's module segments or we'd /// produce `crate::::crate::foo::Bar::method`, which never /// matches receiver-tracked method targets. -fn canonical_fn_name(file: &str, self_type: Option<&[String]>, fn_name: &str) -> String { +fn canonical_fn_name( + file: &str, + self_type: Option<&[String]>, + mod_stack: &[String], + fn_name: &str, +) -> String { let mut segs: Vec = Vec::new(); match self_type { Some(impl_segs) if is_crate_rooted(impl_segs) => { @@ -103,11 +114,13 @@ fn canonical_fn_name(file: &str, self_type: Option<&[String]>, fn_name: &str) -> Some(impl_segs) => { segs.push("crate".to_string()); segs.extend(file_to_module_segments(file)); + segs.extend(mod_stack.iter().cloned()); segs.extend(impl_segs.iter().cloned()); } None => { segs.push("crate".to_string()); segs.extend(file_to_module_segments(file)); + segs.extend(mod_stack.iter().cloned()); } } segs.push(fn_name.to_string()); @@ -165,44 +178,6 @@ pub(crate) fn collect_local_symbols(ast: &syn::File) -> HashSet { .collect() } -/// Extract `(name, &Type)` pairs for every typed positional parameter -/// of a fn signature. Shared by pub-fn collection and graph-build since -/// both need the same `FnContext::signature_params` shape. -/// Framework-extractor patterns like `fn h(State(db): State)` -/// contribute `("db", State)` — the outer type still goes through -/// `resolve_type`, which peels the transparent wrapper to reach `Db` -/// when `State` is configured in `transparent_wrappers`. -pub(crate) fn extract_signature_params(sig: &syn::Signature) -> Vec<(String, &syn::Type)> { - sig.inputs - .iter() - .filter_map(|arg| match arg { - syn::FnArg::Typed(pt) => { - param_name_from_pat(pt.pat.as_ref()).map(|n| (n, pt.ty.as_ref())) - } - _ => None, - }) - .collect() -} - -/// Pull the bound identifier out of a fn-parameter pattern. Supports -/// `Pat::Ident` (the 99% case) and single-ident `Pat::TupleStruct` -/// destructuring (framework extractors: `State(db)`, `Extension(ext)`, -/// `Path(p)`, `Json(body)`, `Data(ctx)`). Returns `None` for deeper -/// destructuring that the resolver can't express yet. -/// Operation: pattern peel. -fn param_name_from_pat(pat: &syn::Pat) -> Option { - match pat { - syn::Pat::Ident(pi) => Some(pi.ident.to_string()), - syn::Pat::TupleStruct(ts) if ts.elems.len() == 1 => { - if let syn::Pat::Ident(pi) = &ts.elems[0] { - return Some(pi.ident.to_string()); - } - None - } - _ => None, - } -} - /// Canonicalise an impl block's self-type through the same alias / /// local-symbol / crate-root pipeline the call collector uses for type /// bindings. Returns a crate-rooted segment list when the type resolves @@ -324,6 +299,7 @@ pub(crate) fn build_call_graph<'ast>( crate_root_modules: &crate_root_modules, type_index: &type_index, impl_type_stack: Vec::new(), + mod_stack: Vec::new(), graph: &mut graph, }; collector.visit_file(ast); @@ -353,11 +329,13 @@ struct FileFnCollector<'a> { local_symbols: &'a HashSet, crate_root_modules: &'a HashSet, type_index: &'a WorkspaceTypeIndex, - /// Stack of enclosing impl blocks' resolved self-types. `None` - /// marks an unresolved self-type (trait object, `&T`, tuple) whose - /// methods we must not record — their canonical would collapse to - /// `crate::::method` and collide with free fns. + /// `None` marks an unresolved self-type (trait object, `&T`, tuple) + /// whose methods we must not record — the canonical would collapse + /// to `crate::::method` and collide with free fns. impl_type_stack: Vec>>, + /// Enclosing inline-mod names so fns inside `mod inner { ... }` + /// record under `crate::::inner::fn`. + mod_stack: Vec, graph: &'a mut CallGraph, } @@ -377,7 +355,8 @@ impl<'a> FileFnCollector<'a> { // don't record; see `resolve_impl_self_type`'s doc. Some(None) => return, }; - let canonical = canonical_fn_name(self.path, self_type.as_deref(), fn_name); + let canonical = + canonical_fn_name(self.path, self_type.as_deref(), &self.mod_stack, fn_name); let ctx = FnContext { body, signature_params: extract_signature_params(sig), @@ -442,6 +421,8 @@ impl<'a, 'ast> Visit<'ast> for FileFnCollector<'a> { if has_cfg_test(&node.attrs) { return; } + self.mod_stack.push(node.ident.to_string()); syn::visit::visit_item_mod(self, node); + self.mod_stack.pop(); } } diff --git a/src/adapters/analyzers/architecture/compiled.rs b/src/adapters/analyzers/architecture/compiled.rs index 91fbace..1229038 100644 --- a/src/adapters/analyzers/architecture/compiled.rs +++ b/src/adapters/analyzers/architecture/compiled.rs @@ -163,7 +163,11 @@ fn build_transparent_macros(user: &[String]) -> HashSet { "cfg_attr", // conditional attribute ]; let mut set: HashSet = DEFAULTS.iter().map(|s| (*s).to_string()).collect(); - set.extend(user.iter().map(|s| last_path_segment(s.trim()).to_string())); + set.extend( + user.iter() + .map(|s| last_path_segment(s.trim()).to_string()) + .filter(|s| !s.is_empty()), + ); set } diff --git a/src/adapters/shared/cfg_test_files.rs b/src/adapters/shared/cfg_test_files.rs index 123d07e..292549a 100644 --- a/src/adapters/shared/cfg_test_files.rs +++ b/src/adapters/shared/cfg_test_files.rs @@ -120,16 +120,11 @@ impl<'a> ChildPathResolver<'a> { parent.with_extension("") }; // Normalize backslashes to forward slashes on Windows so the - // candidates match `known_paths` (which always store /). - let candidate_file = child_dir - .join(format!("{mod_name}.rs")) - .to_string_lossy() - .replace('\\', "/"); - let candidate_dir = child_dir - .join(mod_name) - .join("mod.rs") - .to_string_lossy() - .replace('\\', "/"); + // candidates match `known_paths` (which always store /). The + // `normalize_sep` helper is a no-op on Unix builds. + let candidate_file = normalize_sep(child_dir.join(format!("{mod_name}.rs")).to_string_lossy().as_ref()); + let candidate_dir = + normalize_sep(child_dir.join(mod_name).join("mod.rs").to_string_lossy().as_ref()); if self.known_paths.contains(candidate_file.as_str()) { Some(candidate_file) } else if self.known_paths.contains(candidate_dir.as_str()) { @@ -142,6 +137,24 @@ impl<'a> ChildPathResolver<'a> { /// Extract the string value of a `#[path = "..."]` attribute if present. /// Operation: attribute lookup + literal parsing, no own calls. +/// Convert OS-native path separators into the forward-slash form used +/// by `known_paths`. On Unix this is the identity (no allocation); on +/// Windows we only scan+replace when a backslash is actually present. +/// Operation. +#[cfg(windows)] +fn normalize_sep(path: &str) -> String { + if path.contains('\\') { + path.replace('\\', "/") + } else { + path.to_string() + } +} + +#[cfg(not(windows))] +fn normalize_sep(path: &str) -> String { + path.to_string() +} + fn path_attribute(attrs: &[syn::Attribute]) -> Option { attrs.iter().find_map(|attr| { if !attr.path().is_ident("path") { From 3c3f8790ef0d5544cf6b1d23b13268030d4f7029 Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:56:02 +0200 Subject: [PATCH 08/30] fix: linting --- .../architecture/call_parity_rule/pub_fns.rs | 3 ++- src/adapters/shared/cfg_test_files.rs | 16 +++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs index 9a95028..fcdef77 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs @@ -19,7 +19,8 @@ use super::signature_params::extract_signature_params; use super::workspace_graph::{ - collect_crate_root_modules, collect_local_symbols, impl_self_ty_segments, resolve_impl_self_type, + collect_crate_root_modules, collect_local_symbols, impl_self_ty_segments, + resolve_impl_self_type, }; use crate::adapters::analyzers::architecture::layer_rule::LayerDefinitions; use crate::adapters::shared::cfg_test::{has_cfg_test, has_test_attr}; diff --git a/src/adapters/shared/cfg_test_files.rs b/src/adapters/shared/cfg_test_files.rs index 292549a..2cf2522 100644 --- a/src/adapters/shared/cfg_test_files.rs +++ b/src/adapters/shared/cfg_test_files.rs @@ -122,9 +122,19 @@ impl<'a> ChildPathResolver<'a> { // Normalize backslashes to forward slashes on Windows so the // candidates match `known_paths` (which always store /). The // `normalize_sep` helper is a no-op on Unix builds. - let candidate_file = normalize_sep(child_dir.join(format!("{mod_name}.rs")).to_string_lossy().as_ref()); - let candidate_dir = - normalize_sep(child_dir.join(mod_name).join("mod.rs").to_string_lossy().as_ref()); + let candidate_file = normalize_sep( + child_dir + .join(format!("{mod_name}.rs")) + .to_string_lossy() + .as_ref(), + ); + let candidate_dir = normalize_sep( + child_dir + .join(mod_name) + .join("mod.rs") + .to_string_lossy() + .as_ref(), + ); if self.known_paths.contains(candidate_file.as_str()) { Some(candidate_file) } else if self.known_paths.contains(candidate_dir.as_str()) { From a302babb997ef8891b1e1001f75fe84f4a6ee5cf Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sat, 25 Apr 2026 12:28:52 +0200 Subject: [PATCH 09/30] fix: copilot comments --- .../architecture/call_parity_rule/bindings.rs | 71 +++- .../architecture/call_parity_rule/calls.rs | 136 +++++-- .../call_parity_rule/local_symbols.rs | 115 ++++++ .../architecture/call_parity_rule/mod.rs | 1 + .../architecture/call_parity_rule/pub_fns.rs | 72 ++-- .../call_parity_rule/tests/calls.rs | 8 + .../call_parity_rule/tests/regressions.rs | 2 + .../type_infer/infer/access.rs | 2 + .../type_infer/infer/generics.rs | 2 + .../call_parity_rule/type_infer/mod.rs | 14 +- .../type_infer/patterns/destructure.rs | 2 + .../call_parity_rule/type_infer/resolve.rs | 52 ++- .../type_infer/tests/resolve.rs | 2 + .../type_infer/tests/workspace_index.rs | 376 ++++++++++-------- .../type_infer/workspace_index/fields.rs | 5 +- .../type_infer/workspace_index/functions.rs | 2 +- .../type_infer/workspace_index/methods.rs | 2 +- .../type_infer/workspace_index/mod.rs | 74 ++-- .../type_infer/workspace_index/traits.rs | 30 +- .../call_parity_rule/workspace_graph.rs | 58 ++- src/adapters/shared/cfg_test_files.rs | 6 +- 21 files changed, 696 insertions(+), 336 deletions(-) create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/local_symbols.rs diff --git a/src/adapters/analyzers/architecture/call_parity_rule/bindings.rs b/src/adapters/analyzers/architecture/call_parity_rule/bindings.rs index abfbcdc..d730fc4 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/bindings.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/bindings.rs @@ -7,6 +7,7 @@ //! `(name, canonical)` pair, preferring an explicit `let s: T =` annotation //! over constructor-inference from `let s = T::new()`. +use super::local_symbols::scope_for_local; use crate::adapters::analyzers::architecture::forbidden_rule::{ file_to_module_segments, resolve_to_crate_absolute, }; @@ -74,44 +75,81 @@ fn strip_wrappers(ty: &syn::Type) -> &syn::Type { } } -/// Resolve a type-path segment list into a canonical `[crate, …]` path, -/// applying `crate/self/super` module normalisation, alias-map lookup, -/// and same-file fallback for types declared locally. -/// Returns `None` for unresolvable paths (external crates, unknown idents). +/// Bundled inputs for canonical-type-path resolution. Same-file fallback +/// walks `mod_stack` outward against `local_decl_scopes` to pick the +/// closest enclosing declaration of a single-ident type name. +pub(super) struct CanonScope<'a> { + pub alias_map: &'a HashMap>, + pub local_symbols: &'a HashSet, + pub crate_root_modules: &'a HashSet, + pub importing_file: &'a str, + /// `name → list of mod-paths-within-file where this name is + /// declared`. `None` means flat top-level prepend (legacy path). + pub local_decl_scopes: Option<&'a HashMap>>>, + /// Mod-path of the calling site within `importing_file`. Empty for + /// top-level callers or legacy code paths. + pub mod_stack: &'a [String], +} + +/// Legacy helper for callers that don't track mod scope (call collector, +/// the `bindings::canonical_from_type` adapter, tests). Delegates to the +/// scope-aware variant with empty `mod_stack` + `None` decl scopes — +/// behaviour unchanged. Operation: thin wrapper. pub(super) fn canonicalise_type_segments( segments: &[String], alias_map: &HashMap>, local_symbols: &HashSet, crate_root_modules: &HashSet, importing_file: &str, +) -> Option> { + canonicalise_type_segments_in_scope( + segments, + &CanonScope { + alias_map, + local_symbols, + crate_root_modules, + importing_file, + local_decl_scopes: None, + mod_stack: &[], + }, + ) +} + +/// Resolve a type-path segment list into a canonical `[crate, …]` path, +/// applying `crate/self/super` module normalisation, alias-map lookup, +/// and same-file fallback (mod-scope-aware when `scope.mod_stack` / +/// `scope.local_decl_scopes` are populated). Returns `None` for +/// unresolvable paths (external crates, unknown idents). +/// Operation: closure-hidden own calls keep IOSP clean. +pub(super) fn canonicalise_type_segments_in_scope( + segments: &[String], + scope: &CanonScope<'_>, ) -> Option> { if segments.is_empty() { return None; } + let normalize = + |full| normalize_after_alias(full, scope.importing_file, scope.crate_root_modules); if matches!(segments[0].as_str(), "crate" | "self" | "super") { - let resolved = resolve_to_crate_absolute(importing_file, segments)?; + let resolved = resolve_to_crate_absolute(scope.importing_file, segments)?; let mut full = vec!["crate".to_string()]; full.extend(resolved); return Some(full); } - if let Some(alias) = alias_map.get(&segments[0]) { + if let Some(alias) = scope.alias_map.get(&segments[0]) { let mut full = alias.clone(); full.extend_from_slice(&segments[1..]); - return normalize_after_alias(full, importing_file, crate_root_modules); + return normalize(full); } - // Same-file fallback: `fn f(s: Session)` or `let s = Session::open()` - // where `Session` is declared in this file (no `use` needed in Rust). - // Resolve to `crate::::Session` so receiver-tracked method - // calls match the corresponding graph nodes. - if local_symbols.contains(&segments[0]) { + if scope.local_symbols.contains(&segments[0]) { + let mod_path = scope_for_local(scope.local_decl_scopes, &segments[0], scope.mod_stack); let mut full = vec!["crate".to_string()]; - full.extend(file_to_module_segments(importing_file)); + full.extend(file_to_module_segments(scope.importing_file)); + full.extend(mod_path.iter().cloned()); full.extend_from_slice(segments); return Some(full); } - // Rust 2018+ absolute import: `let s: app::Session = ...` where - // `app` is a workspace crate-root module → prepend `crate`. - if crate_root_modules.contains(&segments[0]) { + if scope.crate_root_modules.contains(&segments[0]) { let mut full = vec!["crate".to_string()]; full.extend_from_slice(segments); return Some(full); @@ -119,6 +157,7 @@ pub(super) fn canonicalise_type_segments( None } + /// After alias-map substitution, re-run `self` / `super` normalisation /// and prepend `crate` for Rust 2018+ absolute imports that resolve /// into a known workspace root module (`use app::foo;` → diff --git a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs index 62c86a9..59ebc50 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs @@ -17,6 +17,7 @@ //! the binding scan patterns. use super::bindings::{canonical_from_type, extract_let_binding, normalize_alias_expansion}; +use super::local_symbols::scope_for_local; use super::type_infer::resolve::{resolve_type, ResolveContext}; use super::type_infer::{ extract_bindings, extract_for_bindings, infer_type, BindingLookup, CanonicalType, InferContext, @@ -51,10 +52,9 @@ pub struct FnContext<'a> { pub self_type: Option>, /// File-level import alias map (output of `gather_alias_map`). pub alias_map: &'a HashMap>, - /// Set of top-level item names declared in the same file. Unqualified - /// calls (`helper()`, no `use` statement) whose first segment is in - /// this set resolve to `crate::::` so the call - /// graph sees local delegation chains. + /// Set of top-level + nested item names declared in the same file. + /// Unqualified calls (`helper()`, no `use` statement) whose first + /// segment is in this set resolve to `crate::::<...>`. pub local_symbols: &'a HashSet, /// Set of crate-root module names (first-segment `` for every /// `src/.rs` / `src//**.rs` in the workspace). Lets the @@ -69,6 +69,15 @@ pub struct FnContext<'a> { /// (typical in unit-test fixtures that don't build the full graph). /// The full `build_call_graph` pipeline always passes `Some(&index)`. pub workspace_index: Option<&'a WorkspaceTypeIndex>, + /// Mod-path of the fn declaration inside `importing_file`. Empty + /// for top-level fns. Used together with `local_decl_scopes` so a + /// fn `crate::file::inner::make` references its sibling type + /// `Session` and resolves to `crate::file::inner::Session`. + pub mod_stack: &'a [String], + /// Per-name list of declaring mod-paths within `importing_file`. + /// `None` for legacy / test callers — the resolver falls back to + /// flat top-level prepend behaviour. + pub local_decl_scopes: Option<&'a HashMap>>>, } // qual:api @@ -94,6 +103,15 @@ struct CanonicalCallCollector<'a> { /// `crate` prefix), if any — used to resolve `Self::method`. self_type_canonical: Option>, signature_params: Vec<(String, &'a syn::Type)>, + /// Mod-path inside `importing_file` of the fn under analysis. + /// Borrowed from `FnContext.mod_stack` — Rust doesn't let inner + /// `mod` items shadow outer resolution, so the path is read-only + /// for the duration of the body walk. + mod_stack: &'a [String], + /// Per-name declaring mod-paths within `importing_file`. Mirrors + /// `FnContext.local_decl_scopes` and feeds the scope-aware + /// `canonicalise_path` branch. + local_decl_scopes: Option<&'a HashMap>>>, /// Scope stack of variable-name → canonical-type-path bindings. /// Inner-most scope is at the end; lookup walks from back to front. /// Always non-empty while a collection is in flight. @@ -123,8 +141,13 @@ impl<'a> CanonicalCallCollector<'a> { if segs.first().map(|s| s.as_str()) == Some("crate") { return segs.clone(); } + // Insert `mod_stack` between file segments and impl segments so + // an `impl Session { ... }` declared inside `mod inner` resolves + // `Self::method` to `crate::::inner::Session::method` — + // matching the path the graph node + type-index keys use. let mut full = vec!["crate".to_string()]; full.extend(file_to_module_segments(ctx.importing_file)); + full.extend(ctx.mod_stack.iter().cloned()); full.extend_from_slice(segs); full }); @@ -135,6 +158,8 @@ impl<'a> CanonicalCallCollector<'a> { importing_file: ctx.importing_file, self_type_canonical, signature_params: ctx.signature_params.clone(), + mod_stack: ctx.mod_stack, + local_decl_scopes: ctx.local_decl_scopes, bindings: vec![HashMap::new()], non_path_bindings: vec![HashMap::new()], calls: HashSet::new(), @@ -179,6 +204,8 @@ impl<'a> CanonicalCallCollector<'a> { importing_file: self.importing_file, type_aliases: self.workspace_index.map(|w| &w.type_aliases), transparent_wrappers: self.workspace_index.map(|w| &w.transparent_wrappers), + local_decl_scopes: self.local_decl_scopes, + mod_stack: self.mod_stack, }; match resolve_type(ty, &rctx) { CanonicalType::Path(segs) => { @@ -242,54 +269,23 @@ impl<'a> CanonicalCallCollector<'a> { } /// Turn a path-segment list into the canonical String used for all - /// call-target comparisons in the call-parity check. + /// call-target comparisons in the call-parity check. Integration: + /// each branch delegates to a dedicated helper. fn canonicalise_path(&self, segments: &[String]) -> String { if segments.is_empty() { return String::new(); } - // Self::method if segments[0] == "Self" { - if let Some(self_canonical) = &self.self_type_canonical { - let mut full = self_canonical.clone(); - full.extend_from_slice(&segments[1..]); - return full.join("::"); - } - return bare(&segments.join("::")); + return self.canonicalise_self_path(segments); } - // crate / self / super — resolve to crate-absolute if matches!(segments[0].as_str(), "crate" | "self" | "super") { - if let Some(resolved) = resolve_to_crate_absolute(self.importing_file, segments) { - let mut full = vec!["crate".to_string()]; - full.extend(resolved); - return full.join("::"); - } - return bare(&segments.join("::")); - } - // Alias-map hit on first segment → replace prefix, then - // re-normalise in case the alias itself resolves through - // `self::` / `super::` (e.g. `use super::foo::Bar;`) or uses - // the Rust 2018+ absolute form (`use app::foo;`). - if let Some(alias) = self.alias_map.get(&segments[0]) { - let mut full = alias.clone(); - full.extend_from_slice(&segments[1..]); - if let Some(normalized) = - normalize_alias_expansion(full, self.importing_file, self.crate_root_modules) - { - return normalized.join("::"); - } + return self.canonicalise_keyword_path(segments); + } + if let Some(canonical) = self.canonicalise_alias_path(segments) { + return canonical; } - // Same-module fallback: unqualified call whose first segment is - // a top-level item in the same file resolves to - // `crate::::`. Without this, idiomatic - // Rust like `fn helper() {}` + `pub fn cmd() { helper(); }` - // leaves `cmd → :helper` as a dead-end edge, and Check A - // can falsely report "no delegation" when the actual delegation - // flows through the local helper. if self.local_symbols.contains(&segments[0]) { - let mut full = vec!["crate".to_string()]; - full.extend(file_to_module_segments(self.importing_file)); - full.extend_from_slice(segments); - return full.join("::"); + return self.canonicalise_local_symbol_path(segments); } // Rust 2018+ absolute call: `app::foo()` without `use` is the // crate-root `app` module, equivalent to `crate::app::foo()`. @@ -304,6 +300,57 @@ impl<'a> CanonicalCallCollector<'a> { bare(&segments.join("::")) } + /// `Self::method` — substitute the enclosing impl's canonical + /// self-type for `Self`. Falls back to `:` when we're not + /// inside an impl. Operation. + fn canonicalise_self_path(&self, segments: &[String]) -> String { + if let Some(self_canonical) = &self.self_type_canonical { + let mut full = self_canonical.clone(); + full.extend_from_slice(&segments[1..]); + return full.join("::"); + } + bare(&segments.join("::")) + } + + /// `crate` / `self` / `super` — resolve through the file-relative + /// module path. Falls back to `:` if `resolve_to_crate_absolute` + /// can't make sense of the path. Operation. + fn canonicalise_keyword_path(&self, segments: &[String]) -> String { + if let Some(resolved) = resolve_to_crate_absolute(self.importing_file, segments) { + let mut full = vec!["crate".to_string()]; + full.extend(resolved); + return full.join("::"); + } + bare(&segments.join("::")) + } + + /// First segment hits the file's import-alias map → replace the + /// prefix and re-normalise (alias may itself reference `self::` or + /// a Rust-2018 crate-root module). Returns `None` when no alias + /// matches. Operation. + fn canonicalise_alias_path(&self, segments: &[String]) -> Option { + let alias = self.alias_map.get(&segments[0])?; + let mut full = alias.clone(); + full.extend_from_slice(&segments[1..]); + let normalized = + normalize_alias_expansion(full, self.importing_file, self.crate_root_modules)?; + Some(normalized.join("::")) + } + + /// Same-file fallback: first segment is declared in this file, so + /// resolve to `crate::::::`. The + /// mod-path walk picks the closest enclosing scope so a call inside + /// `mod inner` to a sibling `helper()` reaches + /// `crate::::inner::helper`. Operation. + fn canonicalise_local_symbol_path(&self, segments: &[String]) -> String { + let mod_path = scope_for_local(self.local_decl_scopes, &segments[0], self.mod_stack); + let mut full = vec!["crate".to_string()]; + full.extend(file_to_module_segments(self.importing_file)); + full.extend(mod_path.iter().cloned()); + full.extend_from_slice(segments); + full.join("::") + } + fn record_call(&mut self, target: String) { self.calls.insert(target); } @@ -418,6 +465,8 @@ impl<'a> CanonicalCallCollector<'a> { importing_file: self.importing_file, type_aliases: Some(&wi.type_aliases), transparent_wrappers: Some(&wi.transparent_wrappers), + local_decl_scopes: self.local_decl_scopes, + mod_stack: self.mod_stack, }; let name = pi.ident.to_string(); match resolve_type(pt.ty.as_ref(), &rctx) { @@ -732,6 +781,7 @@ fn method_unknown(method: &str) -> String { format!("{METHOD_UNKNOWN_PREFIX}{method}") } + // The Visit impl uses an independent `'ast` lifetime so the same // collector can walk both the main fn body (long-lived) and macro // bodies we parse on-the-fly (locally-owned, short-lived). The struct's diff --git a/src/adapters/analyzers/architecture/call_parity_rule/local_symbols.rs b/src/adapters/analyzers/architecture/call_parity_rule/local_symbols.rs new file mode 100644 index 0000000..0459110 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/local_symbols.rs @@ -0,0 +1,115 @@ +//! Per-file local-symbol collection with mod-scope awareness. +//! +//! Two views are kept in sync: +//! +//! - `flat: HashSet` — every name declared anywhere in the +//! file, top-level or nested. Existing callers (`canonicalise_type +//! _segments`, `local_symbols.contains(name)`) keep working unchanged. +//! - `by_name: HashMap>>` — per-name list of +//! mod-paths-within-file where the name is declared. Lets the +//! scope-aware canonicaliser pick the closest enclosing declaration +//! so `Session` referenced from inside `mod inner` resolves to +//! `crate::::inner::Session` when the type is declared there. + +use crate::adapters::shared::cfg_test::has_cfg_test; +use std::collections::{HashMap, HashSet}; + +/// `(flat-set, per-scope-map)` view over the names declared in a file. +#[derive(Debug, Default, Clone)] +pub(crate) struct LocalSymbols { + pub flat: HashSet, + pub by_name: HashMap>>, +} + +// qual:api +/// Flat top-level + nested name set. Backward-compatible shape for +/// callers that don't track mod scope. Operation: project flat view. +pub(crate) fn collect_local_symbols(ast: &syn::File) -> HashSet { + let scoped = collect_local_symbols_scoped(ast); + scoped.flat +} + +// qual:api +/// Scoped variant. Returns both views in one walk so the `flat` set +/// and the `by_name` map are always consistent. Operation. +pub(crate) fn collect_local_symbols_scoped(ast: &syn::File) -> LocalSymbols { + let mut symbols = LocalSymbols::default(); + walk_local_symbols(&ast.items, &mut Vec::new(), &mut symbols); + symbols +} + +/// Recursive AST walk that populates `LocalSymbols.flat` + `by_name`. +/// `mod_stack` carries the current mod-scope (outer-most first). +/// Operation. Own calls hidden in closure for IOSP leniency. +// qual:recursive +fn walk_local_symbols(items: &[syn::Item], mod_stack: &mut Vec, out: &mut LocalSymbols) { + let recurse = + |inner: &[syn::Item], stack: &mut Vec, out: &mut LocalSymbols| { + walk_local_symbols(inner, stack, out); + }; + for item in items { + if let Some(name) = item_name(item) { + out.flat.insert(name.clone()); + out.by_name + .entry(name) + .or_default() + .push(mod_stack.clone()); + } + if let syn::Item::Mod(m) = item { + if !has_cfg_test(&m.attrs) { + if let Some((_, inner)) = m.content.as_ref() { + mod_stack.push(m.ident.to_string()); + recurse(inner, mod_stack, out); + mod_stack.pop(); + } + } + } + } +} + +// qual:api +/// Walk `mod_stack` outward and return the closest enclosing mod-path +/// in which `name` is declared. Falls back to the empty (top-level) +/// scope when `decl_scopes` is `None` (legacy callers) or the name +/// isn't declared anywhere mappable. Shared by the bindings +/// canonicaliser and the call collector so call canonicals and +/// type-index keys agree on which mod a same-name item belongs to. +/// Operation. +pub(crate) fn scope_for_local<'a>( + decl_scopes: Option<&'a HashMap>>>, + name: &str, + mod_stack: &[String], +) -> &'a [String] { + let Some(scopes) = decl_scopes else { + return &[]; + }; + let Some(candidates) = scopes.get(name) else { + return &[]; + }; + for depth in (0..=mod_stack.len()).rev() { + let prefix = &mod_stack[..depth]; + for path in candidates { + if path.as_slice() == prefix { + return path.as_slice(); + } + } + } + candidates.first().map(Vec::as_slice).unwrap_or(&[]) +} + +/// Extract the declared ident from an `Item` if it has one +/// `local_symbols` cares about. Operation: lookup table. +fn item_name(item: &syn::Item) -> Option { + match item { + syn::Item::Fn(f) => Some(f.sig.ident.to_string()), + syn::Item::Mod(m) => Some(m.ident.to_string()), + syn::Item::Struct(s) => Some(s.ident.to_string()), + syn::Item::Enum(e) => Some(e.ident.to_string()), + syn::Item::Union(u) => Some(u.ident.to_string()), + syn::Item::Trait(t) => Some(t.ident.to_string()), + syn::Item::Type(t) => Some(t.ident.to_string()), + syn::Item::Const(c) => Some(c.ident.to_string()), + syn::Item::Static(s) => Some(s.ident.to_string()), + _ => None, + } +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/mod.rs index e21aac9..d787c36 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/mod.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/mod.rs @@ -17,6 +17,7 @@ mod bindings; pub mod calls; pub mod check_a; pub mod check_b; +pub(crate) mod local_symbols; pub mod pub_fns; pub(crate) mod signature_params; pub mod type_infer; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs index fcdef77..255c44b 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs @@ -94,16 +94,22 @@ pub(crate) fn collect_pub_fns_by_layer<'ast>( out } -/// Collect every visible (non-inherited-visibility) top-level type name -/// across the whole non-test workspace. Impls on the same type name get -/// counted as visible regardless of which file the impl lives in — so -/// `pub struct Session` in `src/app/session.rs` and its `impl Session` -/// in a companion file both contribute to the check. +/// Collect every visible (non-inherited-visibility) type name across +/// the whole non-test workspace. Recurses into non-test inline `mod` +/// blocks so types declared inside `pub mod inner { pub struct S; }` +/// are recognised — without that, impls on `S` would be dropped as +/// "private" by Check B's visibility filter. +/// +/// Impls on the same type name get counted as visible regardless of +/// which file the impl lives in — so `pub struct Session` in +/// `src/app/session.rs` and its `impl Session` in a companion file +/// both contribute to the check. /// /// The matching is string-equality on the last segment of the impl's /// self-type path. Two distinct types with the same name in different -/// files both match; that's MVP-level imprecision — false positives -/// (over-counting) rather than false negatives. +/// files / mods both match; that's MVP-level imprecision — false +/// positives (over-counting) rather than false negatives. +/// Integration: per-file delegate to recursive collector. fn collect_visible_type_names_workspace( files: &[(&str, &syn::File)], cfg_test_files: &HashSet, @@ -113,28 +119,44 @@ fn collect_visible_type_names_workspace( if cfg_test_files.contains(*path) { continue; } - for item in &ast.items { - match item { - syn::Item::Struct(s) if is_visible(&s.vis) => { - out.insert(s.ident.to_string()); - } - syn::Item::Enum(e) if is_visible(&e.vis) => { - out.insert(e.ident.to_string()); - } - syn::Item::Union(u) if is_visible(&u.vis) => { - out.insert(u.ident.to_string()); - } - syn::Item::Trait(t) if is_visible(&t.vis) => { - out.insert(t.ident.to_string()); - } - syn::Item::Type(t) if is_visible(&t.vis) => { - out.insert(t.ident.to_string()); + collect_visible_type_names_in_items(&ast.items, &mut out); + } + out +} + +/// Walk a slice of items, inserting visible type-name idents and +/// recursing into non-cfg-test inline mods. Operation: closure-hidden +/// recursion through nested `mod` blocks. +// qual:recursive +fn collect_visible_type_names_in_items(items: &[syn::Item], out: &mut HashSet) { + let recurse = |inner: &[syn::Item], out: &mut HashSet| { + collect_visible_type_names_in_items(inner, out); + }; + for item in items { + match item { + syn::Item::Struct(s) if is_visible(&s.vis) => { + out.insert(s.ident.to_string()); + } + syn::Item::Enum(e) if is_visible(&e.vis) => { + out.insert(e.ident.to_string()); + } + syn::Item::Union(u) if is_visible(&u.vis) => { + out.insert(u.ident.to_string()); + } + syn::Item::Trait(t) if is_visible(&t.vis) => { + out.insert(t.ident.to_string()); + } + syn::Item::Type(t) if is_visible(&t.vis) => { + out.insert(t.ident.to_string()); + } + syn::Item::Mod(m) if !has_cfg_test(&m.attrs) => { + if let Some((_, inner)) = m.content.as_ref() { + recurse(inner, out); } - _ => {} } + _ => {} } } - out } /// Workspace-walker — visits items, tracks impl-type visibility diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/calls.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/calls.rs index e166e80..b8c67b5 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/calls.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/calls.rs @@ -125,6 +125,8 @@ fn ctx_for_fn<'a>(fctx: &'a FileCtx, fn_name: &str, importing_file: &'a str) -> crate_root_modules: &fctx.crate_root_modules, importing_file, workspace_index: None, + mod_stack: &[], + local_decl_scopes: None, } } @@ -315,6 +317,8 @@ fn test_collect_self_dispatch_in_impl() { crate_root_modules: &fctx.crate_root_modules, importing_file: "src/application/session.rs", workspace_index: None, + mod_stack: &[], + local_decl_scopes: None, }; let calls = collect_canonical_calls(&ctx); assert!( @@ -770,6 +774,8 @@ fn test_qualified_impl_path_does_not_double_crate() { crate_root_modules: &fctx.crate_root_modules, importing_file: "src/other_file.rs", workspace_index: None, + mod_stack: &[], + local_decl_scopes: None, }; let calls = collect_canonical_calls(&ctx); assert!( @@ -801,6 +807,8 @@ fn ctx_with_index<'a>( crate_root_modules: &fctx.crate_root_modules, importing_file, workspace_index: Some(index), + mod_stack: &[], + local_decl_scopes: None, } } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs index 7aced58..7720f94 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs @@ -129,6 +129,8 @@ fn run(fx: &RegFixture, index: &WorkspaceTypeIndex, fn_name: &str) -> HashSet) -> Option { pub local_symbols: &'a HashSet, pub crate_root_modules: &'a HashSet, pub importing_file: &'a str, - /// Stage 3 workspace-wide type aliases. `None` means the caller + /// workspace-wide type aliases. `None` means the caller /// doesn't need alias expansion (the workspace-index build phase, /// where the alias map is still being populated). Inference paths /// pass `Some(&workspace.type_aliases)`. The stored tuple carries /// the alias's generic-param names plus its target — use-site args /// are substituted into the target before recursion. pub type_aliases: Option<&'a HashMap, syn::Type)>>, - /// Stage 3 user-defined transparent wrappers — the last-ident + /// user-defined transparent wrappers — the last-ident /// names (e.g. `"State"`, `"Extension"`, `"Data"`) that are peeled /// just like `Arc` / `Box`. `None` means only stdlib wrappers are /// peeled. pub transparent_wrappers: Option<&'a HashSet>, + /// Per-name list of mod-paths-within-file where the name is + /// declared. `None` falls back to flat top-level prepend so legacy + /// callers behave as before. Inline-mod-aware callers pass + /// `Some(&workspace.local_decl_scopes_per_file[file])` (or the + /// build-time equivalent). + pub local_decl_scopes: Option<&'a HashMap>>>, + /// Mod-path inside `importing_file` of the type / fn being resolved. + /// Empty when the caller is at file-top-level or doesn't track mod + /// scope. Used together with `local_decl_scopes` to pick the + /// closest-enclosing declaration of a single-ident type name. + pub mod_stack: &'a [String], } /// Hard recursion cap for `resolve_type_with_depth`. Guards against @@ -49,6 +60,20 @@ pub(crate) struct ResolveContext<'a> { /// fixtures). Real-world types bottom out well under 16 levels. const MAX_RESOLVE_DEPTH: u8 = 32; +/// Build a `CanonScope` view over the resolver's context — DRY helper +/// shared by `resolve_bound_list` and `resolve_generic_path`. Operation: +/// pure field projection. +fn canon_scope<'a>(ctx: &'a ResolveContext<'a>) -> CanonScope<'a> { + CanonScope { + alias_map: ctx.alias_map, + local_symbols: ctx.local_symbols, + crate_root_modules: ctx.crate_root_modules, + importing_file: ctx.importing_file, + local_decl_scopes: ctx.local_decl_scopes, + mod_stack: ctx.mod_stack, + } +} + // qual:api /// Convert a declared / inferred `syn::Type` into a `CanonicalType`. /// References, parens, and the stdlib-wrapper set are peeled; type paths @@ -125,13 +150,7 @@ fn resolve_bound_list( .iter() .map(|s| s.ident.to_string()) .collect(); - if let Some(resolved) = canonicalise_type_segments( - &segs, - ctx.alias_map, - ctx.local_symbols, - ctx.crate_root_modules, - ctx.importing_file, - ) { + if let Some(resolved) = canonicalise_type_segments_in_scope(&segs, &canon_scope(ctx)) { return CanonicalType::TraitBound(resolved); } } @@ -220,7 +239,7 @@ fn future_output_type(args: &syn::PathArguments) -> Option<&syn::Type> { assoc.or_else(|| generic_type_arg(args, 0)) } -/// Stage 3 — check if `name` is a user-configured transparent wrapper. +/// check if `name` is a user-configured transparent wrapper. /// Operation: set lookup with optional presence. fn is_user_transparent(name: &str, ctx: &ResolveContext<'_>) -> bool { ctx.transparent_wrappers @@ -271,15 +290,8 @@ fn peel_single_generic( /// Operation: closure-hidden calls + alias dispatch. fn resolve_generic_path(path: &syn::Path, ctx: &ResolveContext<'_>, depth: u8) -> CanonicalType { let recurse = |t: &syn::Type| resolve_type_with_depth(t, ctx, depth); - let canonicalise = |segs: &[String]| { - canonicalise_type_segments( - segs, - ctx.alias_map, - ctx.local_symbols, - ctx.crate_root_modules, - ctx.importing_file, - ) - }; + let canonicalise = + |segs: &[String]| canonicalise_type_segments_in_scope(segs, &canon_scope(ctx)); let segments: Vec = path.segments.iter().map(|s| s.ident.to_string()).collect(); let Some(resolved) = canonicalise(&segments) else { return CanonicalType::Opaque; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs index fe72225..46688b0 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs @@ -26,6 +26,8 @@ fn ctx<'a>( importing_file, type_aliases: None, transparent_wrappers: None, + local_decl_scopes: None, + mod_stack: &[], } } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs index b57e42b..efc9c97 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs @@ -4,8 +4,11 @@ //! across single- and multi-file workspaces plus the cfg-test skip //! behaviour. +use crate::adapters::analyzers::architecture::call_parity_rule::local_symbols::{ + collect_local_symbols_scoped, LocalSymbols, +}; use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::{ - build_workspace_type_index, CanonicalType, + build_workspace_type_index, CanonicalType, WorkspaceIndexInputs, }; use crate::adapters::shared::use_tree::gather_alias_map; use std::collections::{HashMap, HashSet}; @@ -17,17 +20,24 @@ fn parse_file(src: &str) -> syn::File { struct WsFixture { parsed: Vec<(String, syn::File)>, aliases: HashMap>>, + local_symbols: HashMap, } fn fixture(entries: &[(&str, &str)]) -> WsFixture { let mut parsed = Vec::new(); let mut aliases = HashMap::new(); + let mut local_symbols = HashMap::new(); for (path, src) in entries { let ast = parse_file(src); aliases.insert(path.to_string(), gather_alias_map(&ast)); + local_symbols.insert(path.to_string(), collect_local_symbols_scoped(&ast)); parsed.push((path.to_string(), ast)); } - WsFixture { parsed, aliases } + WsFixture { + parsed, + aliases, + local_symbols, + } } fn borrowed(f: &WsFixture) -> Vec<(&str, &syn::File)> { @@ -55,13 +65,14 @@ fn crate_roots(paths: &[&str]) -> HashSet { #[test] fn test_empty_workspace_produces_empty_index() { let fix = fixture(&[]); - let index = build_workspace_type_index( - &borrowed(&fix), - &fix.aliases, - &HashSet::new(), - &HashSet::new(), - &HashSet::new(), - ); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &HashSet::new(), + crate_root_modules: &HashSet::new(), + transparent_wrappers: &HashSet::new(), + }); assert!(index.struct_fields.is_empty()); assert!(index.method_returns.is_empty()); assert!(index.fn_returns.is_empty()); @@ -81,13 +92,14 @@ fn test_struct_with_named_field_is_indexed() { pub struct Session { pub id: Id } "#, )]); - let index = build_workspace_type_index( - &borrowed(&fix), - &fix.aliases, - &HashSet::new(), - &crate_roots(&["src/app/session.rs"]), - &HashSet::new(), - ); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &HashSet::new(), + crate_root_modules: &crate_roots(&["src/app/session.rs"]), + transparent_wrappers: &HashSet::new(), + }); let field = index.struct_field("crate::app::session::Session", "id"); assert_eq!( field, @@ -104,13 +116,14 @@ fn test_struct_field_with_arc_is_stripped() { pub struct Ctx { pub inner: std::sync::Arc } "#, )]); - let index = build_workspace_type_index( - &borrowed(&fix), - &fix.aliases, - &HashSet::new(), - &crate_roots(&["src/app/context.rs"]), - &HashSet::new(), - ); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &HashSet::new(), + crate_root_modules: &crate_roots(&["src/app/context.rs"]), + transparent_wrappers: &HashSet::new(), + }); let field = index.struct_field("crate::app::context::Ctx", "inner"); assert_eq!( field, @@ -121,13 +134,14 @@ fn test_struct_field_with_arc_is_stripped() { #[test] fn test_tuple_struct_is_not_indexed() { let fix = fixture(&[("src/app/foo.rs", "pub struct Id(pub String);")]); - let index = build_workspace_type_index( - &borrowed(&fix), - &fix.aliases, - &HashSet::new(), - &crate_roots(&["src/app/foo.rs"]), - &HashSet::new(), - ); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &HashSet::new(), + crate_root_modules: &crate_roots(&["src/app/foo.rs"]), + transparent_wrappers: &HashSet::new(), + }); assert!(index.struct_fields.is_empty()); } @@ -139,13 +153,14 @@ fn test_struct_field_with_opaque_type_is_skipped() { pub struct Ctx { pub x: external_crate::Unknown } "#, )]); - let index = build_workspace_type_index( - &borrowed(&fix), - &fix.aliases, - &HashSet::new(), - &crate_roots(&["src/app/foo.rs"]), - &HashSet::new(), - ); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &HashSet::new(), + crate_root_modules: &crate_roots(&["src/app/foo.rs"]), + transparent_wrappers: &HashSet::new(), + }); assert!(index.struct_fields.is_empty()); } @@ -163,13 +178,14 @@ fn test_inherent_method_with_concrete_return() { } "#, )]); - let index = build_workspace_type_index( - &borrowed(&fix), - &fix.aliases, - &HashSet::new(), - &crate_roots(&["src/app/session.rs"]), - &HashSet::new(), - ); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &HashSet::new(), + crate_root_modules: &crate_roots(&["src/app/session.rs"]), + transparent_wrappers: &HashSet::new(), + }); let ret = index.method_return("crate::app::session::Session", "diff"); assert_eq!( ret, @@ -192,13 +208,14 @@ fn test_method_returning_result_wraps() { } "#, )]); - let index = build_workspace_type_index( - &borrowed(&fix), - &fix.aliases, - &HashSet::new(), - &crate_roots(&["src/app/session.rs"]), - &HashSet::new(), - ); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &HashSet::new(), + crate_root_modules: &crate_roots(&["src/app/session.rs"]), + transparent_wrappers: &HashSet::new(), + }); let ret = index .method_return("crate::app::session::Session", "diff") .expect("method indexed"); @@ -220,13 +237,14 @@ fn test_method_with_unit_return_is_not_indexed() { impl S { pub fn bump(&self) {} } "#, )]); - let index = build_workspace_type_index( - &borrowed(&fix), - &fix.aliases, - &HashSet::new(), - &crate_roots(&["src/app/foo.rs"]), - &HashSet::new(), - ); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &HashSet::new(), + crate_root_modules: &crate_roots(&["src/app/foo.rs"]), + transparent_wrappers: &HashSet::new(), + }); assert!(index.method_returns.is_empty()); } @@ -239,13 +257,14 @@ fn test_method_with_impl_trait_return_is_not_indexed() { impl S { pub fn iter(&self) -> impl Iterator { std::iter::empty() } } "#, )]); - let index = build_workspace_type_index( - &borrowed(&fix), - &fix.aliases, - &HashSet::new(), - &crate_roots(&["src/app/foo.rs"]), - &HashSet::new(), - ); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &HashSet::new(), + crate_root_modules: &crate_roots(&["src/app/foo.rs"]), + transparent_wrappers: &HashSet::new(), + }); assert!(index.method_returns.is_empty()); } @@ -260,13 +279,14 @@ fn test_trait_impl_method_is_indexed_by_receiver_type() { impl Convert for S { fn to(&self) -> T { T } } "#, )]); - let index = build_workspace_type_index( - &borrowed(&fix), - &fix.aliases, - &HashSet::new(), - &crate_roots(&["src/app/foo.rs"]), - &HashSet::new(), - ); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &HashSet::new(), + crate_root_modules: &crate_roots(&["src/app/foo.rs"]), + transparent_wrappers: &HashSet::new(), + }); // Keyed by the concrete receiver type S, NOT by the trait. let ret = index.method_return("crate::app::foo::S", "to"); assert_eq!( @@ -286,13 +306,14 @@ fn test_free_fn_return_is_indexed() { pub fn make_session() -> Session { Session } "#, )]); - let index = build_workspace_type_index( - &borrowed(&fix), - &fix.aliases, - &HashSet::new(), - &crate_roots(&["src/app/make.rs"]), - &HashSet::new(), - ); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &HashSet::new(), + crate_root_modules: &crate_roots(&["src/app/make.rs"]), + transparent_wrappers: &HashSet::new(), + }); let ret = index.fn_return("crate::app::make::make_session"); assert_eq!( ret, @@ -308,13 +329,14 @@ fn test_generic_return_type_is_opaque_and_not_indexed() { pub fn get() -> T { unimplemented!() } "#, )]); - let index = build_workspace_type_index( - &borrowed(&fix), - &fix.aliases, - &HashSet::new(), - &crate_roots(&["src/app/make.rs"]), - &HashSet::new(), - ); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &HashSet::new(), + crate_root_modules: &crate_roots(&["src/app/make.rs"]), + transparent_wrappers: &HashSet::new(), + }); // Generic T has no alias/local-symbol entry → Opaque → skipped. assert!(index.fn_returns.is_empty()); } @@ -331,13 +353,14 @@ fn test_fn_inside_inline_mod_keys_include_mod_name() { } "#, )]); - let index = build_workspace_type_index( - &borrowed(&fix), - &fix.aliases, - &HashSet::new(), - &crate_roots(&["src/app/mod.rs"]), - &HashSet::new(), - ); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &HashSet::new(), + crate_root_modules: &crate_roots(&["src/app/mod.rs"]), + transparent_wrappers: &HashSet::new(), + }); // With inline-mod tracking the key is `crate::app::inner::make_session`, // matching how `inner::make_session()` canonicalises at a call site. assert!( @@ -349,6 +372,38 @@ fn test_fn_inside_inline_mod_keys_include_mod_name() { assert!(index.fn_return("crate::app::make_session").is_none()); } +#[test] +fn test_fn_inside_inline_mod_resolves_inner_return_type() { + let fix = fixture(&[( + "src/app/mod.rs", + r#" + pub mod inner { + pub struct Session; + pub fn make() -> Session { Session } + } + "#, + )]); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &HashSet::new(), + crate_root_modules: &crate_roots(&["src/app/mod.rs"]), + transparent_wrappers: &HashSet::new(), + }); + // Pre-fix: `Session` was looked up against the file's top-level + // local symbols (which only contained `inner`), so the return type + // resolved to `Opaque` and `make` was dropped from the index. + // With per-mod-scope resolution `Session` is found at scope `[inner]` + // and the return canonical is `crate::app::inner::Session`. + assert_eq!( + index.fn_return("crate::app::inner::make"), + Some(&CanonicalType::path([ + "crate", "app", "inner", "Session" + ])) + ); +} + #[test] fn test_struct_field_inside_inline_mod_keys_include_mod_name() { let fix = fixture(&[( @@ -361,13 +416,14 @@ fn test_struct_field_inside_inline_mod_keys_include_mod_name() { } "#, )]); - let index = build_workspace_type_index( - &borrowed(&fix), - &fix.aliases, - &HashSet::new(), - &crate_roots(&["src/app/mod.rs"]), - &HashSet::new(), - ); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &HashSet::new(), + crate_root_modules: &crate_roots(&["src/app/mod.rs"]), + transparent_wrappers: &HashSet::new(), + }); assert!( index .struct_field("crate::app::inner::Ctx", "session") @@ -380,13 +436,14 @@ fn test_struct_field_inside_inline_mod_keys_include_mod_name() { #[test] fn test_fn_with_unit_return_is_not_indexed() { let fix = fixture(&[("src/app/foo.rs", "pub fn bump() {}")]); - let index = build_workspace_type_index( - &borrowed(&fix), - &fix.aliases, - &HashSet::new(), - &crate_roots(&["src/app/foo.rs"]), - &HashSet::new(), - ); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &HashSet::new(), + crate_root_modules: &crate_roots(&["src/app/foo.rs"]), + transparent_wrappers: &HashSet::new(), + }); assert!(index.fn_returns.is_empty()); } @@ -404,13 +461,14 @@ fn test_cfg_test_file_is_skipped() { )]); let mut cfg_test = HashSet::new(); cfg_test.insert("src/app/foo.rs".to_string()); - let index = build_workspace_type_index( - &borrowed(&fix), - &fix.aliases, - &cfg_test, - &crate_roots(&["src/app/foo.rs"]), - &HashSet::new(), - ); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &cfg_test, + crate_root_modules: &crate_roots(&["src/app/foo.rs"]), + transparent_wrappers: &HashSet::new(), + }); assert!(index.struct_fields.is_empty()); assert!(index.method_returns.is_empty()); assert!(index.fn_returns.is_empty()); @@ -431,13 +489,14 @@ fn test_trait_declaration_methods_are_indexed() { } "#, )]); - let index = build_workspace_type_index( - &borrowed(&fix), - &fix.aliases, - &HashSet::new(), - &crate_roots(&["src/app/ports.rs"]), - &HashSet::new(), - ); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &HashSet::new(), + crate_root_modules: &crate_roots(&["src/app/ports.rs"]), + transparent_wrappers: &HashSet::new(), + }); assert!(index.trait_has_method("crate::app::ports::Handler", "handle")); assert!(index.trait_has_method("crate::app::ports::Handler", "can_handle")); assert!(!index.trait_has_method("crate::app::ports::Handler", "missing")); @@ -453,13 +512,14 @@ fn test_trait_impl_is_indexed() { impl Handler for MyImpl { fn handle(&self) {} } "#, )]); - let index = build_workspace_type_index( - &borrowed(&fix), - &fix.aliases, - &HashSet::new(), - &crate_roots(&["src/app/foo.rs"]), - &HashSet::new(), - ); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &HashSet::new(), + crate_root_modules: &crate_roots(&["src/app/foo.rs"]), + transparent_wrappers: &HashSet::new(), + }); let impls = index.impls_of_trait("crate::app::foo::Handler"); assert!(impls.contains(&"crate::app::foo::MyImpl".to_string())); } @@ -478,13 +538,14 @@ fn test_multiple_impls_of_same_trait_all_indexed() { impl Handler for C { fn handle(&self) {} } "#, )]); - let index = build_workspace_type_index( - &borrowed(&fix), - &fix.aliases, - &HashSet::new(), - &crate_roots(&["src/app/foo.rs"]), - &HashSet::new(), - ); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &HashSet::new(), + crate_root_modules: &crate_roots(&["src/app/foo.rs"]), + transparent_wrappers: &HashSet::new(), + }); let impls = index.impls_of_trait("crate::app::foo::Handler"); assert_eq!(impls.len(), 3); } @@ -498,13 +559,14 @@ fn test_inherent_impl_does_not_populate_trait_impls() { impl S { pub fn method(&self) {} } "#, )]); - let index = build_workspace_type_index( - &borrowed(&fix), - &fix.aliases, - &HashSet::new(), - &crate_roots(&["src/app/foo.rs"]), - &HashSet::new(), - ); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &HashSet::new(), + crate_root_modules: &crate_roots(&["src/app/foo.rs"]), + transparent_wrappers: &HashSet::new(), + }); // Inherent impl has no trait reference, so trait_impls stays empty. assert!(index.trait_impls.is_empty()); } @@ -525,13 +587,14 @@ fn test_trait_in_one_file_impl_in_another() { "#, ), ]); - let index = build_workspace_type_index( - &borrowed(&fix), - &fix.aliases, - &HashSet::new(), - &crate_roots(&["src/ports/handler.rs", "src/app/session.rs"]), - &HashSet::new(), - ); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &HashSet::new(), + crate_root_modules: &crate_roots(&["src/ports/handler.rs", "src/app/session.rs"]), + transparent_wrappers: &HashSet::new(), + }); // Trait resolved via import alias. let impls = index.impls_of_trait("crate::ports::handler::Handler"); assert!(impls.contains(&"crate::app::session::Session".to_string())); @@ -557,13 +620,14 @@ fn test_struct_in_one_file_impl_in_another() { "#, ), ]); - let index = build_workspace_type_index( - &borrowed(&fix), - &fix.aliases, - &HashSet::new(), - &crate_roots(&["src/app/session.rs", "src/app/impls.rs"]), - &HashSet::new(), - ); + let index = build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed(&fix), + aliases_per_file: &fix.aliases, + local_symbols_per_file: &fix.local_symbols, + cfg_test_files: &HashSet::new(), + crate_root_modules: &crate_roots(&["src/app/session.rs", "src/app/impls.rs"]), + transparent_wrappers: &HashSet::new(), + }); // Struct indexed from its declaration file. assert!(index .struct_field("crate::app::session::Session", "id") diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs index 67f5ed9..db048b2 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs @@ -72,7 +72,7 @@ fn record_struct( return; }; for field in &named.named { - record_field(index, &canonical, ctx, field); + record_field(index, &canonical, ctx, mod_stack, field); } } @@ -82,9 +82,10 @@ fn record_field( index: &mut WorkspaceTypeIndex, canonical: &str, ctx: &BuildContext<'_>, + mod_stack: &[String], field: &syn::Field, ) { - let resolve = |ty: &syn::Type| resolve_type(ty, &resolve_ctx_from_build(ctx)); + let resolve = |ty: &syn::Type| resolve_type(ty, &resolve_ctx_from_build(ctx, mod_stack)); let Some(ident) = field.ident.as_ref() else { return; }; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs index ef43caf..f804201 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs @@ -64,7 +64,7 @@ fn record_fn( mod_stack: &[String], node: &syn::ItemFn, ) { - let resolve = |ty: &syn::Type| resolve_type(ty, &resolve_ctx_from_build(ctx)); + let resolve = |ty: &syn::Type| resolve_type(ty, &resolve_ctx_from_build(ctx, mod_stack)); let syn::ReturnType::Type(_, ret_ty) = &node.sig.output else { return; }; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs index ce85167..271e439 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs @@ -99,7 +99,7 @@ fn record_method( mod_stack: &[String], node: &syn::ImplItemFn, ) { - let resolve = |ty: &syn::Type| resolve_type(ty, &resolve_ctx_from_build(ctx)); + let resolve = |ty: &syn::Type| resolve_type(ty, &resolve_ctx_from_build(ctx, mod_stack)); let canon = |segs: &[String]| canonical_type_key(segs, ctx, mod_stack); let Some(Some(impl_segs)) = impl_stack.last() else { return; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs index ad4f219..af94820 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs @@ -16,7 +16,9 @@ pub mod methods; pub mod traits; use super::canonical::CanonicalType; -use crate::adapters::analyzers::architecture::call_parity_rule::workspace_graph::collect_local_symbols; +use crate::adapters::analyzers::architecture::call_parity_rule::workspace_graph::{ + collect_local_symbols_scoped, LocalSymbols, +}; use crate::adapters::analyzers::architecture::forbidden_rule::file_to_module_segments; use std::collections::{HashMap, HashSet}; @@ -26,11 +28,16 @@ pub(super) struct BuildContext<'a> { pub path: &'a str, pub alias_map: &'a HashMap>, pub local_symbols: &'a HashSet, + /// Per-name list of mod-paths-within-file where `local_symbols` + /// names are declared. Lets the resolver pick + /// `crate::::::Session` over `crate::::Session` + /// when `Session` is declared inside an inline mod. + pub local_decl_scopes: &'a HashMap>>, pub crate_root_modules: &'a HashSet, - /// Stage 3 — user-wrapper names peeled during resolution. Shared + /// user-wrapper names peeled during resolution. Shared /// across the whole build. pub transparent_wrappers: &'a HashSet, - /// Stage 3 — type aliases already collected across the workspace. + /// type aliases already collected across the workspace. /// `None` in pass 1 (the alias collector itself); `Some(&…)` in /// pass 2 so fields/methods/functions/traits that reference an /// alias are resolved through the alias target instead of caching @@ -64,10 +71,13 @@ pub(super) fn canonical_type_key( /// extracted so the per-field / per-method / per-free-fn collectors /// don't each repeat the same construction. `type_aliases` propagates /// through so pass-2 collectors (running after the alias-collector -/// populated them) resolve aliased types transparently. +/// populated them) resolve aliased types transparently. `mod_stack` is +/// the current mod-path inside `ctx.path` — pass `&[]` for top-level +/// items. /// Operation. pub(super) fn resolve_ctx_from_build<'a>( ctx: &'a BuildContext<'a>, + mod_stack: &'a [String], ) -> super::resolve::ResolveContext<'a> { super::resolve::ResolveContext { alias_map: ctx.alias_map, @@ -76,6 +86,8 @@ pub(super) fn resolve_ctx_from_build<'a>( importing_file: ctx.path, type_aliases: ctx.type_aliases, transparent_wrappers: Some(ctx.transparent_wrappers), + local_decl_scopes: Some(ctx.local_decl_scopes), + mod_stack, } } @@ -88,20 +100,20 @@ pub struct WorkspaceTypeIndex { pub method_returns: HashMap<(String, String), CanonicalType>, /// `canonical_free_fn_name → canonical return type`. pub fn_returns: HashMap, - /// Stage 2 — `trait_canonical → [impl_type_canonical, …]`. Every + /// `trait_canonical → [impl_type_canonical, …]`. Every /// `impl Trait for X` in the workspace contributes one entry so /// trait-dispatch can over-approximate edges to every impl. pub trait_impls: HashMap>, - /// Stage 2 — `trait_canonical → {method_name, …}`. Gates + /// `trait_canonical → {method_name, …}`. Gates /// trait-dispatch so `dyn Trait.unrelated_method()` stays /// unresolved. pub trait_methods: HashMap>, - /// Stage 3 — `alias_canonical → (generic_param_names, target)`. + /// `alias_canonical → (generic_param_names, target)`. /// Params are captured so use-sites like `Alias` can /// substitute the params' idents in `target` before resolution. /// Aliases without generics just have an empty `Vec`. pub type_aliases: HashMap, syn::Type)>, - /// Stage 3 — user-configured last-ident names to treat as + /// user-configured last-ident names to treat as /// transparent single-type-param wrappers (framework extractors /// like `State` / `Data`). Mirrored from the /// `CompiledCallParity.transparent_wrappers` at build time. @@ -153,11 +165,24 @@ impl WorkspaceTypeIndex { } } +// qual:api +/// Bundled input for `build_workspace_type_index`. Bundles per-file +/// pre-computed maps + the workspace-wide flag set so the entry-point +/// signature stays under the SRP param count. +pub struct WorkspaceIndexInputs<'a> { + pub files: &'a [(&'a str, &'a syn::File)], + pub aliases_per_file: &'a HashMap>>, + pub local_symbols_per_file: &'a HashMap, + pub cfg_test_files: &'a HashSet, + pub crate_root_modules: &'a HashSet, + pub transparent_wrappers: &'a HashSet, +} + // qual:api /// Build the workspace type index from parsed files + their pre-computed -/// alias maps. Skips cfg-test files wholesale. `transparent_wrappers` -/// seeds the user-configured Stage-3 wrapper list onto the index so -/// downstream inference peels them just like `Arc` / `Box`. +/// alias maps and `LocalSymbols`. Skips cfg-test files wholesale. +/// `transparent_wrappers` seeds the user-configured wrapper list onto +/// the index so downstream inference peels them just like `Arc` / `Box`. /// /// Runs in two passes: first collects type aliases across every file, /// then collects fields/methods/functions/traits with the alias map @@ -165,20 +190,17 @@ impl WorkspaceTypeIndex { /// resolve through to their targets instead of caching the raw alias /// path. Integration. pub fn build_workspace_type_index( - files: &[(&str, &syn::File)], - aliases_per_file: &HashMap>>, - cfg_test_files: &HashSet, - crate_root_modules: &HashSet, - transparent_wrappers: &HashSet, + inputs: &WorkspaceIndexInputs<'_>, ) -> WorkspaceTypeIndex { let mut index = WorkspaceTypeIndex::new(); - index.transparent_wrappers = transparent_wrappers.clone(); + index.transparent_wrappers = inputs.transparent_wrappers.clone(); let shared = |type_aliases| WalkInputs { - files, - aliases_per_file, - cfg_test_files, - crate_root_modules, - transparent_wrappers, + files: inputs.files, + aliases_per_file: inputs.aliases_per_file, + local_symbols_per_file: inputs.local_symbols_per_file, + cfg_test_files: inputs.cfg_test_files, + crate_root_modules: inputs.crate_root_modules, + transparent_wrappers: inputs.transparent_wrappers, type_aliases, }; // Pass 1: aliases across all files (no alias map yet). @@ -208,6 +230,7 @@ pub fn build_workspace_type_index( struct WalkInputs<'a> { files: &'a [(&'a str, &'a syn::File)], aliases_per_file: &'a HashMap>>, + local_symbols_per_file: &'a HashMap, cfg_test_files: &'a HashSet, crate_root_modules: &'a HashSet, transparent_wrappers: &'a HashSet, @@ -228,11 +251,14 @@ where let Some(alias_map) = inputs.aliases_per_file.get(*path) else { continue; }; - let local_symbols = collect_local_symbols(ast); + let Some(local) = inputs.local_symbols_per_file.get(*path) else { + continue; + }; let ctx = BuildContext { path, alias_map, - local_symbols: &local_symbols, + local_symbols: &local.flat, + local_decl_scopes: &local.by_name, crate_root_modules: inputs.crate_root_modules, transparent_wrappers: inputs.transparent_wrappers, type_aliases: inputs.type_aliases, diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs index a8aadbd..46a3ea6 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs @@ -11,7 +11,9 @@ //! over-approximates by recording an edge to every impl's method. use super::{canonical_type_key, BuildContext, WorkspaceTypeIndex}; -use crate::adapters::analyzers::architecture::call_parity_rule::bindings::canonicalise_type_segments; +use crate::adapters::analyzers::architecture::call_parity_rule::bindings::{ + canonicalise_type_segments_in_scope, CanonScope, +}; use crate::adapters::analyzers::architecture::call_parity_rule::workspace_graph::resolve_impl_self_type; use crate::adapters::analyzers::architecture::forbidden_rule::file_to_module_segments; use crate::adapters::shared::cfg_test::has_cfg_test; @@ -98,7 +100,7 @@ fn record_trait_impl( let Some((_, trait_path, _)) = &node.trait_ else { return; }; - let trait_canonical = resolve_trait_path(trait_path, ctx); + let trait_canonical = resolve_trait_path(trait_path, ctx, mod_stack); let Some(trait_canonical) = trait_canonical else { return; }; @@ -121,16 +123,26 @@ fn record_trait_impl( } /// Resolve a trait path (the `T` in `impl T for X`) to its canonical -/// crate-rooted form via the shared canonicalisation pipeline. +/// crate-rooted form via the shared canonicalisation pipeline. Mod +/// scope is honoured so a single-ident trait declared inside an inline +/// mod resolves to `crate::::::Trait`. /// Operation: flatten + delegate. -fn resolve_trait_path(path: &syn::Path, ctx: &BuildContext<'_>) -> Option { +fn resolve_trait_path( + path: &syn::Path, + ctx: &BuildContext<'_>, + mod_stack: &[String], +) -> Option { let segs: Vec = path.segments.iter().map(|s| s.ident.to_string()).collect(); - let resolved = canonicalise_type_segments( + let resolved = canonicalise_type_segments_in_scope( &segs, - ctx.alias_map, - ctx.local_symbols, - ctx.crate_root_modules, - ctx.path, + &CanonScope { + alias_map: ctx.alias_map, + local_symbols: ctx.local_symbols, + crate_root_modules: ctx.crate_root_modules, + importing_file: ctx.path, + local_decl_scopes: Some(ctx.local_decl_scopes), + mod_stack, + }, )?; Some(resolved.join("::")) } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs b/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs index b5e2a83..af2558c 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs @@ -22,7 +22,7 @@ use super::bindings::canonicalise_type_segments; use super::calls::{collect_canonical_calls, FnContext}; use super::signature_params::extract_signature_params; -use super::type_infer::{build_workspace_type_index, WorkspaceTypeIndex}; +use super::type_infer::{build_workspace_type_index, WorkspaceIndexInputs, WorkspaceTypeIndex}; use crate::adapters::analyzers::architecture::forbidden_rule::file_to_module_segments; use crate::adapters::analyzers::architecture::layer_rule::LayerDefinitions; use crate::adapters::shared::cfg_test::has_cfg_test; @@ -155,28 +155,9 @@ fn crate_root_module_of(path: &str) -> Option { Some(name.to_string()) } -/// Collect the names of top-level items declared in this file — fns, -/// mods, types, consts, statics. The call collector uses this set to -/// resolve unqualified calls like `helper()` (without a `use` -/// statement) into `crate::::helper`, instead of bare-name -/// dead-ends that disconnect local delegation chains. -pub(crate) fn collect_local_symbols(ast: &syn::File) -> HashSet { - ast.items - .iter() - .filter_map(|item| match item { - syn::Item::Fn(f) => Some(f.sig.ident.to_string()), - syn::Item::Mod(m) => Some(m.ident.to_string()), - syn::Item::Struct(s) => Some(s.ident.to_string()), - syn::Item::Enum(e) => Some(e.ident.to_string()), - syn::Item::Union(u) => Some(u.ident.to_string()), - syn::Item::Trait(t) => Some(t.ident.to_string()), - syn::Item::Type(t) => Some(t.ident.to_string()), - syn::Item::Const(c) => Some(c.ident.to_string()), - syn::Item::Static(s) => Some(s.ident.to_string()), - _ => None, - }) - .collect() -} +pub(crate) use super::local_symbols::{ + collect_local_symbols, collect_local_symbols_scoped, LocalSymbols, +}; /// Canonicalise an impl block's self-type through the same alias / /// local-symbol / crate-root pipeline the call collector uses for type @@ -274,15 +255,22 @@ pub(crate) fn build_call_graph<'ast>( transparent_wrappers: &HashSet, ) -> CallGraph { let crate_root_modules = collect_crate_root_modules(files); - // Pre-build the workspace type index so `collect_canonical_calls` - // can run shallow inference on complex method-call receivers. - let type_index = build_workspace_type_index( + // Pre-compute `LocalSymbols` per file once and reuse across the + // type-index passes + the graph collector. Without this, every file + // would walk its AST three times just to rebuild the same maps. + let local_symbols_per_file: HashMap = files + .iter() + .filter(|(p, _)| !cfg_test_files.contains(*p)) + .map(|(path, ast)| (path.to_string(), collect_local_symbols_scoped(ast))) + .collect(); + let type_index = build_workspace_type_index(&WorkspaceIndexInputs { files, aliases_per_file, + local_symbols_per_file: &local_symbols_per_file, cfg_test_files, - &crate_root_modules, + crate_root_modules: &crate_root_modules, transparent_wrappers, - ); + }); let mut graph = CallGraph::new(); for (path, ast) in files { if cfg_test_files.contains(*path) { @@ -291,11 +279,14 @@ pub(crate) fn build_call_graph<'ast>( let Some(alias_map) = aliases_per_file.get(*path) else { continue; }; - let local_symbols = collect_local_symbols(ast); + let Some(local) = local_symbols_per_file.get(*path) else { + continue; + }; let mut collector = FileFnCollector { path, alias_map, - local_symbols: &local_symbols, + local_symbols: &local.flat, + local_decl_scopes: &local.by_name, crate_root_modules: &crate_root_modules, type_index: &type_index, impl_type_stack: Vec::new(), @@ -327,6 +318,11 @@ struct FileFnCollector<'a> { path: &'a str, alias_map: &'a HashMap>, local_symbols: &'a HashSet, + /// Per-name list of mod-paths-within-file where the name is + /// declared. Threaded through `FnContext` so the call collector + /// resolves `Self::xxx` and unqualified local calls inside inline + /// mods to the correct `crate::::::…` canonical. + local_decl_scopes: &'a HashMap>>, crate_root_modules: &'a HashSet, type_index: &'a WorkspaceTypeIndex, /// `None` marks an unresolved self-type (trait object, `&T`, tuple) @@ -366,6 +362,8 @@ impl<'a> FileFnCollector<'a> { crate_root_modules: self.crate_root_modules, importing_file: self.path, workspace_index: Some(self.type_index), + mod_stack: &self.mod_stack, + local_decl_scopes: Some(self.local_decl_scopes), }; let calls = collect_canonical_calls(&ctx); self.graph.add_node(&canonical); diff --git a/src/adapters/shared/cfg_test_files.rs b/src/adapters/shared/cfg_test_files.rs index 2cf2522..a8c8c72 100644 --- a/src/adapters/shared/cfg_test_files.rs +++ b/src/adapters/shared/cfg_test_files.rs @@ -145,12 +145,10 @@ impl<'a> ChildPathResolver<'a> { } } -/// Extract the string value of a `#[path = "..."]` attribute if present. -/// Operation: attribute lookup + literal parsing, no own calls. /// Convert OS-native path separators into the forward-slash form used /// by `known_paths`. On Unix this is the identity (no allocation); on /// Windows we only scan+replace when a backslash is actually present. -/// Operation. +/// Operation: pure string normalization. #[cfg(windows)] fn normalize_sep(path: &str) -> String { if path.contains('\\') { @@ -165,6 +163,8 @@ fn normalize_sep(path: &str) -> String { path.to_string() } +/// Extract the string value of a `#[path = "..."]` attribute if present. +/// Operation: attribute lookup + literal parsing, no own calls. fn path_attribute(attrs: &[syn::Attribute]) -> Option { attrs.iter().find_map(|attr| { if !attr.path().is_ident("path") { From ca063631e8e5001874426cff48c2bd1c91faf5be Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sat, 25 Apr 2026 12:53:56 +0200 Subject: [PATCH 10/30] fix: copilot comments --- CHANGELOG.md | 7 +++ README.md | 6 +-- .../architecture/call_parity_rule/bindings.rs | 20 +++----- .../architecture/call_parity_rule/calls.rs | 14 ++--- .../call_parity_rule/local_symbols.rs | 12 ++--- .../architecture/call_parity_rule/pub_fns.rs | 29 +++++++---- .../type_infer/infer/access.rs | 4 +- .../call_parity_rule/type_infer/infer/call.rs | 18 ++++--- .../type_infer/infer/generics.rs | 4 +- .../call_parity_rule/type_infer/infer/mod.rs | 8 +++ .../type_infer/patterns/destructure.rs | 4 +- .../type_infer/tests/combinators.rs | 2 + .../type_infer/tests/support.rs | 2 + .../type_infer/tests/workspace_index.rs | 4 +- .../type_infer/workspace_index/methods.rs | 13 +++-- .../type_infer/workspace_index/mod.rs | 4 +- .../type_infer/workspace_index/traits.rs | 12 +++-- .../call_parity_rule/workspace_graph.rs | 30 ++++------- .../analyzers/architecture/forbidden_rule.rs | 20 +++++--- src/adapters/shared/cfg_test_files.rs | 51 +++++++------------ 20 files changed, 141 insertions(+), 123 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8398e3..dfeb78c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -166,6 +166,13 @@ additive and the legacy fast-path stays intact as a safety net. ### Known Limits Patterns that intentionally stay unresolved and produce `:name` fallback markers rather than fabricate edges: +- Imports inside inline mods (`mod inner { use super::Foo; … }`) are + not separated from the file's top-level alias map. A top-level + `use crate::outer::Session;` is visible to nested mods (correct), but + a `use super::X;` inside `mod inner` isn't given a distinct + scope — alias-map lookups from outside `inner` may see it. In practice + this rarely causes false positives; the same-name-shadowing case can + be worked around by importing at the file-top level. - `Session::open().map(|r| r.m())` — closure-body argument type is unknown. Inner method call stays `:m`. - `fn get() -> T { … }; let x = get(); x.m()` without annotation diff --git a/README.md b/README.md index ff33450..dadbc88 100644 --- a/README.md +++ b/README.md @@ -902,9 +902,9 @@ For framework codebases you can extend the wrapper and macro lists: [architecture.call_parity] # Framework extractor wrappers peeled like Arc / Box: transparent_wrappers = ["State", "Extension", "Json", "Data"] -# Attribute macros that don't affect the call graph (starter pack -# — tracing::instrument, async_trait, tokio::main/test, rstest, -# test_case, pyo3, wasm_bindgen — already applied by default): +# Attribute macros that don't affect the call graph. The set is +# recorded for future macro-expansion integrations and currently has +# no observable effect on the call-graph / type-inference pipeline. transparent_macros = ["my_custom_attr"] ``` diff --git a/src/adapters/analyzers/architecture/call_parity_rule/bindings.rs b/src/adapters/analyzers/architecture/call_parity_rule/bindings.rs index d730fc4..6f71309 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/bindings.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/bindings.rs @@ -9,7 +9,7 @@ use super::local_symbols::scope_for_local; use crate::adapters::analyzers::architecture::forbidden_rule::{ - file_to_module_segments, resolve_to_crate_absolute, + file_to_module_segments, resolve_to_crate_absolute, resolve_to_crate_absolute_in, }; use std::collections::{HashMap, HashSet}; @@ -75,19 +75,15 @@ fn strip_wrappers(ty: &syn::Type) -> &syn::Type { } } -/// Bundled inputs for canonical-type-path resolution. Same-file fallback -/// walks `mod_stack` outward against `local_decl_scopes` to pick the -/// closest enclosing declaration of a single-ident type name. -pub(super) struct CanonScope<'a> { +/// Bundled inputs for canonical-type-path resolution. The same-file +/// fallback walks `mod_stack` outward against `local_decl_scopes` to +/// pick the closest enclosing declaration of a single-ident name. +pub(crate) struct CanonScope<'a> { pub alias_map: &'a HashMap>, pub local_symbols: &'a HashSet, pub crate_root_modules: &'a HashSet, pub importing_file: &'a str, - /// `name → list of mod-paths-within-file where this name is - /// declared`. `None` means flat top-level prepend (legacy path). pub local_decl_scopes: Option<&'a HashMap>>>, - /// Mod-path of the calling site within `importing_file`. Empty for - /// top-level callers or legacy code paths. pub mod_stack: &'a [String], } @@ -121,7 +117,7 @@ pub(super) fn canonicalise_type_segments( /// `scope.local_decl_scopes` are populated). Returns `None` for /// unresolvable paths (external crates, unknown idents). /// Operation: closure-hidden own calls keep IOSP clean. -pub(super) fn canonicalise_type_segments_in_scope( +pub(crate) fn canonicalise_type_segments_in_scope( segments: &[String], scope: &CanonScope<'_>, ) -> Option> { @@ -131,7 +127,8 @@ pub(super) fn canonicalise_type_segments_in_scope( let normalize = |full| normalize_after_alias(full, scope.importing_file, scope.crate_root_modules); if matches!(segments[0].as_str(), "crate" | "self" | "super") { - let resolved = resolve_to_crate_absolute(scope.importing_file, segments)?; + let resolved = + resolve_to_crate_absolute_in(scope.importing_file, scope.mod_stack, segments)?; let mut full = vec!["crate".to_string()]; full.extend(resolved); return Some(full); @@ -157,7 +154,6 @@ pub(super) fn canonicalise_type_segments_in_scope( None } - /// After alias-map substitution, re-run `self` / `super` normalisation /// and prepend `crate` for Rust 2018+ absolute imports that resolve /// into a known workspace root module (`use app::foo;` → diff --git a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs index 59ebc50..8963e5f 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs @@ -24,7 +24,7 @@ use super::type_infer::{ WorkspaceTypeIndex, }; use crate::adapters::analyzers::architecture::forbidden_rule::{ - file_to_module_segments, resolve_to_crate_absolute, + file_to_module_segments, resolve_to_crate_absolute_in, }; use std::collections::{HashMap, HashSet}; use syn::visit::Visit; @@ -312,11 +312,10 @@ impl<'a> CanonicalCallCollector<'a> { bare(&segments.join("::")) } - /// `crate` / `self` / `super` — resolve through the file-relative - /// module path. Falls back to `:` if `resolve_to_crate_absolute` - /// can't make sense of the path. Operation. fn canonicalise_keyword_path(&self, segments: &[String]) -> String { - if let Some(resolved) = resolve_to_crate_absolute(self.importing_file, segments) { + if let Some(resolved) = + resolve_to_crate_absolute_in(self.importing_file, self.mod_stack, segments) + { let mut full = vec!["crate".to_string()]; full.extend(resolved); return full.join("::"); @@ -431,6 +430,8 @@ impl<'a> CanonicalCallCollector<'a> { importing_file: self.importing_file, bindings: &adapter, self_type: self.self_type_canonical.clone(), + mod_stack: self.mod_stack, + local_decl_scopes: self.local_decl_scopes, }; infer_type(expr, &ctx) } @@ -557,6 +558,8 @@ impl<'a> CanonicalCallCollector<'a> { importing_file: self.importing_file, bindings: &adapter, self_type: self.self_type_canonical.clone(), + mod_stack: self.mod_stack, + local_decl_scopes: self.local_decl_scopes, }; match kind { PatKind::Value => extract_bindings(pat, matched, &ictx), @@ -781,7 +784,6 @@ fn method_unknown(method: &str) -> String { format!("{METHOD_UNKNOWN_PREFIX}{method}") } - // The Visit impl uses an independent `'ast` lifetime so the same // collector can walk both the main fn body (long-lived) and macro // bodies we parse on-the-fly (locally-owned, short-lived). The struct's diff --git a/src/adapters/analyzers/architecture/call_parity_rule/local_symbols.rs b/src/adapters/analyzers/architecture/call_parity_rule/local_symbols.rs index 0459110..d01005e 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/local_symbols.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/local_symbols.rs @@ -43,17 +43,13 @@ pub(crate) fn collect_local_symbols_scoped(ast: &syn::File) -> LocalSymbols { /// Operation. Own calls hidden in closure for IOSP leniency. // qual:recursive fn walk_local_symbols(items: &[syn::Item], mod_stack: &mut Vec, out: &mut LocalSymbols) { - let recurse = - |inner: &[syn::Item], stack: &mut Vec, out: &mut LocalSymbols| { - walk_local_symbols(inner, stack, out); - }; + let recurse = |inner: &[syn::Item], stack: &mut Vec, out: &mut LocalSymbols| { + walk_local_symbols(inner, stack, out); + }; for item in items { if let Some(name) = item_name(item) { out.flat.insert(name.clone()); - out.by_name - .entry(name) - .or_default() - .push(mod_stack.clone()); + out.by_name.entry(name).or_default().push(mod_stack.clone()); } if let syn::Item::Mod(m) = item { if !has_cfg_test(&m.attrs) { diff --git a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs index 255c44b..0d5061e 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs @@ -17,10 +17,11 @@ //! //! See Task 2 in the v1.1.0 plan for the full test list. +use super::bindings::CanonScope; +use super::local_symbols::{collect_local_symbols_scoped, LocalSymbols}; use super::signature_params::extract_signature_params; use super::workspace_graph::{ - collect_crate_root_modules, collect_local_symbols, impl_self_ty_segments, - resolve_impl_self_type, + collect_crate_root_modules, impl_self_ty_segments, resolve_impl_self_type, }; use crate::adapters::analyzers::architecture::layer_rule::LayerDefinitions; use crate::adapters::shared::cfg_test::{has_cfg_test, has_test_attr}; @@ -77,13 +78,14 @@ pub(crate) fn collect_pub_fns_by_layer<'ast>( // impl self-types via `use` anyway, and the local-symbol / // crate-root fallbacks still work. let alias_map = aliases_per_file.get(*path).unwrap_or(&empty_aliases); - let local_symbols = collect_local_symbols(ast); + let LocalSymbols { flat, by_name } = collect_local_symbols_scoped(ast); let mut collector = PubFnCollector { file: path.to_string(), found: Vec::new(), visible_types: &visible_types, alias_map, - local_symbols: &local_symbols, + local_symbols: &flat, + local_decl_scopes: &by_name, crate_root_modules: &crate_root_modules, impl_stack: Vec::new(), mod_stack: Vec::new(), @@ -174,8 +176,13 @@ struct PubFnCollector<'ast, 'vis> { /// `use crate::app::Session; impl Session { ... }` and the call /// collector's receiver-tracked canonical agree on the same path. alias_map: &'vis HashMap>, - /// Same-file top-level item names for the local-symbol fallback. + /// Same-file (top-level + nested) item names for the local-symbol + /// fallback in `canonicalise_type_segments_in_scope`. local_symbols: &'vis HashSet, + /// Per-name list of declaring mod-paths within `file`. Lets the + /// resolver pick `crate::::::Session` over the flat + /// top-level form when the type lives inside an inline mod. + local_decl_scopes: &'vis HashMap>>, /// Workspace crate-root module names for Rust 2018+ absolute imports. crate_root_modules: &'vis HashSet, /// Stack of enclosing `impl` blocks: `(self-type segments, is-visible)`. @@ -260,10 +267,14 @@ impl<'ast, 'vis> Visit<'ast> for PubFnCollector<'ast, 'vis> { // never read under that flag. let canonical_segs = resolve_impl_self_type( &node.self_ty, - self.alias_map, - self.local_symbols, - self.crate_root_modules, - &self.file, + &CanonScope { + alias_map: self.alias_map, + local_symbols: self.local_symbols, + crate_root_modules: self.crate_root_modules, + importing_file: &self.file, + local_decl_scopes: Some(self.local_decl_scopes), + mod_stack: &self.mod_stack, + }, ) .unwrap_or_default(); self.impl_stack.push((canonical_segs, visible)); diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/access.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/access.rs index 03dfc7a..aad47ad 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/access.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/access.rs @@ -64,8 +64,8 @@ pub(super) fn infer_cast(c: &syn::ExprCast, ctx: &InferContext<'_>) -> Option) -> Option { /// if we're currently inferring inside an impl body. `None` for /// free-fn contexts. Used to resolve `Self::method(...)` calls. pub self_type: Option>, + /// Mod-path of the call site inside `importing_file`. Empty for + /// top-level inference; populated by the call collector so + /// `inner::make()` from within `mod inner` produces the same + /// `crate::file::inner::make` key the index stores. + pub mod_stack: &'a [String], + /// Per-name list of declaring mod-paths within `importing_file`. + /// `None` for legacy / unit-test callers. + pub local_decl_scopes: Option<&'a HashMap>>>, } // qual:api diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs index 8d3ad8f..38afb6c 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs @@ -93,8 +93,8 @@ fn bind_annotated( importing_file: ctx.importing_file, type_aliases: Some(&ctx.workspace.type_aliases), transparent_wrappers: Some(&ctx.workspace.transparent_wrappers), - local_decl_scopes: None, - mod_stack: &[], + local_decl_scopes: ctx.local_decl_scopes, + mod_stack: ctx.mod_stack, }; resolve_type(ty, &rctx) }; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs index a1c528d..9741fdf 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs @@ -196,6 +196,8 @@ fn test_result_chain_unwrap_then_field() { importing_file: "src/app/test.rs", bindings: &bindings, self_type: None, + mod_stack: &[], + local_decl_scopes: None, }; let expr: syn::Expr = syn::parse_str("res.unwrap().id").expect("parse"); let t = infer_type(&expr, &ctx).expect("chain resolved"); diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/support.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/support.rs index 2b1ed3e..9a69043 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/support.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/support.rs @@ -77,6 +77,8 @@ impl TypeInferFixture { importing_file: &self.file_path, bindings: &self.bindings as &dyn BindingLookup, self_type: self.self_type.clone(), + mod_stack: &[], + local_decl_scopes: None, } } } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs index efc9c97..3758733 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs @@ -398,9 +398,7 @@ fn test_fn_inside_inline_mod_resolves_inner_return_type() { // and the return canonical is `crate::app::inner::Session`. assert_eq!( index.fn_return("crate::app::inner::make"), - Some(&CanonicalType::path([ - "crate", "app", "inner", "Session" - ])) + Some(&CanonicalType::path(["crate", "app", "inner", "Session"])) ); } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs index 271e439..06dd5ec 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs @@ -15,6 +15,7 @@ use super::super::canonical::CanonicalType; use super::super::resolve::resolve_type; use super::{canonical_type_key, resolve_ctx_from_build, BuildContext, WorkspaceTypeIndex}; +use crate::adapters::analyzers::architecture::call_parity_rule::bindings::CanonScope; use crate::adapters::analyzers::architecture::call_parity_rule::workspace_graph::resolve_impl_self_type; use crate::adapters::shared::cfg_test::has_cfg_test; use syn::visit::Visit; @@ -55,10 +56,14 @@ impl<'ast, 'i, 'c> Visit<'ast> for MethodCollector<'i, 'c> { } let resolved = resolve_impl_self_type( &node.self_ty, - self.ctx.alias_map, - self.ctx.local_symbols, - self.ctx.crate_root_modules, - self.ctx.path, + &CanonScope { + alias_map: self.ctx.alias_map, + local_symbols: self.ctx.local_symbols, + crate_root_modules: self.ctx.crate_root_modules, + importing_file: self.ctx.path, + local_decl_scopes: Some(self.ctx.local_decl_scopes), + mod_stack: &self.mod_stack, + }, ); self.impl_stack.push(resolved); syn::visit::visit_item_impl(self, node); diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs index af94820..a173a0e 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs @@ -189,9 +189,7 @@ pub struct WorkspaceIndexInputs<'a> { /// populated so aliased return types (`fn foo() -> AppResult`) /// resolve through to their targets instead of caching the raw alias /// path. Integration. -pub fn build_workspace_type_index( - inputs: &WorkspaceIndexInputs<'_>, -) -> WorkspaceTypeIndex { +pub fn build_workspace_type_index(inputs: &WorkspaceIndexInputs<'_>) -> WorkspaceTypeIndex { let mut index = WorkspaceTypeIndex::new(); index.transparent_wrappers = inputs.transparent_wrappers.clone(); let shared = |type_aliases| WalkInputs { diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs index 46a3ea6..f9edf88 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs @@ -106,10 +106,14 @@ fn record_trait_impl( }; let impl_type_canonical = resolve_impl_self_type( &node.self_ty, - ctx.alias_map, - ctx.local_symbols, - ctx.crate_root_modules, - ctx.path, + &CanonScope { + alias_map: ctx.alias_map, + local_symbols: ctx.local_symbols, + crate_root_modules: ctx.crate_root_modules, + importing_file: ctx.path, + local_decl_scopes: Some(ctx.local_decl_scopes), + mod_stack, + }, ); let Some(impl_segs) = impl_type_canonical else { return; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs b/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs index af2558c..4b1442a 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs @@ -19,7 +19,7 @@ //! file-local helpers — walking only pub fns would under-count delegation //! chains and trigger false positives in Check A. -use super::bindings::canonicalise_type_segments; +use super::bindings::{canonicalise_type_segments_in_scope, CanonScope}; use super::calls::{collect_canonical_calls, FnContext}; use super::signature_params::extract_signature_params; use super::type_infer::{build_workspace_type_index, WorkspaceIndexInputs, WorkspaceTypeIndex}; @@ -172,22 +172,10 @@ pub(crate) use super::local_symbols::{ /// to drop the type segment entirely and collide with free fns. pub(crate) fn resolve_impl_self_type( self_ty: &syn::Type, - alias_map: &HashMap>, - local_symbols: &HashSet, - crate_root_modules: &HashSet, - importing_file: &str, + scope: &CanonScope<'_>, ) -> Option> { let raw = impl_self_ty_segments(self_ty)?; - Some( - canonicalise_type_segments( - &raw, - alias_map, - local_symbols, - crate_root_modules, - importing_file, - ) - .unwrap_or(raw), - ) + Some(canonicalise_type_segments_in_scope(&raw, scope).unwrap_or(raw)) } /// Flatten a `syn::Type::Path` to its segment identifiers — the shape @@ -393,10 +381,14 @@ impl<'a, 'ast> Visit<'ast> for FileFnCollector<'a> { // `record_fn` skips method recording for those impls. let resolved = resolve_impl_self_type( &node.self_ty, - self.alias_map, - self.local_symbols, - self.crate_root_modules, - self.path, + &CanonScope { + alias_map: self.alias_map, + local_symbols: self.local_symbols, + crate_root_modules: self.crate_root_modules, + importing_file: self.path, + local_decl_scopes: Some(self.local_decl_scopes), + mod_stack: &self.mod_stack, + }, ); self.impl_type_stack.push(resolved); syn::visit::visit_item_impl(self, node); diff --git a/src/adapters/analyzers/architecture/forbidden_rule.rs b/src/adapters/analyzers/architecture/forbidden_rule.rs index 9b4a706..be25236 100644 --- a/src/adapters/analyzers/architecture/forbidden_rule.rs +++ b/src/adapters/analyzers/architecture/forbidden_rule.rs @@ -106,21 +106,30 @@ fn evaluate_import( pub(crate) fn resolve_to_crate_absolute( importing_file: &str, segments: &[String], +) -> Option> { + resolve_to_crate_absolute_in(importing_file, &[], segments) +} + +/// Variant that resolves `self::` / `super::` relative to the inline +/// `mod_stack` inside `importing_file`. Pass `&[]` to behave as the +/// file-level resolver. +pub(crate) fn resolve_to_crate_absolute_in( + importing_file: &str, + mod_stack: &[String], + segments: &[String], ) -> Option> { let first = segments.first()?; + let mut base = file_to_module_segments(importing_file); + base.extend_from_slice(mod_stack); let resolved = match first.as_str() { "crate" => segments[1..].to_vec(), "self" => { - let mut base = file_to_module_segments(importing_file); base.extend_from_slice(&segments[1..]); base } "super" => { - let mut base = file_to_module_segments(importing_file); let mut i = 0; while segments.get(i).is_some_and(|s| s == "super") { - // More `super`s than ancestors → silently ignore (no - // architecture-rule meaning we can derive). base.pop()?; i += 1; } @@ -129,9 +138,6 @@ pub(crate) fn resolve_to_crate_absolute( } _ => return None, }; - // A resolved path with a `*` leaf (e.g. `crate::foo::*`) matches no - // concrete file — skip so we don't emit bogus `src/*/…` candidates - // that could collide with broad `to = "src/**"` rules. if resolved.iter().any(|s| s == "*") { return None; } diff --git a/src/adapters/shared/cfg_test_files.rs b/src/adapters/shared/cfg_test_files.rs index a8c8c72..270a72e 100644 --- a/src/adapters/shared/cfg_test_files.rs +++ b/src/adapters/shared/cfg_test_files.rs @@ -6,6 +6,7 @@ //! resulting set to classify functions as test helpers rather than //! production code. +use std::borrow::Cow; use std::collections::HashSet; use std::path::Path; @@ -119,26 +120,16 @@ impl<'a> ChildPathResolver<'a> { } else { parent.with_extension("") }; - // Normalize backslashes to forward slashes on Windows so the - // candidates match `known_paths` (which always store /). The - // `normalize_sep` helper is a no-op on Unix builds. - let candidate_file = normalize_sep( - child_dir - .join(format!("{mod_name}.rs")) - .to_string_lossy() - .as_ref(), - ); - let candidate_dir = normalize_sep( - child_dir - .join(mod_name) - .join("mod.rs") - .to_string_lossy() - .as_ref(), - ); - if self.known_paths.contains(candidate_file.as_str()) { - Some(candidate_file) - } else if self.known_paths.contains(candidate_dir.as_str()) { - Some(candidate_dir) + let file_raw = child_dir.join(format!("{mod_name}.rs")); + let dir_raw = child_dir.join(mod_name).join("mod.rs"); + let file_lossy = file_raw.to_string_lossy(); + let dir_lossy = dir_raw.to_string_lossy(); + let candidate_file = normalize_sep(&file_lossy); + let candidate_dir = normalize_sep(&dir_lossy); + if self.known_paths.contains(candidate_file.as_ref()) { + Some(candidate_file.into_owned()) + } else if self.known_paths.contains(candidate_dir.as_ref()) { + Some(candidate_dir.into_owned()) } else { None } @@ -146,23 +137,17 @@ impl<'a> ChildPathResolver<'a> { } /// Convert OS-native path separators into the forward-slash form used -/// by `known_paths`. On Unix this is the identity (no allocation); on -/// Windows we only scan+replace when a backslash is actually present. -/// Operation: pure string normalization. -#[cfg(windows)] -fn normalize_sep(path: &str) -> String { - if path.contains('\\') { - path.replace('\\', "/") +/// by `known_paths`. Returns `Cow::Borrowed` on Unix and on Windows +/// paths without backslashes; allocates only when a replacement is +/// actually needed. +fn normalize_sep(path: &str) -> Cow<'_, str> { + if cfg!(windows) && path.contains('\\') { + Cow::Owned(path.replace('\\', "/")) } else { - path.to_string() + Cow::Borrowed(path) } } -#[cfg(not(windows))] -fn normalize_sep(path: &str) -> String { - path.to_string() -} - /// Extract the string value of a `#[path = "..."]` attribute if present. /// Operation: attribute lookup + literal parsing, no own calls. fn path_attribute(attrs: &[syn::Attribute]) -> Option { From 7f496c0c875ca6c19f7e56b3452420e1ad774c74 Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:12:26 +0200 Subject: [PATCH 11/30] fix: copilot comments --- .../architecture/call_parity_rule/bindings.rs | 39 +++++- .../architecture/call_parity_rule/calls.rs | 118 +++++++++++------- .../architecture/call_parity_rule/pub_fns.rs | 7 ++ .../call_parity_rule/tests/calls.rs | 4 + .../call_parity_rule/tests/regressions.rs | 1 + .../type_infer/infer/access.rs | 1 + .../call_parity_rule/type_infer/infer/call.rs | 1 + .../type_infer/infer/generics.rs | 1 + .../call_parity_rule/type_infer/infer/mod.rs | 4 + .../type_infer/patterns/destructure.rs | 1 + .../call_parity_rule/type_infer/resolve.rs | 5 + .../type_infer/tests/combinators.rs | 1 + .../type_infer/tests/resolve.rs | 1 + .../type_infer/tests/support.rs | 1 + .../type_infer/tests/workspace_index.rs | 31 ++++- .../type_infer/workspace_index/methods.rs | 25 +++- .../type_infer/workspace_index/mod.rs | 12 ++ .../type_infer/workspace_index/traits.rs | 2 + .../call_parity_rule/workspace_graph.rs | 19 ++- src/adapters/shared/cfg_test_files.rs | 4 +- src/adapters/shared/use_tree.rs | 46 +++++++ 21 files changed, 262 insertions(+), 62 deletions(-) diff --git a/src/adapters/analyzers/architecture/call_parity_rule/bindings.rs b/src/adapters/analyzers/architecture/call_parity_rule/bindings.rs index 6f71309..07728e6 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/bindings.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/bindings.rs @@ -11,6 +11,7 @@ use super::local_symbols::scope_for_local; use crate::adapters::analyzers::architecture::forbidden_rule::{ file_to_module_segments, resolve_to_crate_absolute, resolve_to_crate_absolute_in, }; +use crate::adapters::shared::use_tree::ScopedAliasMap; use std::collections::{HashMap, HashSet}; /// Infer a canonical type-path from a `syn::Type`, stripping common @@ -76,14 +77,23 @@ fn strip_wrappers(ty: &syn::Type) -> &syn::Type { } /// Bundled inputs for canonical-type-path resolution. The same-file -/// fallback walks `mod_stack` outward against `local_decl_scopes` to -/// pick the closest enclosing declaration of a single-ident name. +/// fallback walks `mod_stack` outward against `local_decl_scopes` / +/// `aliases_per_scope` to pick the closest enclosing declaration of a +/// single-ident name. pub(crate) struct CanonScope<'a> { + /// Top-level (file-scope) `use` aliases. Used as the legacy fallback + /// when `aliases_per_scope` is `None`. pub alias_map: &'a HashMap>, pub local_symbols: &'a HashSet, pub crate_root_modules: &'a HashSet, pub importing_file: &'a str, pub local_decl_scopes: Option<&'a HashMap>>>, + /// `mod_path → (name → canonical_path)` capturing each inline + /// module's own `use` items. The empty `Vec` key holds top-level + /// aliases. When `Some`, alias lookup walks `mod_stack` outward + /// honouring per-mod scope; when `None`, the flat `alias_map` is + /// used (legacy / unit-test path). + pub aliases_per_scope: Option<&'a ScopedAliasMap>, pub mod_stack: &'a [String], } @@ -106,6 +116,7 @@ pub(super) fn canonicalise_type_segments( crate_root_modules, importing_file, local_decl_scopes: None, + aliases_per_scope: None, mod_stack: &[], }, ) @@ -133,8 +144,8 @@ pub(crate) fn canonicalise_type_segments_in_scope( full.extend(resolved); return Some(full); } - if let Some(alias) = scope.alias_map.get(&segments[0]) { - let mut full = alias.clone(); + if let Some(alias) = lookup_alias(scope, &segments[0]) { + let mut full = alias.to_vec(); full.extend_from_slice(&segments[1..]); return normalize(full); } @@ -154,6 +165,26 @@ pub(crate) fn canonicalise_type_segments_in_scope( None } +/// Resolve `name` against the alias maps in scope. Walks `mod_stack` +/// outward through `aliases_per_scope` so a `use` declared inside an +/// inline mod shadows a same-name file-level alias. Falls back to the +/// flat `alias_map` when the scoped overlay isn't provided (legacy / +/// unit-test path). +fn lookup_alias<'a>(scope: &'a CanonScope<'a>, name: &str) -> Option<&'a [String]> { + if let Some(per_scope) = scope.aliases_per_scope { + for depth in (0..=scope.mod_stack.len()).rev() { + let prefix = &scope.mod_stack[..depth]; + if let Some(map) = per_scope.get(prefix) { + if let Some(path) = map.get(name) { + return Some(path.as_slice()); + } + } + } + return None; + } + scope.alias_map.get(name).map(Vec::as_slice) +} + /// After alias-map substitution, re-run `self` / `super` normalisation /// and prepend `crate` for Rust 2018+ absolute imports that resolve /// into a known workspace root module (`use app::foo;` → diff --git a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs index 8963e5f..a8e232d 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs @@ -26,6 +26,7 @@ use super::type_infer::{ use crate::adapters::analyzers::architecture::forbidden_rule::{ file_to_module_segments, resolve_to_crate_absolute_in, }; +use crate::adapters::shared::use_tree::ScopedAliasMap; use std::collections::{HashMap, HashSet}; use syn::visit::Visit; @@ -51,7 +52,11 @@ pub struct FnContext<'a> { /// path like `["crate","foo","Bar"]` for `impl crate::foo::Bar`. pub self_type: Option>, /// File-level import alias map (output of `gather_alias_map`). + /// Used as fallback when `aliases_per_scope` is `None`. pub alias_map: &'a HashMap>, + /// Per-mod alias maps (output of `gather_alias_map_scoped`). `None` + /// for legacy / unit-test callers. + pub aliases_per_scope: Option<&'a ScopedAliasMap>, /// Set of top-level + nested item names declared in the same file. /// Unqualified calls (`helper()`, no `use` statement) whose first /// segment is in this set resolve to `crate::::<...>`. @@ -96,6 +101,7 @@ pub fn collect_canonical_calls(ctx: &FnContext<'_>) -> HashSet { // visit-order invariants the walker depends on. struct CanonicalCallCollector<'a> { alias_map: &'a HashMap>, + aliases_per_scope: Option<&'a ScopedAliasMap>, local_symbols: &'a HashSet, crate_root_modules: &'a HashSet, importing_file: &'a str, @@ -153,6 +159,7 @@ impl<'a> CanonicalCallCollector<'a> { }); Self { alias_map: ctx.alias_map, + aliases_per_scope: ctx.aliases_per_scope, local_symbols: ctx.local_symbols, crate_root_modules: ctx.crate_root_modules, importing_file: ctx.importing_file, @@ -191,12 +198,26 @@ impl<'a> CanonicalCallCollector<'a> { } /// Install a signature-param binding using the full `resolve_type` - /// pipeline. `Path` → legacy `Vec` scope (simple and cheap - /// to look up). `Opaque` is dropped. Everything else — `TraitBound`, - /// `Result`/`Option`/`Future` wrappers, `Slice`/`Map` — lands in - /// `non_path_bindings` so `?` / `.await` / trait-dispatch fire - /// correctly on method-call sites. Operation. + /// pipeline. Path → legacy scope, wrappers / trait bounds → + /// `non_path_bindings`, `Opaque` dropped. Always seeds frame 0 + /// because signature params live for the whole body walk. fn seed_param_via_resolver(&mut self, name: &str, ty: &syn::Type) { + match self.resolve_param_type(ty) { + CanonicalType::Path(segs) => { + self.bindings[0].insert(name.to_string(), segs); + } + CanonicalType::Opaque => {} + other => { + self.non_path_bindings[0].insert(name.to_string(), other); + } + } + } + + /// Resolve a parameter / closure-arg type through the full + /// scope-aware pipeline (alias expansion, transparent wrappers, + /// trait-bound extraction, inline-mod resolution). Used by both + /// signature seeding and closure-param seeding. + fn resolve_param_type(&self, ty: &syn::Type) -> CanonicalType { let rctx = ResolveContext { alias_map: self.alias_map, local_symbols: self.local_symbols, @@ -205,18 +226,10 @@ impl<'a> CanonicalCallCollector<'a> { type_aliases: self.workspace_index.map(|w| &w.type_aliases), transparent_wrappers: self.workspace_index.map(|w| &w.transparent_wrappers), local_decl_scopes: self.local_decl_scopes, + aliases_per_scope: self.aliases_per_scope, mod_stack: self.mod_stack, }; - match resolve_type(ty, &rctx) { - CanonicalType::Path(segs) => { - self.bindings[0].insert(name.to_string(), segs); - } - CanonicalType::Opaque => {} - other => { - // Signature params always seed the outermost scope (frame 0). - self.non_path_bindings[0].insert(name.to_string(), other); - } - } + resolve_type(ty, &rctx) } fn enter_scope(&mut self) { @@ -268,6 +281,28 @@ impl<'a> CanonicalCallCollector<'a> { self.current_non_path_scope_mut().insert(name, ty); } + /// Install a typed closure parameter (`|x: T| …`) through the same + /// scope-aware pipeline used for signature params. Untyped params + /// stay unbound (their type isn't recoverable without checker-level + /// inference); the resulting `Opaque` tombstone shadows any outer + /// same-name binding so the closure body can't accidentally reach + /// outer state. + fn install_closure_param(&mut self, pat: &syn::Pat) { + let syn::Pat::Type(pt) = pat else { + if let Some(name) = extract_pat_ident_name(pat) { + self.install_non_path_binding(name, CanonicalType::Opaque); + } + return; + }; + let Some(name) = extract_pat_ident_name(pt.pat.as_ref()) else { + return; + }; + match self.resolve_param_type(&pt.ty) { + CanonicalType::Path(segs) => self.install_path_binding(name, segs), + other => self.install_non_path_binding(name, other), + } + } + /// Turn a path-segment list into the canonical String used for all /// call-target comparisons in the call-parity check. Integration: /// each branch delegates to a dedicated helper. @@ -432,6 +467,7 @@ impl<'a> CanonicalCallCollector<'a> { self_type: self.self_type_canonical.clone(), mod_stack: self.mod_stack, local_decl_scopes: self.local_decl_scopes, + aliases_per_scope: self.aliases_per_scope, }; infer_type(expr, &ctx) } @@ -467,6 +503,7 @@ impl<'a> CanonicalCallCollector<'a> { type_aliases: Some(&wi.type_aliases), transparent_wrappers: Some(&wi.transparent_wrappers), local_decl_scopes: self.local_decl_scopes, + aliases_per_scope: self.aliases_per_scope, mod_stack: self.mod_stack, }; let name = pi.ident.to_string(); @@ -560,6 +597,7 @@ impl<'a> CanonicalCallCollector<'a> { self_type: self.self_type_canonical.clone(), mod_stack: self.mod_stack, local_decl_scopes: self.local_decl_scopes, + aliases_per_scope: self.aliases_per_scope, }; match kind { PatKind::Value => extract_bindings(pat, matched, &ictx), @@ -806,28 +844,26 @@ impl<'a, 'ast> Visit<'ast> for CanonicalCallCollector<'a> { self.visit_expr(else_expr); } } - // `let x: T = …` with a workspace index — route the annotation - // through the full resolver so Stage-3 alias expansion + wrapper - // peeling + trait-bound extraction apply. Without this, `let r: - // AppResult = …` would cache the raw alias path and - // later `r.unwrap().m()` would miss the method-return edge. if self.try_install_annotated_binding(local) { return; } - // Fast-path: direct `let s = T::ctor()` — the legacy prefix-based - // extractor resolves without needing a populated workspace index, - // so unit-test fixtures work. - if let Some((name, ty_canonical)) = extract_let_binding( - local, - self.alias_map, - self.local_symbols, - self.crate_root_modules, - self.importing_file, - ) { - self.install_path_binding(name, ty_canonical); - return; + // The legacy `extract_let_binding` shortcut isn't mod-scope aware + // — it would install `let s = inner::Session::new()` as + // `crate::file::Session` while the index keys it under + // `crate::file::inner::Session`. Skip it when a workspace index + // is available so inference (which is scope-aware) takes over. + if self.workspace_index.is_none() { + if let Some((name, ty_canonical)) = extract_let_binding( + local, + self.alias_map, + self.local_symbols, + self.crate_root_modules, + self.importing_file, + ) { + self.install_path_binding(name, ty_canonical); + return; + } } - // Simple-ident inference fallback (handles method chains + wrapper types). if extract_pat_ident_name(&local.pat).is_some() { self.install_inferred_let_binding(local); return; @@ -932,22 +968,8 @@ impl<'a, 'ast> Visit<'ast> for CanonicalCallCollector<'a> { fn visit_expr_closure(&mut self, c: &'ast syn::ExprClosure) { self.enter_scope(); - // Closure params: extract typed idents into scope. for input in &c.inputs { - if let syn::Pat::Type(pt) = input { - if let syn::Pat::Ident(pi) = pt.pat.as_ref() { - let name = pi.ident.to_string(); - if let Some(canonical) = canonical_from_type( - &pt.ty, - self.alias_map, - self.local_symbols, - self.crate_root_modules, - self.importing_file, - ) { - self.current_scope_mut().insert(name, canonical); - } - } - } + self.install_closure_param(input); } self.visit_expr(&c.body); self.exit_scope(); diff --git a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs index 0d5061e..39f887d 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs @@ -25,6 +25,8 @@ use super::workspace_graph::{ }; use crate::adapters::analyzers::architecture::layer_rule::LayerDefinitions; use crate::adapters::shared::cfg_test::{has_cfg_test, has_test_attr}; +use crate::adapters::shared::use_tree::gather_alias_map_scoped; +use crate::adapters::shared::use_tree::ScopedAliasMap; use std::collections::{HashMap, HashSet}; use syn::visit::Visit; use syn::Visibility; @@ -79,11 +81,13 @@ pub(crate) fn collect_pub_fns_by_layer<'ast>( // crate-root fallbacks still work. let alias_map = aliases_per_file.get(*path).unwrap_or(&empty_aliases); let LocalSymbols { flat, by_name } = collect_local_symbols_scoped(ast); + let aliases_per_scope = gather_alias_map_scoped(ast); let mut collector = PubFnCollector { file: path.to_string(), found: Vec::new(), visible_types: &visible_types, alias_map, + aliases_per_scope: &aliases_per_scope, local_symbols: &flat, local_decl_scopes: &by_name, crate_root_modules: &crate_root_modules, @@ -176,6 +180,8 @@ struct PubFnCollector<'ast, 'vis> { /// `use crate::app::Session; impl Session { ... }` and the call /// collector's receiver-tracked canonical agree on the same path. alias_map: &'vis HashMap>, + /// Per-mod aliases for `use` items inside inline modules. + aliases_per_scope: &'vis ScopedAliasMap, /// Same-file (top-level + nested) item names for the local-symbol /// fallback in `canonicalise_type_segments_in_scope`. local_symbols: &'vis HashSet, @@ -273,6 +279,7 @@ impl<'ast, 'vis> Visit<'ast> for PubFnCollector<'ast, 'vis> { crate_root_modules: self.crate_root_modules, importing_file: &self.file, local_decl_scopes: Some(self.local_decl_scopes), + aliases_per_scope: Some(self.aliases_per_scope), mod_stack: &self.mod_stack, }, ) diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/calls.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/calls.rs index b8c67b5..e27cf16 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/calls.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/calls.rs @@ -127,6 +127,7 @@ fn ctx_for_fn<'a>(fctx: &'a FileCtx, fn_name: &str, importing_file: &'a str) -> workspace_index: None, mod_stack: &[], local_decl_scopes: None, + aliases_per_scope: None, } } @@ -319,6 +320,7 @@ fn test_collect_self_dispatch_in_impl() { workspace_index: None, mod_stack: &[], local_decl_scopes: None, + aliases_per_scope: None, }; let calls = collect_canonical_calls(&ctx); assert!( @@ -776,6 +778,7 @@ fn test_qualified_impl_path_does_not_double_crate() { workspace_index: None, mod_stack: &[], local_decl_scopes: None, + aliases_per_scope: None, }; let calls = collect_canonical_calls(&ctx); assert!( @@ -809,6 +812,7 @@ fn ctx_with_index<'a>( workspace_index: Some(index), mod_stack: &[], local_decl_scopes: None, + aliases_per_scope: None, } } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs index 7720f94..d85b6de 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs @@ -131,6 +131,7 @@ fn run(fx: &RegFixture, index: &WorkspaceTypeIndex, fn_name: &str) -> HashSet) -> Option) -> Option { /// Per-name list of declaring mod-paths within `importing_file`. /// `None` for legacy / unit-test callers. pub local_decl_scopes: Option<&'a HashMap>>>, + /// Per-mod alias maps for `use` items inside inline modules. + /// `None` falls back to `alias_map`. + pub aliases_per_scope: Option<&'a ScopedAliasMap>, } // qual:api diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs index 38afb6c..fbfbedc 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs @@ -94,6 +94,7 @@ fn bind_annotated( type_aliases: Some(&ctx.workspace.type_aliases), transparent_wrappers: Some(&ctx.workspace.transparent_wrappers), local_decl_scopes: ctx.local_decl_scopes, + aliases_per_scope: ctx.aliases_per_scope, mod_stack: ctx.mod_stack, }; resolve_type(ty, &rctx) diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs index 508f871..fae108a 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs @@ -21,6 +21,7 @@ use super::super::bindings::{canonicalise_type_segments_in_scope, CanonScope}; use super::alias_substitution::substitute_alias_args; use super::canonical::CanonicalType; +use crate::adapters::shared::use_tree::ScopedAliasMap; use std::collections::{HashMap, HashSet}; /// Resolution inputs, bundled so the recursive calls don't drag a long @@ -48,6 +49,9 @@ pub(crate) struct ResolveContext<'a> { /// `Some(&workspace.local_decl_scopes_per_file[file])` (or the /// build-time equivalent). pub local_decl_scopes: Option<&'a HashMap>>>, + /// Per-mod alias maps for `use` items declared inside inline mods. + /// `None` falls back to the flat `alias_map`. + pub aliases_per_scope: Option<&'a ScopedAliasMap>, /// Mod-path inside `importing_file` of the type / fn being resolved. /// Empty when the caller is at file-top-level or doesn't track mod /// scope. Used together with `local_decl_scopes` to pick the @@ -70,6 +74,7 @@ fn canon_scope<'a>(ctx: &'a ResolveContext<'a>) -> CanonScope<'a> { crate_root_modules: ctx.crate_root_modules, importing_file: ctx.importing_file, local_decl_scopes: ctx.local_decl_scopes, + aliases_per_scope: ctx.aliases_per_scope, mod_stack: ctx.mod_stack, } } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs index 9741fdf..48c31cf 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs @@ -198,6 +198,7 @@ fn test_result_chain_unwrap_then_field() { self_type: None, mod_stack: &[], local_decl_scopes: None, + aliases_per_scope: None, }; let expr: syn::Expr = syn::parse_str("res.unwrap().id").expect("parse"); let t = infer_type(&expr, &ctx).expect("chain resolved"); diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs index 46688b0..4d61cef 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs @@ -27,6 +27,7 @@ fn ctx<'a>( type_aliases: None, transparent_wrappers: None, local_decl_scopes: None, + aliases_per_scope: None, mod_stack: &[], } } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/support.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/support.rs index 9a69043..4e3494e 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/support.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/support.rs @@ -79,6 +79,7 @@ impl TypeInferFixture { self_type: self.self_type.clone(), mod_stack: &[], local_decl_scopes: None, + aliases_per_scope: None, } } } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs index 3758733..6b09638 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs @@ -10,7 +10,9 @@ use crate::adapters::analyzers::architecture::call_parity_rule::local_symbols::{ use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::{ build_workspace_type_index, CanonicalType, WorkspaceIndexInputs, }; -use crate::adapters::shared::use_tree::gather_alias_map; +use crate::adapters::shared::use_tree::{ + gather_alias_map, gather_alias_map_scoped, ScopedAliasMap, +}; use std::collections::{HashMap, HashSet}; fn parse_file(src: &str) -> syn::File { @@ -20,22 +22,26 @@ fn parse_file(src: &str) -> syn::File { struct WsFixture { parsed: Vec<(String, syn::File)>, aliases: HashMap>>, + aliases_scoped: HashMap, local_symbols: HashMap, } fn fixture(entries: &[(&str, &str)]) -> WsFixture { let mut parsed = Vec::new(); let mut aliases = HashMap::new(); + let mut aliases_scoped = HashMap::new(); let mut local_symbols = HashMap::new(); for (path, src) in entries { let ast = parse_file(src); aliases.insert(path.to_string(), gather_alias_map(&ast)); + aliases_scoped.insert(path.to_string(), gather_alias_map_scoped(&ast)); local_symbols.insert(path.to_string(), collect_local_symbols_scoped(&ast)); parsed.push((path.to_string(), ast)); } WsFixture { parsed, aliases, + aliases_scoped, local_symbols, } } @@ -68,6 +74,7 @@ fn test_empty_workspace_produces_empty_index() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &HashSet::new(), crate_root_modules: &HashSet::new(), @@ -95,6 +102,7 @@ fn test_struct_with_named_field_is_indexed() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &HashSet::new(), crate_root_modules: &crate_roots(&["src/app/session.rs"]), @@ -119,6 +127,7 @@ fn test_struct_field_with_arc_is_stripped() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &HashSet::new(), crate_root_modules: &crate_roots(&["src/app/context.rs"]), @@ -137,6 +146,7 @@ fn test_tuple_struct_is_not_indexed() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &HashSet::new(), crate_root_modules: &crate_roots(&["src/app/foo.rs"]), @@ -156,6 +166,7 @@ fn test_struct_field_with_opaque_type_is_skipped() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &HashSet::new(), crate_root_modules: &crate_roots(&["src/app/foo.rs"]), @@ -181,6 +192,7 @@ fn test_inherent_method_with_concrete_return() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &HashSet::new(), crate_root_modules: &crate_roots(&["src/app/session.rs"]), @@ -211,6 +223,7 @@ fn test_method_returning_result_wraps() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &HashSet::new(), crate_root_modules: &crate_roots(&["src/app/session.rs"]), @@ -240,6 +253,7 @@ fn test_method_with_unit_return_is_not_indexed() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &HashSet::new(), crate_root_modules: &crate_roots(&["src/app/foo.rs"]), @@ -260,6 +274,7 @@ fn test_method_with_impl_trait_return_is_not_indexed() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &HashSet::new(), crate_root_modules: &crate_roots(&["src/app/foo.rs"]), @@ -282,6 +297,7 @@ fn test_trait_impl_method_is_indexed_by_receiver_type() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &HashSet::new(), crate_root_modules: &crate_roots(&["src/app/foo.rs"]), @@ -309,6 +325,7 @@ fn test_free_fn_return_is_indexed() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &HashSet::new(), crate_root_modules: &crate_roots(&["src/app/make.rs"]), @@ -332,6 +349,7 @@ fn test_generic_return_type_is_opaque_and_not_indexed() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &HashSet::new(), crate_root_modules: &crate_roots(&["src/app/make.rs"]), @@ -356,6 +374,7 @@ fn test_fn_inside_inline_mod_keys_include_mod_name() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &HashSet::new(), crate_root_modules: &crate_roots(&["src/app/mod.rs"]), @@ -386,6 +405,7 @@ fn test_fn_inside_inline_mod_resolves_inner_return_type() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &HashSet::new(), crate_root_modules: &crate_roots(&["src/app/mod.rs"]), @@ -417,6 +437,7 @@ fn test_struct_field_inside_inline_mod_keys_include_mod_name() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &HashSet::new(), crate_root_modules: &crate_roots(&["src/app/mod.rs"]), @@ -437,6 +458,7 @@ fn test_fn_with_unit_return_is_not_indexed() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &HashSet::new(), crate_root_modules: &crate_roots(&["src/app/foo.rs"]), @@ -462,6 +484,7 @@ fn test_cfg_test_file_is_skipped() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &cfg_test, crate_root_modules: &crate_roots(&["src/app/foo.rs"]), @@ -490,6 +513,7 @@ fn test_trait_declaration_methods_are_indexed() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &HashSet::new(), crate_root_modules: &crate_roots(&["src/app/ports.rs"]), @@ -513,6 +537,7 @@ fn test_trait_impl_is_indexed() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &HashSet::new(), crate_root_modules: &crate_roots(&["src/app/foo.rs"]), @@ -539,6 +564,7 @@ fn test_multiple_impls_of_same_trait_all_indexed() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &HashSet::new(), crate_root_modules: &crate_roots(&["src/app/foo.rs"]), @@ -560,6 +586,7 @@ fn test_inherent_impl_does_not_populate_trait_impls() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &HashSet::new(), crate_root_modules: &crate_roots(&["src/app/foo.rs"]), @@ -588,6 +615,7 @@ fn test_trait_in_one_file_impl_in_another() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &HashSet::new(), crate_root_modules: &crate_roots(&["src/ports/handler.rs", "src/app/session.rs"]), @@ -621,6 +649,7 @@ fn test_struct_in_one_file_impl_in_another() { let index = build_workspace_type_index(&WorkspaceIndexInputs { files: &borrowed(&fix), aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, local_symbols_per_file: &fix.local_symbols, cfg_test_files: &HashSet::new(), crate_root_modules: &crate_roots(&["src/app/session.rs", "src/app/impls.rs"]), diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs index 06dd5ec..5bd4cf7 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs @@ -62,6 +62,7 @@ impl<'ast, 'i, 'c> Visit<'ast> for MethodCollector<'i, 'c> { crate_root_modules: self.ctx.crate_root_modules, importing_file: self.ctx.path, local_decl_scopes: Some(self.ctx.local_decl_scopes), + aliases_per_scope: Some(self.ctx.aliases_per_scope), mod_stack: &self.mod_stack, }, ); @@ -104,15 +105,13 @@ fn record_method( mod_stack: &[String], node: &syn::ImplItemFn, ) { - let resolve = |ty: &syn::Type| resolve_type(ty, &resolve_ctx_from_build(ctx, mod_stack)); - let canon = |segs: &[String]| canonical_type_key(segs, ctx, mod_stack); let Some(Some(impl_segs)) = impl_stack.last() else { return; }; let syn::ReturnType::Type(_, ret_ty) = &node.sig.output else { return; }; - let inner = resolve(ret_ty); + let inner = resolve_method_return(ret_ty, impl_segs, ctx, mod_stack); if matches!(inner, CanonicalType::Opaque) { return; } @@ -121,9 +120,27 @@ fn record_method( } else { inner }; - let receiver_canonical = canon(impl_segs); + let receiver_canonical = canonical_type_key(impl_segs, ctx, mod_stack); let method_name = node.sig.ident.to_string(); index .method_returns .insert((receiver_canonical, method_name), ret); } + +/// Resolve a method's return type, substituting bare `Self` (and +/// `Self::Inner` paths) with the enclosing impl's canonical self-type. +/// Without this, `pub fn open() -> Self` on `impl Session` would index +/// as `Opaque` because the resolver doesn't know what `Self` refers to. +fn resolve_method_return( + ret_ty: &syn::Type, + impl_segs: &[String], + ctx: &BuildContext<'_>, + mod_stack: &[String], +) -> CanonicalType { + if let syn::Type::Path(p) = ret_ty { + if p.qself.is_none() && p.path.segments.first().is_some_and(|s| s.ident == "Self") { + return CanonicalType::Path(impl_segs.to_vec()); + } + } + resolve_type(ret_ty, &resolve_ctx_from_build(ctx, mod_stack)) +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs index a173a0e..66bc04a 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs @@ -20,6 +20,7 @@ use crate::adapters::analyzers::architecture::call_parity_rule::workspace_graph: collect_local_symbols_scoped, LocalSymbols, }; use crate::adapters::analyzers::architecture::forbidden_rule::file_to_module_segments; +use crate::adapters::shared::use_tree::ScopedAliasMap; use std::collections::{HashMap, HashSet}; /// Per-file resolution context passed to every collector. Owned by the @@ -27,6 +28,7 @@ use std::collections::{HashMap, HashSet}; pub(super) struct BuildContext<'a> { pub path: &'a str, pub alias_map: &'a HashMap>, + pub aliases_per_scope: &'a ScopedAliasMap, pub local_symbols: &'a HashSet, /// Per-name list of mod-paths-within-file where `local_symbols` /// names are declared. Lets the resolver pick @@ -87,6 +89,7 @@ pub(super) fn resolve_ctx_from_build<'a>( type_aliases: ctx.type_aliases, transparent_wrappers: Some(ctx.transparent_wrappers), local_decl_scopes: Some(ctx.local_decl_scopes), + aliases_per_scope: Some(ctx.aliases_per_scope), mod_stack, } } @@ -172,6 +175,7 @@ impl WorkspaceTypeIndex { pub struct WorkspaceIndexInputs<'a> { pub files: &'a [(&'a str, &'a syn::File)], pub aliases_per_file: &'a HashMap>>, + pub aliases_scoped_per_file: &'a HashMap, pub local_symbols_per_file: &'a HashMap, pub cfg_test_files: &'a HashSet, pub crate_root_modules: &'a HashSet, @@ -195,6 +199,7 @@ pub fn build_workspace_type_index(inputs: &WorkspaceIndexInputs<'_>) -> Workspac let shared = |type_aliases| WalkInputs { files: inputs.files, aliases_per_file: inputs.aliases_per_file, + aliases_scoped_per_file: inputs.aliases_scoped_per_file, local_symbols_per_file: inputs.local_symbols_per_file, cfg_test_files: inputs.cfg_test_files, crate_root_modules: inputs.crate_root_modules, @@ -228,6 +233,7 @@ pub fn build_workspace_type_index(inputs: &WorkspaceIndexInputs<'_>) -> Workspac struct WalkInputs<'a> { files: &'a [(&'a str, &'a syn::File)], aliases_per_file: &'a HashMap>>, + aliases_scoped_per_file: &'a HashMap, local_symbols_per_file: &'a HashMap, cfg_test_files: &'a HashSet, crate_root_modules: &'a HashSet, @@ -252,9 +258,15 @@ where let Some(local) = inputs.local_symbols_per_file.get(*path) else { continue; }; + let empty_scoped = HashMap::new(); + let aliases_per_scope = inputs + .aliases_scoped_per_file + .get(*path) + .unwrap_or(&empty_scoped); let ctx = BuildContext { path, alias_map, + aliases_per_scope, local_symbols: &local.flat, local_decl_scopes: &local.by_name, crate_root_modules: inputs.crate_root_modules, diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs index f9edf88..b997352 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs @@ -112,6 +112,7 @@ fn record_trait_impl( crate_root_modules: ctx.crate_root_modules, importing_file: ctx.path, local_decl_scopes: Some(ctx.local_decl_scopes), + aliases_per_scope: Some(ctx.aliases_per_scope), mod_stack, }, ); @@ -145,6 +146,7 @@ fn resolve_trait_path( crate_root_modules: ctx.crate_root_modules, importing_file: ctx.path, local_decl_scopes: Some(ctx.local_decl_scopes), + aliases_per_scope: Some(ctx.aliases_per_scope), mod_stack, }, )?; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs b/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs index 4b1442a..dd946b3 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs @@ -26,6 +26,8 @@ use super::type_infer::{build_workspace_type_index, WorkspaceIndexInputs, Worksp use crate::adapters::analyzers::architecture::forbidden_rule::file_to_module_segments; use crate::adapters::analyzers::architecture::layer_rule::LayerDefinitions; use crate::adapters::shared::cfg_test::has_cfg_test; +use crate::adapters::shared::use_tree::gather_alias_map_scoped; +use crate::adapters::shared::use_tree::ScopedAliasMap; use std::collections::{HashMap, HashSet, VecDeque}; use syn::visit::Visit; @@ -243,22 +245,28 @@ pub(crate) fn build_call_graph<'ast>( transparent_wrappers: &HashSet, ) -> CallGraph { let crate_root_modules = collect_crate_root_modules(files); - // Pre-compute `LocalSymbols` per file once and reuse across the - // type-index passes + the graph collector. Without this, every file - // would walk its AST three times just to rebuild the same maps. + // Pre-compute `LocalSymbols` + per-mod alias maps per file once and + // reuse across the type-index passes + the graph collector. let local_symbols_per_file: HashMap = files .iter() .filter(|(p, _)| !cfg_test_files.contains(*p)) .map(|(path, ast)| (path.to_string(), collect_local_symbols_scoped(ast))) .collect(); + let aliases_scoped_per_file: HashMap = files + .iter() + .filter(|(p, _)| !cfg_test_files.contains(*p)) + .map(|(path, ast)| (path.to_string(), gather_alias_map_scoped(ast))) + .collect(); let type_index = build_workspace_type_index(&WorkspaceIndexInputs { files, aliases_per_file, + aliases_scoped_per_file: &aliases_scoped_per_file, local_symbols_per_file: &local_symbols_per_file, cfg_test_files, crate_root_modules: &crate_root_modules, transparent_wrappers, }); + let empty_scoped = HashMap::new(); let mut graph = CallGraph::new(); for (path, ast) in files { if cfg_test_files.contains(*path) { @@ -270,9 +278,11 @@ pub(crate) fn build_call_graph<'ast>( let Some(local) = local_symbols_per_file.get(*path) else { continue; }; + let aliases_per_scope = aliases_scoped_per_file.get(*path).unwrap_or(&empty_scoped); let mut collector = FileFnCollector { path, alias_map, + aliases_per_scope, local_symbols: &local.flat, local_decl_scopes: &local.by_name, crate_root_modules: &crate_root_modules, @@ -305,6 +315,7 @@ fn populate_layer_cache(graph: &mut CallGraph, layers: &LayerDefinitions) { struct FileFnCollector<'a> { path: &'a str, alias_map: &'a HashMap>, + aliases_per_scope: &'a ScopedAliasMap, local_symbols: &'a HashSet, /// Per-name list of mod-paths-within-file where the name is /// declared. Threaded through `FnContext` so the call collector @@ -346,6 +357,7 @@ impl<'a> FileFnCollector<'a> { signature_params: extract_signature_params(sig), self_type, alias_map: self.alias_map, + aliases_per_scope: Some(self.aliases_per_scope), local_symbols: self.local_symbols, crate_root_modules: self.crate_root_modules, importing_file: self.path, @@ -387,6 +399,7 @@ impl<'a, 'ast> Visit<'ast> for FileFnCollector<'a> { crate_root_modules: self.crate_root_modules, importing_file: self.path, local_decl_scopes: Some(self.local_decl_scopes), + aliases_per_scope: Some(self.aliases_per_scope), mod_stack: &self.mod_stack, }, ); diff --git a/src/adapters/shared/cfg_test_files.rs b/src/adapters/shared/cfg_test_files.rs index 270a72e..7f2031c 100644 --- a/src/adapters/shared/cfg_test_files.rs +++ b/src/adapters/shared/cfg_test_files.rs @@ -124,8 +124,8 @@ impl<'a> ChildPathResolver<'a> { let dir_raw = child_dir.join(mod_name).join("mod.rs"); let file_lossy = file_raw.to_string_lossy(); let dir_lossy = dir_raw.to_string_lossy(); - let candidate_file = normalize_sep(&file_lossy); - let candidate_dir = normalize_sep(&dir_lossy); + let candidate_file = normalize_sep(file_lossy.as_ref()); + let candidate_dir = normalize_sep(dir_lossy.as_ref()); if self.known_paths.contains(candidate_file.as_ref()) { Some(candidate_file.into_owned()) } else if self.known_paths.contains(candidate_dir.as_ref()) { diff --git a/src/adapters/shared/use_tree.rs b/src/adapters/shared/use_tree.rs index accf844..0e039dd 100644 --- a/src/adapters/shared/use_tree.rs +++ b/src/adapters/shared/use_tree.rs @@ -10,6 +10,15 @@ use std::collections::HashMap; use syn::spanned::Spanned; use syn::UseTree; +/// `name → canonical_path_segments` — flat alias map for one scope. +pub type AliasMap = HashMap>; + +/// Per-mod alias maps within a single file. Key is the mod-path inside +/// the file (empty `Vec` for top-level); value is that mod's own `use` +/// items. Inner mods don't inherit outer entries — Rust requires each +/// mod to re-import names it wants to reference. +pub type ScopedAliasMap = HashMap, AliasMap>; + /// Apply `f` to the root `UseTree` of every `use` item in the file. /// Shared iteration backbone for `gather_imports` / `gather_alias_map` /// so the two walkers don't duplicate the item-filter. @@ -85,6 +94,43 @@ pub fn gather_alias_map(ast: &syn::File) -> HashMap> { out } +// qual:api +/// Like `gather_alias_map`, but separates `use` items by their declaring +/// inline-mod scope. Returns `mod_path → name → canonical_path`. The +/// empty `Vec` key holds top-level `use` items. Each inline `mod inner +/// { use … }` contributes its own `[…inner]` entry. Inner mods do not +/// inherit outer entries — Rust's name-resolution scoping requires +/// each mod to re-import names it wants to use. +pub fn gather_alias_map_scoped(ast: &syn::File) -> ScopedAliasMap { + let mut out = ScopedAliasMap::new(); + walk_scoped_aliases(&ast.items, &mut Vec::new(), &mut out); + out +} + +// qual:recursive +fn walk_scoped_aliases(items: &[syn::Item], mod_stack: &mut Vec, out: &mut ScopedAliasMap) { + let walk = |inner: &[syn::Item], stack: &mut Vec, out: &mut ScopedAliasMap| { + walk_scoped_aliases(inner, stack, out); + }; + { + let scope_map = out.entry(mod_stack.clone()).or_default(); + for item in items { + if let syn::Item::Use(u) = item { + collect_alias_entries(&[], &u.tree, scope_map); + } + } + } + for item in items { + if let syn::Item::Mod(m) = item { + if let Some((_, inner)) = m.content.as_ref() { + mod_stack.push(m.ident.to_string()); + walk(inner, mod_stack, out); + mod_stack.pop(); + } + } + } +} + // qual:recursive fn collect_alias_entries( prefix: &[String], From e7fbcbb94f0d2c7b786d035e072dfb2848a4d588 Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:42:35 +0200 Subject: [PATCH 12/30] fix: copilot comments --- CHANGELOG.md | 7 - .../architecture/call_parity_rule/bindings.rs | 127 ++-- .../architecture/call_parity_rule/calls.rs | 258 +++---- .../call_parity_rule/local_symbols.rs | 115 ++- .../architecture/call_parity_rule/pub_fns.rs | 55 +- .../call_parity_rule/tests/calls.rs | 191 +++-- .../call_parity_rule/tests/regressions.rs | 27 +- .../type_infer/infer/access.rs | 10 +- .../call_parity_rule/type_infer/infer/call.rs | 7 +- .../type_infer/infer/generics.rs | 10 +- .../call_parity_rule/type_infer/infer/mod.rs | 34 +- .../type_infer/patterns/destructure.rs | 10 +- .../call_parity_rule/type_infer/resolve.rs | 112 +-- .../type_infer/tests/combinators.rs | 21 +- .../type_infer/tests/infer_access.rs | 2 +- .../type_infer/tests/infer_call.rs | 2 +- .../type_infer/tests/patterns_destructure.rs | 2 +- .../type_infer/tests/patterns_iterator.rs | 2 +- .../type_infer/tests/resolve.rs | 235 +++++- .../type_infer/tests/support.rs | 27 +- .../type_infer/tests/workspace_index.rs | 669 ++++++++++++------ .../type_infer/workspace_index/aliases.rs | 25 +- .../type_infer/workspace_index/fields.rs | 2 +- .../type_infer/workspace_index/functions.rs | 2 +- .../type_infer/workspace_index/methods.rs | 9 +- .../type_infer/workspace_index/mod.rs | 119 ++-- .../type_infer/workspace_index/traits.rs | 16 +- .../call_parity_rule/workspace_graph.rs | 70 +- 28 files changed, 1297 insertions(+), 869 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfeb78c..e8398e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -166,13 +166,6 @@ additive and the legacy fast-path stays intact as a safety net. ### Known Limits Patterns that intentionally stay unresolved and produce `:name` fallback markers rather than fabricate edges: -- Imports inside inline mods (`mod inner { use super::Foo; … }`) are - not separated from the file's top-level alias map. A top-level - `use crate::outer::Session;` is visible to nested mods (correct), but - a `use super::X;` inside `mod inner` isn't given a distinct - scope — alias-map lookups from outside `inner` may see it. In practice - this rarely causes false positives; the same-name-shadowing case can - be worked around by importing at the file-top level. - `Session::open().map(|r| r.m())` — closure-body argument type is unknown. Inner method call stays `:m`. - `fn get() -> T { … }; let x = get(); x.m()` without annotation diff --git a/src/adapters/analyzers/architecture/call_parity_rule/bindings.rs b/src/adapters/analyzers/architecture/call_parity_rule/bindings.rs index 07728e6..0f4083a 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/bindings.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/bindings.rs @@ -7,7 +7,7 @@ //! `(name, canonical)` pair, preferring an explicit `let s: T =` annotation //! over constructor-inference from `let s = T::new()`. -use super::local_symbols::scope_for_local; +use super::local_symbols::{scope_for_local, FileScope}; use crate::adapters::analyzers::architecture::forbidden_rule::{ file_to_module_segments, resolve_to_crate_absolute, resolve_to_crate_absolute_in, }; @@ -76,31 +76,17 @@ fn strip_wrappers(ty: &syn::Type) -> &syn::Type { } } -/// Bundled inputs for canonical-type-path resolution. The same-file -/// fallback walks `mod_stack` outward against `local_decl_scopes` / -/// `aliases_per_scope` to pick the closest enclosing declaration of a -/// single-ident name. +/// Bundled inputs for canonical-type-path resolution. Per-file lookup +/// tables live in `file: &FileScope`; `mod_stack` is per-call-site. pub(crate) struct CanonScope<'a> { - /// Top-level (file-scope) `use` aliases. Used as the legacy fallback - /// when `aliases_per_scope` is `None`. - pub alias_map: &'a HashMap>, - pub local_symbols: &'a HashSet, - pub crate_root_modules: &'a HashSet, - pub importing_file: &'a str, - pub local_decl_scopes: Option<&'a HashMap>>>, - /// `mod_path → (name → canonical_path)` capturing each inline - /// module's own `use` items. The empty `Vec` key holds top-level - /// aliases. When `Some`, alias lookup walks `mod_stack` outward - /// honouring per-mod scope; when `None`, the flat `alias_map` is - /// used (legacy / unit-test path). - pub aliases_per_scope: Option<&'a ScopedAliasMap>, + pub file: &'a FileScope<'a>, pub mod_stack: &'a [String], } -/// Legacy helper for callers that don't track mod scope (call collector, -/// the `bindings::canonical_from_type` adapter, tests). Delegates to the -/// scope-aware variant with empty `mod_stack` + `None` decl scopes — -/// behaviour unchanged. Operation: thin wrapper. +/// Legacy helper for callers that have only the flat per-file maps +/// (unit-test fixtures, the `canonical_from_type` adapter). Builds an +/// empty `ScopedAliasMap` / `local_decl_scopes` overlay so the +/// scope-aware path falls back to flat behaviour automatically. pub(super) fn canonicalise_type_segments( segments: &[String], alias_map: &HashMap>, @@ -108,26 +94,29 @@ pub(super) fn canonicalise_type_segments( crate_root_modules: &HashSet, importing_file: &str, ) -> Option> { + let empty_scoped = ScopedAliasMap::new(); + let empty_decls = HashMap::new(); + let file = FileScope { + path: importing_file, + alias_map, + aliases_per_scope: &empty_scoped, + local_symbols, + local_decl_scopes: &empty_decls, + crate_root_modules, + }; canonicalise_type_segments_in_scope( segments, &CanonScope { - alias_map, - local_symbols, - crate_root_modules, - importing_file, - local_decl_scopes: None, - aliases_per_scope: None, + file: &file, mod_stack: &[], }, ) } -/// Resolve a type-path segment list into a canonical `[crate, …]` path, -/// applying `crate/self/super` module normalisation, alias-map lookup, -/// and same-file fallback (mod-scope-aware when `scope.mod_stack` / -/// `scope.local_decl_scopes` are populated). Returns `None` for -/// unresolvable paths (external crates, unknown idents). -/// Operation: closure-hidden own calls keep IOSP clean. +/// Resolve a type-path segment list into a canonical `[crate, …]` path +/// against `scope`. Returns `None` for unresolvable paths (external +/// crates, unknown idents, or in-file names not declared at the +/// current `mod_stack`). pub(crate) fn canonicalise_type_segments_in_scope( segments: &[String], scope: &CanonScope<'_>, @@ -135,11 +124,9 @@ pub(crate) fn canonicalise_type_segments_in_scope( if segments.is_empty() { return None; } - let normalize = - |full| normalize_after_alias(full, scope.importing_file, scope.crate_root_modules); + let file = scope.file; if matches!(segments[0].as_str(), "crate" | "self" | "super") { - let resolved = - resolve_to_crate_absolute_in(scope.importing_file, scope.mod_stack, segments)?; + let resolved = resolve_to_crate_absolute_in(file.path, scope.mod_stack, segments)?; let mut full = vec!["crate".to_string()]; full.extend(resolved); return Some(full); @@ -147,17 +134,26 @@ pub(crate) fn canonicalise_type_segments_in_scope( if let Some(alias) = lookup_alias(scope, &segments[0]) { let mut full = alias.to_vec(); full.extend_from_slice(&segments[1..]); - return normalize(full); + return normalize_after_alias(full, file.path, scope.mod_stack, file.crate_root_modules); } - if scope.local_symbols.contains(&segments[0]) { - let mod_path = scope_for_local(scope.local_decl_scopes, &segments[0], scope.mod_stack); - let mut full = vec!["crate".to_string()]; - full.extend(file_to_module_segments(scope.importing_file)); - full.extend(mod_path.iter().cloned()); - full.extend_from_slice(segments); - return Some(full); + if file.local_symbols.contains(&segments[0]) { + if let Some(mod_path) = + scope_for_local(file.local_decl_scopes, &segments[0], scope.mod_stack) + { + let mut full = vec!["crate".to_string()]; + full.extend(file_to_module_segments(file.path)); + full.extend(mod_path.iter().cloned()); + full.extend_from_slice(segments); + return Some(full); + } + if file.local_decl_scopes.is_empty() { + let mut full = vec!["crate".to_string()]; + full.extend(file_to_module_segments(file.path)); + full.extend_from_slice(segments); + return Some(full); + } } - if scope.crate_root_modules.contains(&segments[0]) { + if file.crate_root_modules.contains(&segments[0]) { let mut full = vec!["crate".to_string()]; full.extend_from_slice(segments); return Some(full); @@ -165,39 +161,31 @@ pub(crate) fn canonicalise_type_segments_in_scope( None } -/// Resolve `name` against the alias maps in scope. Walks `mod_stack` -/// outward through `aliases_per_scope` so a `use` declared inside an -/// inline mod shadows a same-name file-level alias. Falls back to the -/// flat `alias_map` when the scoped overlay isn't provided (legacy / -/// unit-test path). +/// Resolve `name` against the alias map for exactly the current +/// `mod_stack`. Rust `use` items are module-local — child mods don't +/// inherit parents — so this looks up only at the current scope. When +/// the scoped overlay has no entry for `mod_stack` (legacy / unit-test +/// callers), falls back to the flat `alias_map`. fn lookup_alias<'a>(scope: &'a CanonScope<'a>, name: &str) -> Option<&'a [String]> { - if let Some(per_scope) = scope.aliases_per_scope { - for depth in (0..=scope.mod_stack.len()).rev() { - let prefix = &scope.mod_stack[..depth]; - if let Some(map) = per_scope.get(prefix) { - if let Some(path) = map.get(name) { - return Some(path.as_slice()); - } - } - } - return None; + if let Some(map) = scope.file.aliases_per_scope.get(scope.mod_stack) { + return map.get(name).map(Vec::as_slice); } - scope.alias_map.get(name).map(Vec::as_slice) + scope.file.alias_map.get(name).map(Vec::as_slice) } /// After alias-map substitution, re-run `self` / `super` normalisation -/// and prepend `crate` for Rust 2018+ absolute imports that resolve -/// into a known workspace root module (`use app::foo;` → -/// `crate::app::foo`). Without this, both forms leak non-crate-rooted -/// canonicals that never match graph nodes. +/// (relative to `mod_stack` inside `importing_file`, so an alias +/// declared inside an inline mod resolves its `self`/`super` against +/// that mod) and prepend `crate` for Rust 2018+ absolute imports. fn normalize_after_alias( expanded: Vec, importing_file: &str, + mod_stack: &[String], crate_root_modules: &HashSet, ) -> Option> { match expanded.first().map(|s| s.as_str()) { Some("self") | Some("super") => { - let resolved = resolve_to_crate_absolute(importing_file, &expanded)?; + let resolved = resolve_to_crate_absolute_in(importing_file, mod_stack, &expanded)?; let mut full = vec!["crate".to_string()]; full.extend(resolved); Some(full) @@ -215,9 +203,10 @@ fn normalize_after_alias( pub(super) fn normalize_alias_expansion( expanded: Vec, importing_file: &str, + mod_stack: &[String], crate_root_modules: &HashSet, ) -> Option> { - normalize_after_alias(expanded, importing_file, crate_root_modules) + normalize_after_alias(expanded, importing_file, mod_stack, crate_root_modules) } /// Extract a `(name, canonical_type_path)` pair from a `let` statement. diff --git a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs index a8e232d..d25ec84 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs @@ -17,7 +17,7 @@ //! the binding scan patterns. use super::bindings::{canonical_from_type, extract_let_binding, normalize_alias_expansion}; -use super::local_symbols::scope_for_local; +use super::local_symbols::{scope_for_local, FileScope}; use super::type_infer::resolve::{resolve_type, ResolveContext}; use super::type_infer::{ extract_bindings, extract_for_bindings, infer_type, BindingLookup, CanonicalType, InferContext, @@ -26,7 +26,6 @@ use super::type_infer::{ use crate::adapters::analyzers::architecture::forbidden_rule::{ file_to_module_segments, resolve_to_crate_absolute_in, }; -use crate::adapters::shared::use_tree::ScopedAliasMap; use std::collections::{HashMap, HashSet}; use syn::visit::Visit; @@ -38,51 +37,25 @@ const METHOD_UNKNOWN_PREFIX: &str = ":"; /// strings are layer-unknown (external, stdlib, or not aliased). const BARE_UNKNOWN_PREFIX: &str = ":"; -/// Input for the canonical-call collector. Bundles the fn body with its -/// signature types, impl-self-type context, and file-level alias map. +/// Input for the canonical-call collector. Per-file lookup tables live +/// in `file`; the rest is per-fn. pub struct FnContext<'a> { + pub file: &'a FileScope<'a>, + /// Mod-path of the fn declaration inside `file.path`. Empty for + /// top-level fns. + pub mod_stack: &'a [String], /// Body of the function we analyse. pub body: &'a syn::Block, - /// Named signature parameters with their declared types. Feeds the - /// top-level binding scope so `fn foo(s: Session) { s.search() }` - /// resolves correctly. + /// Named signature parameters with their declared types. pub signature_params: Vec<(String, &'a syn::Type)>, - /// Type-path of the enclosing `impl` block, if any. Just the - /// type-name segments (e.g. `["RlmSession"]`), or a crate-rooted - /// path like `["crate","foo","Bar"]` for `impl crate::foo::Bar`. + /// Type-path of the enclosing `impl` block, if any. pub self_type: Option>, - /// File-level import alias map (output of `gather_alias_map`). - /// Used as fallback when `aliases_per_scope` is `None`. - pub alias_map: &'a HashMap>, - /// Per-mod alias maps (output of `gather_alias_map_scoped`). `None` - /// for legacy / unit-test callers. - pub aliases_per_scope: Option<&'a ScopedAliasMap>, - /// Set of top-level + nested item names declared in the same file. - /// Unqualified calls (`helper()`, no `use` statement) whose first - /// segment is in this set resolve to `crate::::<...>`. - pub local_symbols: &'a HashSet, - /// Set of crate-root module names (first-segment `` for every - /// `src/.rs` / `src//**.rs` in the workspace). Lets the - /// Rust 2018+ absolute-import form `use app::foo;` resolve to - /// `crate::app::foo` instead of a dead-end `app::foo` canonical. - pub crate_root_modules: &'a HashSet, - /// File path of the fn under analysis. Used to resolve - /// `crate::` / `self::` / `super::` prefixes and `Self::…`. - pub importing_file: &'a str, - /// Workspace type-index for shallow inference fallback. `None` means - /// the collector falls back to `:name` for complex receivers - /// (typical in unit-test fixtures that don't build the full graph). - /// The full `build_call_graph` pipeline always passes `Some(&index)`. + /// Workspace type-index for shallow inference fallback. `None` for + /// unit-test fixtures. pub workspace_index: Option<&'a WorkspaceTypeIndex>, - /// Mod-path of the fn declaration inside `importing_file`. Empty - /// for top-level fns. Used together with `local_decl_scopes` so a - /// fn `crate::file::inner::make` references its sibling type - /// `Session` and resolves to `crate::file::inner::Session`. - pub mod_stack: &'a [String], - /// Per-name list of declaring mod-paths within `importing_file`. - /// `None` for legacy / test callers — the resolver falls back to - /// flat top-level prepend behaviour. - pub local_decl_scopes: Option<&'a HashMap>>>, + /// All workspace `FileScope`s. Lets alias expansion switch into + /// the alias's declaring scope. `None` for unit-test fixtures. + pub workspace_files: Option<&'a HashMap>>, } // qual:api @@ -100,77 +73,52 @@ pub fn collect_canonical_calls(ctx: &FnContext<'_>) -> HashSet { // halves of a single walk; splitting them further fragments the // visit-order invariants the walker depends on. struct CanonicalCallCollector<'a> { - alias_map: &'a HashMap>, - aliases_per_scope: Option<&'a ScopedAliasMap>, - local_symbols: &'a HashSet, - crate_root_modules: &'a HashSet, - importing_file: &'a str, + file: &'a FileScope<'a>, + /// Mod-path inside `file.path` of the fn under analysis. Read-only + /// for the duration of the body walk. + mod_stack: &'a [String], /// Full canonical path of the enclosing impl's self-type (with /// `crate` prefix), if any — used to resolve `Self::method`. self_type_canonical: Option>, signature_params: Vec<(String, &'a syn::Type)>, - /// Mod-path inside `importing_file` of the fn under analysis. - /// Borrowed from `FnContext.mod_stack` — Rust doesn't let inner - /// `mod` items shadow outer resolution, so the path is read-only - /// for the duration of the body walk. - mod_stack: &'a [String], - /// Per-name declaring mod-paths within `importing_file`. Mirrors - /// `FnContext.local_decl_scopes` and feeds the scope-aware - /// `canonicalise_path` branch. - local_decl_scopes: Option<&'a HashMap>>>, /// Scope stack of variable-name → canonical-type-path bindings. - /// Inner-most scope is at the end; lookup walks from back to front. /// Always non-empty while a collection is in flight. bindings: Vec>>, - /// Parallel scope stack for bindings whose inferred type isn't a - /// simple `Path` — trait bounds (`dyn Trait`) and stdlib wrappers - /// (`Result`, `Option`, `Future`, `Vec`, - /// `HashMap<_, V>`). Pushed/popped in lockstep with `bindings` so - /// non-path bindings respect lexical scope just like path ones. - /// Kept parallel (not merged into a single `CanonicalType` stack) - /// because the legacy fast-path reads from `bindings` by segment - /// vector directly — migrating that is a separate refactor. + /// Parallel scope stack for non-Path bindings (`Result<…>`, + /// `dyn Trait`, etc.). Pushed/popped in lockstep with `bindings`. non_path_bindings: Vec>, calls: HashSet, - /// Workspace type-index for shallow inference fallback. Mirrored - /// from `FnContext` so the visitor doesn't need the full context - /// passed through every method. + /// Workspace type-index for shallow inference fallback. `None` + /// for unit-test fixtures. workspace_index: Option<&'a WorkspaceTypeIndex>, + /// Workspace `FileScope` map for alias decl-site resolution. + workspace_files: Option<&'a HashMap>>, } impl<'a> CanonicalCallCollector<'a> { fn new(ctx: &'a FnContext<'a>) -> Self { let self_type_canonical = ctx.self_type.as_ref().map(|segs| { // Qualified impl path (`impl crate::foo::Bar { ... }`) — use - // as-is so Self::method canonicalises to `crate::foo::Bar::method`, - // matching graph nodes built via `canonical_fn_name`. + // as-is so Self::method canonicalises to `crate::foo::Bar::method`. if segs.first().map(|s| s.as_str()) == Some("crate") { return segs.clone(); } - // Insert `mod_stack` between file segments and impl segments so - // an `impl Session { ... }` declared inside `mod inner` resolves - // `Self::method` to `crate::::inner::Session::method` — - // matching the path the graph node + type-index keys use. let mut full = vec!["crate".to_string()]; - full.extend(file_to_module_segments(ctx.importing_file)); + full.extend(file_to_module_segments(ctx.file.path)); full.extend(ctx.mod_stack.iter().cloned()); full.extend_from_slice(segs); full }); Self { - alias_map: ctx.alias_map, - aliases_per_scope: ctx.aliases_per_scope, - local_symbols: ctx.local_symbols, - crate_root_modules: ctx.crate_root_modules, - importing_file: ctx.importing_file, + file: ctx.file, + mod_stack: ctx.mod_stack, self_type_canonical, signature_params: ctx.signature_params.clone(), - mod_stack: ctx.mod_stack, - local_decl_scopes: ctx.local_decl_scopes, bindings: vec![HashMap::new()], non_path_bindings: vec![HashMap::new()], calls: HashSet::new(), workspace_index: ctx.workspace_index, + workspace_files: ctx.workspace_files, } } @@ -187,10 +135,10 @@ impl<'a> CanonicalCallCollector<'a> { // Legacy fast-path for unit-test fixtures without an index. if let Some(canonical) = canonical_from_type( ty, - self.alias_map, - self.local_symbols, - self.crate_root_modules, - self.importing_file, + self.file.alias_map, + self.file.local_symbols, + self.file.crate_root_modules, + self.file.path, ) { self.bindings[0].insert(name.clone(), canonical); } @@ -219,15 +167,11 @@ impl<'a> CanonicalCallCollector<'a> { /// signature seeding and closure-param seeding. fn resolve_param_type(&self, ty: &syn::Type) -> CanonicalType { let rctx = ResolveContext { - alias_map: self.alias_map, - local_symbols: self.local_symbols, - crate_root_modules: self.crate_root_modules, - importing_file: self.importing_file, + file: self.file, + mod_stack: self.mod_stack, type_aliases: self.workspace_index.map(|w| &w.type_aliases), transparent_wrappers: self.workspace_index.map(|w| &w.transparent_wrappers), - local_decl_scopes: self.local_decl_scopes, - aliases_per_scope: self.aliases_per_scope, - mod_stack: self.mod_stack, + workspace_files: self.workspace_files, }; resolve_type(ty, &rctx) } @@ -281,25 +225,25 @@ impl<'a> CanonicalCallCollector<'a> { self.current_non_path_scope_mut().insert(name, ty); } - /// Install a typed closure parameter (`|x: T| …`) through the same - /// scope-aware pipeline used for signature params. Untyped params - /// stay unbound (their type isn't recoverable without checker-level - /// inference); the resulting `Opaque` tombstone shadows any outer - /// same-name binding so the closure body can't accidentally reach - /// outer state. + /// Install closure parameter bindings. For `|x: T|` the type goes + /// through the same scope-aware pipeline as signature params. For + /// untyped or destructured patterns, every bound ident gets an + /// `Opaque` tombstone — without this, an outer same-name binding + /// could leak into the closure body and synthesize a stale edge. fn install_closure_param(&mut self, pat: &syn::Pat) { - let syn::Pat::Type(pt) = pat else { - if let Some(name) = extract_pat_ident_name(pat) { - self.install_non_path_binding(name, CanonicalType::Opaque); + if let syn::Pat::Type(pt) = pat { + if let Some(name) = extract_pat_ident_name(pt.pat.as_ref()) { + match self.resolve_param_type(&pt.ty) { + CanonicalType::Path(segs) => self.install_path_binding(name, segs), + other => self.install_non_path_binding(name, other), + } + return; } - return; - }; - let Some(name) = extract_pat_ident_name(pt.pat.as_ref()) else { - return; - }; - match self.resolve_param_type(&pt.ty) { - CanonicalType::Path(segs) => self.install_path_binding(name, segs), - other => self.install_non_path_binding(name, other), + } + let mut idents = Vec::new(); + collect_pattern_idents(pat, &mut idents); + for name in idents { + self.install_non_path_binding(name, CanonicalType::Opaque); } } @@ -319,14 +263,14 @@ impl<'a> CanonicalCallCollector<'a> { if let Some(canonical) = self.canonicalise_alias_path(segments) { return canonical; } - if self.local_symbols.contains(&segments[0]) { - return self.canonicalise_local_symbol_path(segments); + if let Some(canonical) = self.canonicalise_local_symbol_path(segments) { + return canonical; } // Rust 2018+ absolute call: `app::foo()` without `use` is the // crate-root `app` module, equivalent to `crate::app::foo()`. // If `app` is a known workspace root module, prepend `crate::` // so the canonical matches graph nodes. - if self.crate_root_modules.contains(&segments[0]) { + if self.file.crate_root_modules.contains(&segments[0]) { let mut full = vec!["crate".to_string()]; full.extend_from_slice(segments); return full.join("::"); @@ -349,7 +293,7 @@ impl<'a> CanonicalCallCollector<'a> { fn canonicalise_keyword_path(&self, segments: &[String]) -> String { if let Some(resolved) = - resolve_to_crate_absolute_in(self.importing_file, self.mod_stack, segments) + resolve_to_crate_absolute_in(self.file.path, self.mod_stack, segments) { let mut full = vec!["crate".to_string()]; full.extend(resolved); @@ -358,31 +302,47 @@ impl<'a> CanonicalCallCollector<'a> { bare(&segments.join("::")) } - /// First segment hits the file's import-alias map → replace the - /// prefix and re-normalise (alias may itself reference `self::` or - /// a Rust-2018 crate-root module). Returns `None` when no alias - /// matches. Operation. + /// First segment hits a `use` alias visible at the current + /// `mod_stack`. The alias path is then re-normalised (it may + /// itself reference `self::`/`super::` or a Rust-2018 crate-root + /// module). Returns `None` when no alias matches. fn canonicalise_alias_path(&self, segments: &[String]) -> Option { - let alias = self.alias_map.get(&segments[0])?; - let mut full = alias.clone(); + let alias = self.lookup_alias_at_scope(&segments[0])?; + let mut full = alias.to_vec(); full.extend_from_slice(&segments[1..]); - let normalized = - normalize_alias_expansion(full, self.importing_file, self.crate_root_modules)?; + let normalized = normalize_alias_expansion( + full, + self.file.path, + self.mod_stack, + self.file.crate_root_modules, + )?; Some(normalized.join("::")) } - /// Same-file fallback: first segment is declared in this file, so - /// resolve to `crate::::::`. The - /// mod-path walk picks the closest enclosing scope so a call inside - /// `mod inner` to a sibling `helper()` reaches - /// `crate::::inner::helper`. Operation. - fn canonicalise_local_symbol_path(&self, segments: &[String]) -> String { - let mod_path = scope_for_local(self.local_decl_scopes, &segments[0], self.mod_stack); + /// Look up `name` in the alias map for exactly the current + /// `mod_stack`. Falls back to the flat top-level `alias_map` for + /// legacy callers that don't populate `aliases_per_scope`. + fn lookup_alias_at_scope(&self, name: &str) -> Option<&[String]> { + if let Some(map) = self.file.aliases_per_scope.get(self.mod_stack) { + return map.get(name).map(Vec::as_slice); + } + self.file.alias_map.get(name).map(Vec::as_slice) + } + + /// Same-file fallback: first segment is declared in this file at + /// exactly the current `mod_stack`. Returns `None` when the name + /// isn't in `local_symbols` or its declaration is in a different + /// scope, letting the caller fall through to crate-root resolution. + fn canonicalise_local_symbol_path(&self, segments: &[String]) -> Option { + if !self.file.local_symbols.contains(&segments[0]) { + return None; + } + let mod_path = scope_for_local(self.file.local_decl_scopes, &segments[0], self.mod_stack)?; let mut full = vec!["crate".to_string()]; - full.extend(file_to_module_segments(self.importing_file)); + full.extend(file_to_module_segments(self.file.path)); full.extend(mod_path.iter().cloned()); full.extend_from_slice(segments); - full.join("::") + Some(full.join("::")) } fn record_call(&mut self, target: String) { @@ -458,16 +418,12 @@ impl<'a> CanonicalCallCollector<'a> { non_path_scope: &self.non_path_bindings, }; let ctx = InferContext { + file: self.file, + mod_stack: self.mod_stack, workspace: self.workspace_index?, - alias_map: self.alias_map, - local_symbols: self.local_symbols, - crate_root_modules: self.crate_root_modules, - importing_file: self.importing_file, bindings: &adapter, self_type: self.self_type_canonical.clone(), - mod_stack: self.mod_stack, - local_decl_scopes: self.local_decl_scopes, - aliases_per_scope: self.aliases_per_scope, + workspace_files: self.workspace_files, }; infer_type(expr, &ctx) } @@ -496,15 +452,11 @@ impl<'a> CanonicalCallCollector<'a> { return false; } let rctx = ResolveContext { - alias_map: self.alias_map, - local_symbols: self.local_symbols, - crate_root_modules: self.crate_root_modules, - importing_file: self.importing_file, + file: self.file, + mod_stack: self.mod_stack, type_aliases: Some(&wi.type_aliases), transparent_wrappers: Some(&wi.transparent_wrappers), - local_decl_scopes: self.local_decl_scopes, - aliases_per_scope: self.aliases_per_scope, - mod_stack: self.mod_stack, + workspace_files: self.workspace_files, }; let name = pi.ident.to_string(); match resolve_type(pt.ty.as_ref(), &rctx) { @@ -588,16 +540,12 @@ impl<'a> CanonicalCallCollector<'a> { non_path_scope: &self.non_path_bindings, }; let ictx = InferContext { + file: self.file, + mod_stack: self.mod_stack, workspace, - alias_map: self.alias_map, - local_symbols: self.local_symbols, - crate_root_modules: self.crate_root_modules, - importing_file: self.importing_file, bindings: &adapter, self_type: self.self_type_canonical.clone(), - mod_stack: self.mod_stack, - local_decl_scopes: self.local_decl_scopes, - aliases_per_scope: self.aliases_per_scope, + workspace_files: self.workspace_files, }; match kind { PatKind::Value => extract_bindings(pat, matched, &ictx), @@ -855,10 +803,10 @@ impl<'a, 'ast> Visit<'ast> for CanonicalCallCollector<'a> { if self.workspace_index.is_none() { if let Some((name, ty_canonical)) = extract_let_binding( local, - self.alias_map, - self.local_symbols, - self.crate_root_modules, - self.importing_file, + self.file.alias_map, + self.file.local_symbols, + self.file.crate_root_modules, + self.file.path, ) { self.install_path_binding(name, ty_canonical); return; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/local_symbols.rs b/src/adapters/analyzers/architecture/call_parity_rule/local_symbols.rs index d01005e..d256c1a 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/local_symbols.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/local_symbols.rs @@ -12,7 +12,9 @@ //! `crate::::inner::Session` when the type is declared there. use crate::adapters::shared::cfg_test::has_cfg_test; +use crate::adapters::shared::use_tree::ScopedAliasMap; use std::collections::{HashMap, HashSet}; +use std::sync::OnceLock; /// `(flat-set, per-scope-map)` view over the names declared in a file. #[derive(Debug, Default, Clone)] @@ -21,6 +23,77 @@ pub(crate) struct LocalSymbols { pub by_name: HashMap>>, } +/// All per-file lookup tables a resolver / call collector needs in +/// one place. Built once per file by the call-parity entry points; +/// borrowed into every `CanonScope` / `ResolveContext` / `FnContext` +/// / `InferContext` / `BuildContext` instead of duplicating the same +/// six fields across each context struct. +pub(crate) struct FileScope<'a> { + pub path: &'a str, + /// Top-level (file-scope) `use` aliases. Equivalent to + /// `aliases_per_scope.get(&[])` when the scoped map was built via + /// `gather_alias_map_scoped`; kept as a separate field so legacy / + /// unit-test callers can populate just this one. + pub alias_map: &'a HashMap>, + /// Per-mod alias maps (output of `gather_alias_map_scoped`). Tests + /// can pass an empty map; the lookup then falls back to + /// `alias_map` for the legacy flat behaviour. + pub aliases_per_scope: &'a ScopedAliasMap, + pub local_symbols: &'a HashSet, + pub local_decl_scopes: &'a HashMap>>, + pub crate_root_modules: &'a HashSet, +} + +/// Inputs to `build_workspace_files_map`. Bundled because the per-file +/// pre-computed maps are themselves several arguments. +pub(crate) struct WorkspaceFilesInputs<'a> { + pub files: &'a [(&'a str, &'a syn::File)], + pub cfg_test_files: &'a HashSet, + pub aliases_per_file: &'a HashMap>>, + pub aliases_scoped_per_file: &'a HashMap, + pub local_symbols_per_file: &'a HashMap, + pub crate_root_modules: &'a HashSet, +} + +// qual:api +/// Pre-build a `FileScope` for every non-cfg-test workspace file. +/// Reused by the type-index build and the call-graph collector so each +/// file's lookup tables only get assembled once. +pub(crate) fn build_workspace_files_map<'a>( + inputs: WorkspaceFilesInputs<'a>, +) -> HashMap> { + static EMPTY_SCOPED: OnceLock = OnceLock::new(); + let empty_scoped: &'static ScopedAliasMap = EMPTY_SCOPED.get_or_init(ScopedAliasMap::new); + let mut out = HashMap::new(); + for (path, _) in inputs.files { + if inputs.cfg_test_files.contains(*path) { + continue; + } + let Some(alias_map) = inputs.aliases_per_file.get(*path) else { + continue; + }; + let Some(local) = inputs.local_symbols_per_file.get(*path) else { + continue; + }; + let aliases_per_scope = inputs + .aliases_scoped_per_file + .get(*path) + .unwrap_or(empty_scoped); + out.insert( + path.to_string(), + FileScope { + path, + alias_map, + aliases_per_scope, + local_symbols: &local.flat, + local_decl_scopes: &local.by_name, + crate_root_modules: inputs.crate_root_modules, + }, + ); + } + out +} + // qual:api /// Flat top-level + nested name set. Backward-compatible shape for /// callers that don't track mod scope. Operation: project flat view. @@ -64,33 +137,29 @@ fn walk_local_symbols(items: &[syn::Item], mod_stack: &mut Vec, out: &mu } // qual:api -/// Walk `mod_stack` outward and return the closest enclosing mod-path -/// in which `name` is declared. Falls back to the empty (top-level) -/// scope when `decl_scopes` is `None` (legacy callers) or the name -/// isn't declared anywhere mappable. Shared by the bindings -/// canonicaliser and the call collector so call canonicals and -/// type-index keys agree on which mod a same-name item belongs to. -/// Operation. +/// Look up the mod-path in which `name` is declared at exactly the +/// current `mod_stack` scope. Rust resolves unqualified names against +/// the current module only — child modules don't inherit parent +/// declarations — so this intentionally does *not* walk outward. +/// +/// An empty `decl_scopes` map means "scope tracking not populated" +/// (test fixtures without `collect_local_symbols_scoped`); the +/// canonicaliser then falls back to flat top-level prepend. A +/// populated map with no exact match returns `None` so the caller +/// skips the same-file branch entirely. pub(crate) fn scope_for_local<'a>( - decl_scopes: Option<&'a HashMap>>>, + decl_scopes: &'a HashMap>>, name: &str, mod_stack: &[String], -) -> &'a [String] { - let Some(scopes) = decl_scopes else { - return &[]; - }; - let Some(candidates) = scopes.get(name) else { - return &[]; - }; - for depth in (0..=mod_stack.len()).rev() { - let prefix = &mod_stack[..depth]; - for path in candidates { - if path.as_slice() == prefix { - return path.as_slice(); - } - } +) -> Option<&'a [String]> { + if decl_scopes.is_empty() { + return Some(&[]); } - candidates.first().map(Vec::as_slice).unwrap_or(&[]) + let candidates = decl_scopes.get(name)?; + candidates + .iter() + .find(|path| path.as_slice() == mod_stack) + .map(Vec::as_slice) } /// Extract the declared ident from an `Item` if it has one diff --git a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs index 39f887d..e79bb06 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs @@ -18,7 +18,7 @@ //! See Task 2 in the v1.1.0 plan for the full test list. use super::bindings::CanonScope; -use super::local_symbols::{collect_local_symbols_scoped, LocalSymbols}; +use super::local_symbols::{collect_local_symbols_scoped, FileScope, LocalSymbols}; use super::signature_params::extract_signature_params; use super::workspace_graph::{ collect_crate_root_modules, impl_self_ty_segments, resolve_impl_self_type, @@ -82,15 +82,19 @@ pub(crate) fn collect_pub_fns_by_layer<'ast>( let alias_map = aliases_per_file.get(*path).unwrap_or(&empty_aliases); let LocalSymbols { flat, by_name } = collect_local_symbols_scoped(ast); let aliases_per_scope = gather_alias_map_scoped(ast); - let mut collector = PubFnCollector { - file: path.to_string(), - found: Vec::new(), - visible_types: &visible_types, + let file = FileScope { + path, alias_map, aliases_per_scope: &aliases_per_scope, local_symbols: &flat, local_decl_scopes: &by_name, crate_root_modules: &crate_root_modules, + }; + let mut collector = PubFnCollector { + file_path: path.to_string(), + file: &file, + found: Vec::new(), + visible_types: &visible_types, impl_stack: Vec::new(), mod_stack: Vec::new(), }; @@ -168,35 +172,19 @@ fn collect_visible_type_names_in_items(items: &[syn::Item], out: &mut HashSet { - file: String, + /// Owning copy of the file path — kept on the collector because + /// `PubFnInfo` is constructed for each fn, each takes the file + /// path by value, and `file.path: &str` from the borrowed + /// `FileScope` doesn't satisfy `String` ownership requirements. + file_path: String, + file: &'vis FileScope<'vis>, found: Vec>, /// Workspace-wide set of type names whose declaration carries a - /// visibility modifier. Impls on any type not in this set are - /// skipped — impls on private types aren't reachable from outside - /// their declaring file. Shared across files so cross-file impls - /// on a `pub struct` are correctly recognised. + /// visibility modifier. Shared across files. visible_types: &'vis HashSet, - /// File's import aliases — used to canonicalise impl self-types so - /// `use crate::app::Session; impl Session { ... }` and the call - /// collector's receiver-tracked canonical agree on the same path. - alias_map: &'vis HashMap>, - /// Per-mod aliases for `use` items inside inline modules. - aliases_per_scope: &'vis ScopedAliasMap, - /// Same-file (top-level + nested) item names for the local-symbol - /// fallback in `canonicalise_type_segments_in_scope`. - local_symbols: &'vis HashSet, - /// Per-name list of declaring mod-paths within `file`. Lets the - /// resolver pick `crate::::::Session` over the flat - /// top-level form when the type lives inside an inline mod. - local_decl_scopes: &'vis HashMap>>, - /// Workspace crate-root module names for Rust 2018+ absolute imports. - crate_root_modules: &'vis HashSet, /// Stack of enclosing `impl` blocks: `(self-type segments, is-visible)`. - /// Merged so the two halves can't drift out of sync. impl_stack: Vec<(Vec, bool)>, - /// Names of enclosing inline `mod inner { ... }` blocks. Feeds - /// `PubFnInfo.mod_stack` so Check B canonical names align with the - /// graph keys / type index (both of which now prefix inline mods). + /// Names of enclosing inline `mod inner { ... }` blocks. mod_stack: Vec, } @@ -217,7 +205,7 @@ impl<'ast, 'vis> PubFnCollector<'ast, 'vis> { sig: &'ast syn::Signature, ) { self.found.push(PubFnInfo { - file: self.file.clone(), + file: self.file_path.clone(), fn_name: name, line, body, @@ -274,12 +262,7 @@ impl<'ast, 'vis> Visit<'ast> for PubFnCollector<'ast, 'vis> { let canonical_segs = resolve_impl_self_type( &node.self_ty, &CanonScope { - alias_map: self.alias_map, - local_symbols: self.local_symbols, - crate_root_modules: self.crate_root_modules, - importing_file: &self.file, - local_decl_scopes: Some(self.local_decl_scopes), - aliases_per_scope: Some(self.aliases_per_scope), + file: self.file, mod_stack: &self.mod_stack, }, ) diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/calls.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/calls.rs index e27cf16..dc394a3 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/calls.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/calls.rs @@ -5,10 +5,11 @@ use crate::adapters::analyzers::architecture::call_parity_rule::calls::{ collect_canonical_calls, FnContext, }; +use crate::adapters::analyzers::architecture::call_parity_rule::local_symbols::FileScope; use crate::adapters::analyzers::architecture::call_parity_rule::workspace_graph::{ collect_crate_root_modules, collect_local_symbols, }; -use crate::adapters::shared::use_tree::gather_alias_map; +use crate::adapters::shared::use_tree::{gather_alias_map, ScopedAliasMap}; use std::collections::{HashMap, HashSet}; fn parse_file(src: &str) -> syn::File { @@ -25,22 +26,38 @@ fn parse_type(src: &str) -> syn::Type { struct FileCtx { file: syn::File, alias_map: HashMap>, + aliases_per_scope: ScopedAliasMap, local_symbols: HashSet, + local_decl_scopes: HashMap>>, crate_root_modules: HashSet, } +impl FileCtx { + fn file_scope<'a>(&'a self, importing_file: &'a str) -> FileScope<'a> { + FileScope { + path: importing_file, + alias_map: &self.alias_map, + aliases_per_scope: &self.aliases_per_scope, + local_symbols: &self.local_symbols, + local_decl_scopes: &self.local_decl_scopes, + crate_root_modules: &self.crate_root_modules, + } + } +} + fn load(src: &str) -> FileCtx { let file = parse_file(src); let alias_map = gather_alias_map(&file); let local_symbols = collect_local_symbols(&file); - // For single-file unit tests the root module set is empty — tests - // that exercise Rust-2018 absolute imports populate it manually. - let crate_root_modules = HashSet::new(); + // Single-file unit tests don't populate the scoped overlays — + // they're left empty so the resolver falls back to flat behaviour. FileCtx { file, alias_map, + aliases_per_scope: ScopedAliasMap::new(), local_symbols, - crate_root_modules, + local_decl_scopes: HashMap::new(), + crate_root_modules: HashSet::new(), } } @@ -114,20 +131,20 @@ fn sig_params(sig: &syn::Signature) -> Vec<(String, &syn::Type)> { .collect() } -fn ctx_for_fn<'a>(fctx: &'a FileCtx, fn_name: &str, importing_file: &'a str) -> FnContext<'a> { +fn ctx_for_fn<'a>( + fctx: &'a FileCtx, + file_scope: &'a FileScope<'a>, + fn_name: &str, +) -> FnContext<'a> { let f = find_fn(&fctx.file, fn_name); FnContext { + file: file_scope, + mod_stack: &[], body: &f.block, signature_params: sig_params(&f.sig), self_type: None, - alias_map: &fctx.alias_map, - local_symbols: &fctx.local_symbols, - crate_root_modules: &fctx.crate_root_modules, - importing_file, workspace_index: None, - mod_stack: &[], - local_decl_scopes: None, - aliases_per_scope: None, + workspace_files: None, } } @@ -156,7 +173,8 @@ fn test_collect_direct_qualified_call() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_search", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_search"); let calls = collect_canonical_calls(&ctx); assert!(calls.contains("crate::application::stats::get_stats")); } @@ -171,7 +189,8 @@ fn test_collect_unqualified_via_use_alias() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_search", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_search"); let calls = collect_canonical_calls(&ctx); assert!(calls.contains("crate::application::stats::get_stats")); } @@ -185,7 +204,8 @@ fn test_collect_unqualified_no_alias_is_bare() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_search", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_search"); let calls = collect_canonical_calls(&ctx); assert!(calls.contains(":foo")); } @@ -202,7 +222,8 @@ fn test_collect_in_semicolon_separated_macro_descends() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_search", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_search"); let calls = collect_canonical_calls(&ctx); assert!( calls.contains(":compute"), @@ -219,7 +240,8 @@ fn test_collect_in_macro_descends() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_search", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_search"); let calls = collect_canonical_calls(&ctx); assert!(calls.contains(":validate")); // The macro itself must not be recorded as a call target. @@ -241,7 +263,8 @@ fn test_collect_self_super_prefix() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_search", "src/cli/mod.rs"); + let fs = fctx.file_scope("src/cli/mod.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_search"); let calls = collect_canonical_calls(&ctx); assert!( calls.contains("crate::cli::helpers::format"), @@ -259,7 +282,8 @@ fn test_collect_turbofish_stripped() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_search", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_search"); let calls = collect_canonical_calls(&ctx); assert!(calls.contains(":Box::new")); } @@ -274,7 +298,8 @@ fn test_collect_closure_body_collected() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_search", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_search"); let calls = collect_canonical_calls(&ctx); assert!(calls.contains(":inner_call")); } @@ -288,7 +313,8 @@ fn test_collect_await_is_not_extra_call() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_search", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_search"); let calls = collect_canonical_calls(&ctx); assert!(calls.contains(":f")); // .await is not a call target @@ -310,17 +336,20 @@ fn test_collect_self_dispatch_in_impl() { let (item, f) = find_impl_fn(&fctx.file, "RlmSession", "search"); let self_ty = canonical_of_impl_self(item); let ctx = FnContext { + file: &FileScope { + path: "src/application/session.rs", + alias_map: &fctx.alias_map, + aliases_per_scope: &ScopedAliasMap::new(), + local_symbols: &fctx.local_symbols, + local_decl_scopes: &HashMap::new(), + crate_root_modules: &fctx.crate_root_modules, + }, + mod_stack: &[], body: &f.block, signature_params: sig_params(&f.sig), self_type: self_ty, - alias_map: &fctx.alias_map, - local_symbols: &fctx.local_symbols, - crate_root_modules: &fctx.crate_root_modules, - importing_file: "src/application/session.rs", workspace_index: None, - mod_stack: &[], - local_decl_scopes: None, - aliases_per_scope: None, + workspace_files: None, }; let calls = collect_canonical_calls(&ctx); assert!( @@ -343,7 +372,8 @@ fn test_tracker_let_constructor_binding() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_search", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_search"); let calls = collect_canonical_calls(&ctx); assert!( calls.contains("crate::app::session::RlmSession::search"), @@ -363,7 +393,8 @@ fn test_tracker_let_type_annotation() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_search", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_search"); let calls = collect_canonical_calls(&ctx); assert!(calls.contains("crate::app::session::RlmSession::search")); } @@ -378,7 +409,8 @@ fn test_tracker_fn_param_type() { } "#, ); - let ctx = ctx_for_fn(&fctx, "handle", "src/mcp/handlers.rs"); + let fs = fctx.file_scope("src/mcp/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "handle"); let calls = collect_canonical_calls(&ctx); assert!(calls.contains("crate::app::session::RlmSession::search")); } @@ -393,7 +425,8 @@ fn test_tracker_fn_param_ref_type() { } "#, ); - let ctx = ctx_for_fn(&fctx, "handle", "src/mcp/handlers.rs"); + let fs = fctx.file_scope("src/mcp/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "handle"); let calls = collect_canonical_calls(&ctx); assert!(calls.contains("crate::app::session::RlmSession::search")); } @@ -409,7 +442,8 @@ fn test_tracker_fn_param_arc_type() { } "#, ); - let ctx = ctx_for_fn(&fctx, "handle", "src/mcp/handlers.rs"); + let fs = fctx.file_scope("src/mcp/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "handle"); let calls = collect_canonical_calls(&ctx); assert!(calls.contains("crate::app::session::RlmSession::search")); } @@ -423,8 +457,9 @@ fn test_tracker_fn_param_box_ref_mut_type() { pub fn b(session: &mut RlmSession) { session.search(1); } "#, ); + let fs = fctx.file_scope("src/mcp/handlers.rs"); for name in &["a", "b"] { - let ctx = ctx_for_fn(&fctx, name, "src/mcp/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, name); let calls = collect_canonical_calls(&ctx); assert!( calls.contains("crate::app::session::RlmSession::search"), @@ -445,7 +480,8 @@ fn test_tracker_alias_resolved_constructor() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_search", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_search"); let calls = collect_canonical_calls(&ctx); assert!(calls.contains("crate::app::session::RlmSession::search")); } @@ -463,7 +499,8 @@ fn test_tracker_shadowing_uses_latest() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_search", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_search"); let calls = collect_canonical_calls(&ctx); assert!(calls.contains("crate::app::session::RlmSession::search")); assert!(!calls.contains("crate::cli::CliSession::search")); @@ -478,7 +515,8 @@ fn test_tracker_unknown_receiver_falls_back_to_method_shape() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_search", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_search"); let calls = collect_canonical_calls(&ctx); assert!(calls.contains(":search")); assert!(!calls.iter().any(|c| c.contains("UnknownType::search"))); @@ -496,7 +534,8 @@ fn test_tracker_closure_inherits_parent_bindings() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_search", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_search"); let calls = collect_canonical_calls(&ctx); assert!(calls.contains("crate::app::session::RlmSession::search")); } @@ -512,7 +551,8 @@ fn test_tracker_factory_helper_unresolved_falls_back_to_method_shape() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_search", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_search"); let calls = collect_canonical_calls(&ctx); assert!(calls.contains(":search")); } @@ -527,7 +567,8 @@ fn test_tracker_in_async_fn() { } "#, ); - let ctx = ctx_for_fn(&fctx, "handle", "src/mcp/handlers.rs"); + let fs = fctx.file_scope("src/mcp/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "handle"); let calls = collect_canonical_calls(&ctx); assert!(calls.contains("crate::app::session::RlmSession::search")); } @@ -543,7 +584,8 @@ fn test_collect_async_block() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_search", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_search"); let calls = collect_canonical_calls(&ctx); assert!( calls.contains("crate::app::session::RlmSession::search"), @@ -555,7 +597,8 @@ fn test_collect_async_block() { #[test] fn test_empty_body_yields_no_calls() { let fctx = load("pub fn f() {}"); - let ctx = ctx_for_fn(&fctx, "f", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "f"); let calls = collect_canonical_calls(&ctx); assert_eq!(calls, HashSet::::new()); } @@ -573,7 +616,8 @@ fn test_local_helper_call_resolves_to_crate_module() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_foo", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_foo"); let calls = collect_canonical_calls(&ctx); assert!( calls.contains("crate::cli::handlers::helper"), @@ -597,7 +641,8 @@ fn test_external_call_without_use_still_falls_to_bare() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_foo", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_foo"); let calls = collect_canonical_calls(&ctx); assert!( calls.contains(":not_a_local_symbol"), @@ -619,7 +664,8 @@ fn test_super_aliased_call_normalises_to_crate_rooted() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_foo", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_foo"); let calls = collect_canonical_calls(&ctx); assert!( calls.contains("crate::cli::stats::get_stats"), @@ -647,7 +693,8 @@ fn test_unqualified_local_type_in_signature_resolves() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_foo", "src/application/session.rs"); + let fs = fctx.file_scope("src/application/session.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_foo"); let calls = collect_canonical_calls(&ctx); assert!( calls.contains("crate::application::session::Session::search"), @@ -672,7 +719,8 @@ fn test_rust2018_absolute_call_without_use_resolves_to_crate_rooted() { "#, &["app"], ); - let ctx = ctx_for_fn(&fctx, "cmd_x", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_x"); let calls = collect_canonical_calls(&ctx); assert!( calls.contains("crate::app::foo"), @@ -699,7 +747,8 @@ fn test_rust2018_absolute_import_resolves_to_crate_rooted() { "#, &["app"], ); - let ctx = ctx_for_fn(&fctx, "cmd_x", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_x"); let calls = collect_canonical_calls(&ctx); assert!( calls.contains("crate::app::foo"), @@ -742,7 +791,8 @@ fn test_top_level_self_as_alias_maps_to_current_file() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_x", "src/util/fs_helpers.rs"); + let fs = fctx.file_scope("src/util/fs_helpers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_x"); let calls = collect_canonical_calls(&ctx); assert!( calls.contains("crate::util::fs_helpers::something"), @@ -768,17 +818,20 @@ fn test_qualified_impl_path_does_not_double_crate() { let (item, f) = find_impl_fn(&fctx.file, "Session", "search"); let self_ty = canonical_of_impl_self(item); let ctx = FnContext { + file: &FileScope { + path: "src/other_file.rs", + alias_map: &fctx.alias_map, + aliases_per_scope: &ScopedAliasMap::new(), + local_symbols: &fctx.local_symbols, + local_decl_scopes: &HashMap::new(), + crate_root_modules: &fctx.crate_root_modules, + }, + mod_stack: &[], body: &f.block, signature_params: sig_params(&f.sig), self_type: self_ty, - alias_map: &fctx.alias_map, - local_symbols: &fctx.local_symbols, - crate_root_modules: &fctx.crate_root_modules, - importing_file: "src/other_file.rs", workspace_index: None, - mod_stack: &[], - local_decl_scopes: None, - aliases_per_scope: None, + workspace_files: None, }; let calls = collect_canonical_calls(&ctx); assert!( @@ -796,23 +849,19 @@ fn test_qualified_impl_path_does_not_double_crate() { /// Helper: build a `FnContext` with a pre-populated workspace index. fn ctx_with_index<'a>( fctx: &'a FileCtx, + file_scope: &'a FileScope<'a>, fn_name: &str, - importing_file: &'a str, index: &'a crate::adapters::analyzers::architecture::call_parity_rule::type_infer::WorkspaceTypeIndex, ) -> FnContext<'a> { let f = find_fn(&fctx.file, fn_name); FnContext { + file: file_scope, + mod_stack: &[], body: &f.block, signature_params: sig_params(&f.sig), self_type: None, - alias_map: &fctx.alias_map, - local_symbols: &fctx.local_symbols, - crate_root_modules: &fctx.crate_root_modules, - importing_file, workspace_index: Some(index), - mod_stack: &[], - local_decl_scopes: None, - aliases_per_scope: None, + workspace_files: None, } } @@ -844,7 +893,8 @@ fn test_inference_fallback_resolves_rlm_pattern() { "crate", "app", "session", "Session", ]))), ); - let ctx = ctx_with_index(&fctx, "cmd_diff", "src/cli/handlers.rs", &index); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_with_index(&fctx, &fs, "cmd_diff", &index); let calls = collect_canonical_calls(&ctx); assert!( calls.contains("crate::app::session::Session::diff"), @@ -874,7 +924,8 @@ fn test_inference_fallback_resolves_field_access() { ("crate::app::Ctx".to_string(), "session".to_string()), CanonicalType::path(["crate", "app", "Session"]), ); - let ctx = ctx_with_index(&fctx, "handle_diff", "src/cli/handlers.rs", &index); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_with_index(&fctx, &fs, "handle_diff", &index); let calls = collect_canonical_calls(&ctx); assert!( calls.contains("crate::app::Session::diff"), @@ -907,7 +958,8 @@ fn test_inference_fallback_on_result_unwrap_chain() { "crate", "app", "session", "Session", ]))), ); - let ctx = ctx_with_index(&fctx, "cmd_direct", "src/cli/handlers.rs", &index); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_with_index(&fctx, &fs, "cmd_direct", &index); let calls = collect_canonical_calls(&ctx); assert!( calls.contains("crate::app::session::Session::diff"), @@ -928,7 +980,8 @@ fn test_existing_fast_path_still_works_without_index() { } "#, ); - let ctx = ctx_for_fn(&fctx, "cmd_search", "src/cli/handlers.rs"); + let fs = fctx.file_scope("src/cli/handlers.rs"); + let ctx = ctx_for_fn(&fctx, &fs, "cmd_search"); let calls = collect_canonical_calls(&ctx); assert!(calls.contains("crate::app::session::RlmSession::search")); } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs index d85b6de..afc36ed 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs @@ -15,11 +15,12 @@ use crate::adapters::analyzers::architecture::call_parity_rule::calls::{ collect_canonical_calls, FnContext, }; +use crate::adapters::analyzers::architecture::call_parity_rule::local_symbols::FileScope; use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::{ CanonicalType, WorkspaceTypeIndex, }; use crate::adapters::analyzers::architecture::call_parity_rule::workspace_graph::collect_local_symbols; -use crate::adapters::shared::use_tree::gather_alias_map; +use crate::adapters::shared::use_tree::{gather_alias_map, ScopedAliasMap}; use std::collections::{HashMap, HashSet}; const SESSION_PATH: &str = "crate::app::session::Session"; @@ -121,17 +122,20 @@ fn sig_params(sig: &syn::Signature) -> Vec<(String, &syn::Type)> { fn run(fx: &RegFixture, index: &WorkspaceTypeIndex, fn_name: &str) -> HashSet { let f = find_fn(&fx.file, fn_name); let ctx = FnContext { + file: &FileScope { + path: "src/cli/handlers.rs", + alias_map: &fx.alias_map, + aliases_per_scope: &ScopedAliasMap::new(), + local_symbols: &fx.local_symbols, + local_decl_scopes: &HashMap::new(), + crate_root_modules: &fx.crate_roots, + }, + mod_stack: &[], body: &f.block, signature_params: sig_params(&f.sig), self_type: None, - alias_map: &fx.alias_map, - local_symbols: &fx.local_symbols, - crate_root_modules: &fx.crate_roots, - importing_file: "src/cli/handlers.rs", workspace_index: Some(index), - mod_stack: &[], - local_decl_scopes: None, - aliases_per_scope: None, + workspace_files: None, }; collect_canonical_calls(&ctx) } @@ -654,7 +658,12 @@ fn type_alias_expands_to_target_via_signature_param() { // Non-generic alias — no params to substitute. index.type_aliases.insert( "crate::cli::handlers::DbRef".to_string(), - (Vec::new(), aliased), + crate::adapters::analyzers::architecture::call_parity_rule::type_infer::workspace_index::AliasDef { + params: Vec::new(), + target: aliased, + decl_file: "src/cli/handlers.rs".to_string(), + decl_mod_stack: Vec::new(), + }, ); // Store::read() method. index.method_returns.insert( diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/access.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/access.rs index 2e6708c..25e6935 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/access.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/access.rs @@ -58,15 +58,11 @@ pub(super) fn infer_await(a: &syn::ExprAwait, ctx: &InferContext<'_>) -> Option< /// Operation: delegate to `resolve_type`. pub(super) fn infer_cast(c: &syn::ExprCast, ctx: &InferContext<'_>) -> Option { let rctx = ResolveContext { - alias_map: ctx.alias_map, - local_symbols: ctx.local_symbols, - crate_root_modules: ctx.crate_root_modules, - importing_file: ctx.importing_file, + file: ctx.file, + mod_stack: ctx.mod_stack, type_aliases: Some(&ctx.workspace.type_aliases), transparent_wrappers: Some(&ctx.workspace.transparent_wrappers), - local_decl_scopes: ctx.local_decl_scopes, - aliases_per_scope: ctx.aliases_per_scope, - mod_stack: ctx.mod_stack, + workspace_files: ctx.workspace_files, }; let ty = resolve_type(&c.ty, &rctx); if ty.is_opaque() { diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/call.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/call.rs index 0bc9672..e8c9c7e 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/call.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/call.rs @@ -140,12 +140,7 @@ fn canonicalise_call_path(segs: &[String], ctx: &InferContext<'_>) -> Option None, })?; let rctx = ResolveContext { - alias_map: ctx.alias_map, - local_symbols: ctx.local_symbols, - crate_root_modules: ctx.crate_root_modules, - importing_file: ctx.importing_file, + file: ctx.file, + mod_stack: ctx.mod_stack, type_aliases: Some(&ctx.workspace.type_aliases), transparent_wrappers: Some(&ctx.workspace.transparent_wrappers), - local_decl_scopes: ctx.local_decl_scopes, - aliases_per_scope: ctx.aliases_per_scope, - mod_stack: ctx.mod_stack, + workspace_files: ctx.workspace_files, }; let resolved = resolve_type(first_ty, &rctx); if matches!(resolved, CanonicalType::Opaque) { diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/mod.rs index 9ec56f4..cc11ff8 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/mod.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/mod.rs @@ -17,10 +17,10 @@ pub mod access; pub mod call; pub mod generics; +use super::super::local_symbols::FileScope; use super::canonical::CanonicalType; use super::workspace_index::WorkspaceTypeIndex; -use crate::adapters::shared::use_tree::ScopedAliasMap; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; /// Look up a scoped variable name → inferred type. Implementations may /// back this by a flat map (tests), a stack of maps, or an adapter over @@ -59,32 +59,22 @@ impl BindingLookup for FlatBindings { } } -/// Inputs to the inference engine. Bundles the workspace index, the -/// per-file resolution pipeline (alias map + local symbols + crate -/// roots + importing file path), the current binding scope, and the -/// enclosing impl's self-type (for `Self::xxx` path resolution). +/// Inputs to the inference engine. Per-file lookup tables live in +/// `file`; the rest is per-call-site. pub struct InferContext<'a> { + pub file: &'a FileScope<'a>, + /// Mod-path of the call site inside `file.path`. Empty for + /// top-level inference. + pub mod_stack: &'a [String], pub workspace: &'a WorkspaceTypeIndex, - pub alias_map: &'a HashMap>, - pub local_symbols: &'a HashSet, - pub crate_root_modules: &'a HashSet, - pub importing_file: &'a str, pub bindings: &'a dyn BindingLookup, /// Canonical segments of the enclosing `impl T { ... }`'s self-type, /// if we're currently inferring inside an impl body. `None` for - /// free-fn contexts. Used to resolve `Self::method(...)` calls. + /// free-fn contexts. pub self_type: Option>, - /// Mod-path of the call site inside `importing_file`. Empty for - /// top-level inference; populated by the call collector so - /// `inner::make()` from within `mod inner` produces the same - /// `crate::file::inner::make` key the index stores. - pub mod_stack: &'a [String], - /// Per-name list of declaring mod-paths within `importing_file`. - /// `None` for legacy / unit-test callers. - pub local_decl_scopes: Option<&'a HashMap>>>, - /// Per-mod alias maps for `use` items inside inline modules. - /// `None` falls back to `alias_map`. - pub aliases_per_scope: Option<&'a ScopedAliasMap>, + /// All workspace `FileScope`s, for cross-module alias resolution. + /// `None` for unit-test fixtures. + pub workspace_files: Option<&'a HashMap>>, } // qual:api diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs index fbfbedc..758fac4 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs @@ -87,15 +87,11 @@ fn bind_annotated( ) { let resolve = |ty: &syn::Type| { let rctx = ResolveContext { - alias_map: ctx.alias_map, - local_symbols: ctx.local_symbols, - crate_root_modules: ctx.crate_root_modules, - importing_file: ctx.importing_file, + file: ctx.file, + mod_stack: ctx.mod_stack, type_aliases: Some(&ctx.workspace.type_aliases), transparent_wrappers: Some(&ctx.workspace.transparent_wrappers), - local_decl_scopes: ctx.local_decl_scopes, - aliases_per_scope: ctx.aliases_per_scope, - mod_stack: ctx.mod_stack, + workspace_files: ctx.workspace_files, }; resolve_type(ty, &rctx) }; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs index fae108a..46cef90 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs @@ -19,44 +19,29 @@ //! semantics. use super::super::bindings::{canonicalise_type_segments_in_scope, CanonScope}; +use super::super::local_symbols::FileScope; use super::alias_substitution::substitute_alias_args; use super::canonical::CanonicalType; -use crate::adapters::shared::use_tree::ScopedAliasMap; use std::collections::{HashMap, HashSet}; -/// Resolution inputs, bundled so the recursive calls don't drag a long -/// parameter list around. +/// Resolution inputs. Per-file lookup tables live in `file`; the +/// remaining fields are workspace-wide or per-call-site. pub(crate) struct ResolveContext<'a> { - pub alias_map: &'a HashMap>, - pub local_symbols: &'a HashSet, - pub crate_root_modules: &'a HashSet, - pub importing_file: &'a str, - /// workspace-wide type aliases. `None` means the caller - /// doesn't need alias expansion (the workspace-index build phase, - /// where the alias map is still being populated). Inference paths - /// pass `Some(&workspace.type_aliases)`. The stored tuple carries - /// the alias's generic-param names plus its target — use-site args - /// are substituted into the target before recursion. - pub type_aliases: Option<&'a HashMap, syn::Type)>>, - /// user-defined transparent wrappers — the last-ident - /// names (e.g. `"State"`, `"Extension"`, `"Data"`) that are peeled - /// just like `Arc` / `Box`. `None` means only stdlib wrappers are - /// peeled. - pub transparent_wrappers: Option<&'a HashSet>, - /// Per-name list of mod-paths-within-file where the name is - /// declared. `None` falls back to flat top-level prepend so legacy - /// callers behave as before. Inline-mod-aware callers pass - /// `Some(&workspace.local_decl_scopes_per_file[file])` (or the - /// build-time equivalent). - pub local_decl_scopes: Option<&'a HashMap>>>, - /// Per-mod alias maps for `use` items declared inside inline mods. - /// `None` falls back to the flat `alias_map`. - pub aliases_per_scope: Option<&'a ScopedAliasMap>, - /// Mod-path inside `importing_file` of the type / fn being resolved. - /// Empty when the caller is at file-top-level or doesn't track mod - /// scope. Used together with `local_decl_scopes` to pick the - /// closest-enclosing declaration of a single-ident type name. + pub file: &'a FileScope<'a>, pub mod_stack: &'a [String], + /// Workspace-wide type aliases. `None` during pass 1 of the index + /// build (the alias collector itself); `Some(&…)` afterwards. + pub type_aliases: Option<&'a HashMap>, + /// User-defined transparent wrappers (`State`, `Extension`, …). + /// `None` means only stdlib wrappers are peeled. + pub transparent_wrappers: Option<&'a HashSet>, + /// Per-file scopes for the whole workspace. `Some(&…)` lets alias + /// expansion switch to the alias's decl-site scope when resolving + /// the target — without this, `type Repo = Arc;` declared + /// in `domain` and used from `app` would try to resolve `Store` in + /// `app`'s scope and fail. `None` falls back to using the + /// use-site's scope (legacy / unit-test path). + pub workspace_files: Option<&'a HashMap>>, } /// Hard recursion cap for `resolve_type_with_depth`. Guards against @@ -69,12 +54,7 @@ const MAX_RESOLVE_DEPTH: u8 = 32; /// pure field projection. fn canon_scope<'a>(ctx: &'a ResolveContext<'a>) -> CanonScope<'a> { CanonScope { - alias_map: ctx.alias_map, - local_symbols: ctx.local_symbols, - crate_root_modules: ctx.crate_root_modules, - importing_file: ctx.importing_file, - local_decl_scopes: ctx.local_decl_scopes, - aliases_per_scope: ctx.aliases_per_scope, + file: ctx.file, mod_stack: ctx.mod_stack, } } @@ -149,6 +129,12 @@ fn resolve_bound_list( if is_marker_trait(&trait_bound.path) { continue; } + // `impl Future` deserves the same `Future(T)` shape + // the path-form `Future` produces, so `.await` on + // the result resolves through the combinator table. + if let Some(args) = future_args(&trait_bound.path) { + return wrap_future_output(args, ctx, 0); + } let segs: Vec = trait_bound .path .segments @@ -162,6 +148,14 @@ fn resolve_bound_list( CanonicalType::Opaque } +/// Return the path arguments of a `Future` trait bound (covers bare +/// `Future`, `std::future::Future`, and any other path ending in +/// `Future`); `None` when the last segment isn't `Future`. +fn future_args(path: &syn::Path) -> Option<&syn::PathArguments> { + let last = path.segments.last()?; + (last.ident == "Future").then_some(&last.arguments) +} + /// Marker traits (plus common auto-derive names) that are skipped when /// picking the dispatch-relevant trait from a `dyn T1 + T2` bound set. /// Kept as a const so the list is greppable and easy to extend. @@ -289,12 +283,12 @@ fn peel_single_generic( } /// Resolve a non-wrapper path through the shared canonicalisation -/// pipeline (alias map / local symbols / crate roots). If the canonical -/// matches a recorded workspace type-alias, the alias target is -/// substituted with use-site generic args and recursively resolved. -/// Operation: closure-hidden calls + alias dispatch. +/// pipeline. On an alias hit, substitute use-site generic args into +/// the alias target and recursively resolve it against the alias's +/// *own* declaring scope — without that, an imported alias like +/// `type Repo = Arc` would try to resolve `Store` in the +/// use-site's scope, where it isn't necessarily known. fn resolve_generic_path(path: &syn::Path, ctx: &ResolveContext<'_>, depth: u8) -> CanonicalType { - let recurse = |t: &syn::Type| resolve_type_with_depth(t, ctx, depth); let canonicalise = |segs: &[String]| canonicalise_type_segments_in_scope(segs, &canon_scope(ctx)); let segments: Vec = path.segments.iter().map(|s| s.ident.to_string()).collect(); @@ -302,13 +296,39 @@ fn resolve_generic_path(path: &syn::Path, ctx: &ResolveContext<'_>, depth: u8) - return CanonicalType::Opaque; }; let key = resolved.join("::"); - if let Some((params, target)) = ctx.type_aliases.and_then(|m| m.get(&key)) { - let expanded = substitute_alias_args(target, params, path); - return recurse(&expanded); + if let Some(alias) = ctx.type_aliases.and_then(|m| m.get(&key)) { + let expanded = substitute_alias_args(&alias.target, &alias.params, path); + return resolve_in_alias_scope(&expanded, alias, ctx, depth); } CanonicalType::Path(resolved) } +/// Resolve `target` (an alias body, post-substitution) against the +/// alias's declaring scope. Falls back to the use-site scope when +/// `workspace_files` lacks an entry for `decl_file` (legacy / unit- +/// test paths). Operation: scope swap + recurse. +fn resolve_in_alias_scope( + target: &syn::Type, + alias: &super::workspace_index::AliasDef, + ctx: &ResolveContext<'_>, + depth: u8, +) -> CanonicalType { + let decl_file = ctx + .workspace_files + .and_then(|files| files.get(&alias.decl_file)); + let Some(decl_file) = decl_file else { + return resolve_type_with_depth(target, ctx, depth); + }; + let decl_ctx = ResolveContext { + file: decl_file, + mod_stack: &alias.decl_mod_stack, + type_aliases: ctx.type_aliases, + transparent_wrappers: ctx.transparent_wrappers, + workspace_files: ctx.workspace_files, + }; + resolve_type_with_depth(target, &decl_ctx, depth) +} + /// Extract the type at position `idx` from angle-bracketed generic args. /// Lifetimes / const args are skipped; only type args count. fn generic_type_arg(args: &syn::PathArguments, idx: usize) -> Option<&syn::Type> { diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs index 48c31cf..5af3ead 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs @@ -170,9 +170,11 @@ fn test_slice_receiver_is_none() { fn test_result_chain_unwrap_then_field() { // Verifies that combinator lookup produces a `Path` the next layer // of inference can index. This is the rlm-bug unblocking pattern. + use crate::adapters::analyzers::architecture::call_parity_rule::local_symbols::FileScope; use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::{ infer_type, FlatBindings, InferContext, WorkspaceTypeIndex, }; + use crate::adapters::shared::use_tree::ScopedAliasMap; use std::collections::{HashMap, HashSet}; let mut index = WorkspaceTypeIndex::new(); @@ -186,19 +188,24 @@ fn test_result_chain_unwrap_then_field() { CanonicalType::Result(Box::new(CanonicalType::path(["crate", "app", "Session"]))), ); let alias_map = HashMap::new(); + let aliases_per_scope = ScopedAliasMap::new(); let local_symbols = HashSet::new(); + let local_decl_scopes = HashMap::new(); let crate_roots = HashSet::new(); let ctx = InferContext { + file: &FileScope { + path: "src/app/test.rs", + alias_map: &alias_map, + aliases_per_scope: &aliases_per_scope, + local_symbols: &local_symbols, + local_decl_scopes: &local_decl_scopes, + crate_root_modules: &crate_roots, + }, + mod_stack: &[], workspace: &index, - alias_map: &alias_map, - local_symbols: &local_symbols, - crate_root_modules: &crate_roots, - importing_file: "src/app/test.rs", bindings: &bindings, self_type: None, - mod_stack: &[], - local_decl_scopes: None, - aliases_per_scope: None, + workspace_files: None, }; let expr: syn::Expr = syn::parse_str("res.unwrap().id").expect("parse"); let t = infer_type(&expr, &ctx).expect("chain resolved"); diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_access.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_access.rs index f01822e..04a7254 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_access.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_access.rs @@ -8,7 +8,7 @@ use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::{ fn infer(f: &TypeInferFixture, src: &str) -> Option { let expr: syn::Expr = syn::parse_str(src).ok()?; - infer_type(&expr, &f.ctx()) + infer_type(&expr, &f.ctx(&f.file_scope())) } // ── Field access ───────────────────────────────────────────────── diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_call.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_call.rs index 0dcc772..39a523a 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_call.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_call.rs @@ -7,7 +7,7 @@ use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::{ fn infer(f: &TypeInferFixture, src: &str) -> Option { let expr: syn::Expr = syn::parse_str(src).ok()?; - infer_type(&expr, &f.ctx()) + infer_type(&expr, &f.ctx(&f.file_scope())) } // ── Path expressions (bare idents) ─────────────────────────────── diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs index 10eafd4..776a673 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs @@ -11,7 +11,7 @@ fn bindings( matched: CanonicalType, ) -> Vec<(String, CanonicalType)> { let pat = parse_pat(pat_src); - extract_bindings(&pat, &matched, &f.ctx()) + extract_bindings(&pat, &matched, &f.ctx(&f.file_scope())) } // ── Pat::Ident ─────────────────────────────────────────────────── diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_iterator.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_iterator.rs index c59560e..320d719 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_iterator.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_iterator.rs @@ -11,7 +11,7 @@ fn for_bindings( iter: CanonicalType, ) -> Vec<(String, CanonicalType)> { let pat = parse_pat(pat_src); - extract_for_bindings(&pat, &iter, &f.ctx()) + extract_for_bindings(&pat, &iter, &f.ctx(&f.file_scope())) } #[test] diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs index 4d61cef..bb15216 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs @@ -3,32 +3,25 @@ //! The `resolve` module is `pub(super)` — these tests live in the same //! crate and reach it via the in-crate module path. +use crate::adapters::analyzers::architecture::call_parity_rule::local_symbols::FileScope; use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::canonical::CanonicalType; use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::resolve::{ resolve_type, ResolveContext, }; +use crate::adapters::shared::use_tree::ScopedAliasMap; use std::collections::{HashMap, HashSet}; fn parse_type(src: &str) -> syn::Type { syn::parse_str(src).expect("parse type") } -fn ctx<'a>( - alias_map: &'a HashMap>, - local_symbols: &'a HashSet, - crate_root_modules: &'a HashSet, - importing_file: &'a str, -) -> ResolveContext<'a> { +fn ctx<'a>(file: &'a FileScope<'a>) -> ResolveContext<'a> { ResolveContext { - alias_map, - local_symbols, - crate_root_modules, - importing_file, + file, + mod_stack: &[], type_aliases: None, transparent_wrappers: None, - local_decl_scopes: None, - aliases_per_scope: None, - mod_stack: &[], + workspace_files: None, } } @@ -39,7 +32,17 @@ fn test_bare_path_resolves_via_local_symbols() { local.insert("Session".to_string()); let roots = HashSet::new(); let ty = parse_type("Session"); - let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/app/session.rs")); + let resolved = resolve_type( + &ty, + &ctx(&FileScope { + path: "src/app/session.rs", + alias_map: &alias_map, + aliases_per_scope: &ScopedAliasMap::new(), + local_symbols: &local, + local_decl_scopes: &HashMap::new(), + crate_root_modules: &roots, + }), + ); assert_eq!( resolved, CanonicalType::path(["crate", "app", "session", "Session"]) @@ -53,7 +56,17 @@ fn test_reference_type_strips_and_recurses() { local.insert("Session".to_string()); let roots = HashSet::new(); let ty = parse_type("&Session"); - let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/app/session.rs")); + let resolved = resolve_type( + &ty, + &ctx(&FileScope { + path: "src/app/session.rs", + alias_map: &alias_map, + aliases_per_scope: &ScopedAliasMap::new(), + local_symbols: &local, + local_decl_scopes: &HashMap::new(), + crate_root_modules: &roots, + }), + ); assert_eq!( resolved, CanonicalType::path(["crate", "app", "session", "Session"]) @@ -67,7 +80,17 @@ fn test_result_wraps_inner() { local.insert("Session".to_string()); let roots = HashSet::new(); let ty = parse_type("Result"); - let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/app/session.rs")); + let resolved = resolve_type( + &ty, + &ctx(&FileScope { + path: "src/app/session.rs", + alias_map: &alias_map, + aliases_per_scope: &ScopedAliasMap::new(), + local_symbols: &local, + local_decl_scopes: &HashMap::new(), + crate_root_modules: &roots, + }), + ); match resolved { CanonicalType::Result(inner) => { assert_eq!( @@ -86,7 +109,17 @@ fn test_option_wraps_inner() { local.insert("T".to_string()); let roots = HashSet::new(); let ty = parse_type("Option"); - let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/foo.rs")); + let resolved = resolve_type( + &ty, + &ctx(&FileScope { + path: "src/foo.rs", + alias_map: &alias_map, + aliases_per_scope: &ScopedAliasMap::new(), + local_symbols: &local, + local_decl_scopes: &HashMap::new(), + crate_root_modules: &roots, + }), + ); assert!(matches!(resolved, CanonicalType::Option(_))); } @@ -97,7 +130,17 @@ fn test_arc_is_stripped() { local.insert("Session".to_string()); let roots = HashSet::new(); let ty = parse_type("Arc"); - let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/app/session.rs")); + let resolved = resolve_type( + &ty, + &ctx(&FileScope { + path: "src/app/session.rs", + alias_map: &alias_map, + aliases_per_scope: &ScopedAliasMap::new(), + local_symbols: &local, + local_decl_scopes: &HashMap::new(), + crate_root_modules: &roots, + }), + ); assert_eq!( resolved, CanonicalType::path(["crate", "app", "session", "Session"]) @@ -113,7 +156,17 @@ fn test_nested_smart_pointers_strip_to_inner() { // Only smart-pointer wrappers (`Arc` / `Box` / `Rc` / `Cow`) are // Deref-transparent, so nesting them still reaches the inner type. let ty = parse_type("Arc>"); - let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/app/session.rs")); + let resolved = resolve_type( + &ty, + &ctx(&FileScope { + path: "src/app/session.rs", + alias_map: &alias_map, + aliases_per_scope: &ScopedAliasMap::new(), + local_symbols: &local, + local_decl_scopes: &HashMap::new(), + crate_root_modules: &roots, + }), + ); assert_eq!( resolved, CanonicalType::path(["crate", "app", "session", "Session"]) @@ -129,7 +182,17 @@ fn test_rwlock_is_not_peeled() { // `RwLock::read()` returns a guard, not the inner value — peeling // it would synthesize bogus `Session::read` edges. Stays `Opaque`. let ty = parse_type("Arc>"); - let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/app/session.rs")); + let resolved = resolve_type( + &ty, + &ctx(&FileScope { + path: "src/app/session.rs", + alias_map: &alias_map, + aliases_per_scope: &ScopedAliasMap::new(), + local_symbols: &local, + local_decl_scopes: &HashMap::new(), + crate_root_modules: &roots, + }), + ); assert_eq!(resolved, CanonicalType::Opaque); } @@ -140,7 +203,17 @@ fn test_vec_becomes_slice() { local.insert("Handler".to_string()); let roots = HashSet::new(); let ty = parse_type("Vec"); - let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/foo.rs")); + let resolved = resolve_type( + &ty, + &ctx(&FileScope { + path: "src/foo.rs", + alias_map: &alias_map, + aliases_per_scope: &ScopedAliasMap::new(), + local_symbols: &local, + local_decl_scopes: &HashMap::new(), + crate_root_modules: &roots, + }), + ); assert!(matches!(resolved, CanonicalType::Slice(_))); } @@ -151,7 +224,17 @@ fn test_hashmap_keeps_value_type() { local.insert("Handler".to_string()); let roots = HashSet::new(); let ty = parse_type("HashMap"); - let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/foo.rs")); + let resolved = resolve_type( + &ty, + &ctx(&FileScope { + path: "src/foo.rs", + alias_map: &alias_map, + aliases_per_scope: &ScopedAliasMap::new(), + local_symbols: &local, + local_decl_scopes: &HashMap::new(), + crate_root_modules: &roots, + }), + ); match resolved { CanonicalType::Map(inner) => { assert_eq!(*inner, CanonicalType::path(["crate", "foo", "Handler"])); @@ -167,7 +250,17 @@ fn test_array_becomes_slice() { local.insert("T".to_string()); let roots = HashSet::new(); let ty = parse_type("[T; 4]"); - let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/foo.rs")); + let resolved = resolve_type( + &ty, + &ctx(&FileScope { + path: "src/foo.rs", + alias_map: &alias_map, + aliases_per_scope: &ScopedAliasMap::new(), + local_symbols: &local, + local_decl_scopes: &HashMap::new(), + crate_root_modules: &roots, + }), + ); assert!(matches!(resolved, CanonicalType::Slice(_))); } @@ -178,7 +271,17 @@ fn test_slice_type_becomes_slice() { local.insert("T".to_string()); let roots = HashSet::new(); let ty = parse_type("&[T]"); - let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/foo.rs")); + let resolved = resolve_type( + &ty, + &ctx(&FileScope { + path: "src/foo.rs", + alias_map: &alias_map, + aliases_per_scope: &ScopedAliasMap::new(), + local_symbols: &local, + local_decl_scopes: &HashMap::new(), + crate_root_modules: &roots, + }), + ); assert!(matches!(resolved, CanonicalType::Slice(_))); } @@ -188,7 +291,17 @@ fn test_trait_object_unresolved_is_opaque() { let local = HashSet::new(); let roots = HashSet::new(); let ty = parse_type("Box"); - let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/foo.rs")); + let resolved = resolve_type( + &ty, + &ctx(&FileScope { + path: "src/foo.rs", + alias_map: &alias_map, + aliases_per_scope: &ScopedAliasMap::new(), + local_symbols: &local, + local_decl_scopes: &HashMap::new(), + crate_root_modules: &roots, + }), + ); // Box → strip Box → dyn T — when T isn't resolvable (not in // local symbols / alias map / crate roots), stays Opaque. assert_eq!(resolved, CanonicalType::Opaque); @@ -201,7 +314,17 @@ fn test_trait_object_resolves_via_local_symbols() { local.insert("Handler".to_string()); let roots = HashSet::new(); let ty = parse_type("Box"); - let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/app/mod.rs")); + let resolved = resolve_type( + &ty, + &ctx(&FileScope { + path: "src/app/mod.rs", + alias_map: &alias_map, + aliases_per_scope: &ScopedAliasMap::new(), + local_symbols: &local, + local_decl_scopes: &HashMap::new(), + crate_root_modules: &roots, + }), + ); assert_eq!( resolved, CanonicalType::TraitBound(vec![ @@ -219,7 +342,17 @@ fn test_impl_trait_unresolved_is_opaque() { let roots = HashSet::new(); // `Iterator` isn't in local symbols / alias map — stays Opaque. let ty = parse_type("impl Iterator"); - let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/foo.rs")); + let resolved = resolve_type( + &ty, + &ctx(&FileScope { + path: "src/foo.rs", + alias_map: &alias_map, + aliases_per_scope: &ScopedAliasMap::new(), + local_symbols: &local, + local_decl_scopes: &HashMap::new(), + crate_root_modules: &roots, + }), + ); assert_eq!(resolved, CanonicalType::Opaque); } @@ -232,7 +365,17 @@ fn test_impl_trait_resolves_to_trait_bound() { // `impl Handler` return-type resolves to `TraitBound(Handler)` so // trait-dispatch over-approximation can fire on the method call. let ty = parse_type("impl Handler + Send"); - let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/app/mod.rs")); + let resolved = resolve_type( + &ty, + &ctx(&FileScope { + path: "src/app/mod.rs", + alias_map: &alias_map, + aliases_per_scope: &ScopedAliasMap::new(), + local_symbols: &local, + local_decl_scopes: &HashMap::new(), + crate_root_modules: &roots, + }), + ); assert_eq!( resolved, CanonicalType::TraitBound(vec![ @@ -249,7 +392,17 @@ fn test_unknown_external_path_is_opaque() { let local = HashSet::new(); let roots = HashSet::new(); let ty = parse_type("external_crate::UnknownType"); - let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/foo.rs")); + let resolved = resolve_type( + &ty, + &ctx(&FileScope { + path: "src/foo.rs", + alias_map: &alias_map, + aliases_per_scope: &ScopedAliasMap::new(), + local_symbols: &local, + local_decl_scopes: &HashMap::new(), + crate_root_modules: &roots, + }), + ); assert_eq!(resolved, CanonicalType::Opaque); } @@ -268,7 +421,17 @@ fn test_aliased_path_resolves_via_alias_map() { let local = HashSet::new(); let roots = HashSet::new(); let ty = parse_type("Session"); - let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/cli/handlers.rs")); + let resolved = resolve_type( + &ty, + &ctx(&FileScope { + path: "src/cli/handlers.rs", + alias_map: &alias_map, + aliases_per_scope: &ScopedAliasMap::new(), + local_symbols: &local, + local_decl_scopes: &HashMap::new(), + crate_root_modules: &roots, + }), + ); assert_eq!( resolved, CanonicalType::path(["crate", "app", "session", "Session"]) @@ -282,6 +445,16 @@ fn test_future_wraps_output() { local.insert("Response".to_string()); let roots = HashSet::new(); let ty = parse_type("Future"); - let resolved = resolve_type(&ty, &ctx(&alias_map, &local, &roots, "src/foo.rs")); + let resolved = resolve_type( + &ty, + &ctx(&FileScope { + path: "src/foo.rs", + alias_map: &alias_map, + aliases_per_scope: &ScopedAliasMap::new(), + local_symbols: &local, + local_decl_scopes: &HashMap::new(), + crate_root_modules: &roots, + }), + ); assert!(matches!(resolved, CanonicalType::Future(_))); } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/support.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/support.rs index 4e3494e..d27c078 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/support.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/support.rs @@ -6,9 +6,11 @@ //! directly — no `&mut self` helper methods, which keeps the struct //! SRP-clean and NMS-compliant. +use crate::adapters::analyzers::architecture::call_parity_rule::local_symbols::FileScope; use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::{ BindingLookup, FlatBindings, InferContext, WorkspaceTypeIndex, }; +use crate::adapters::shared::use_tree::ScopedAliasMap; use std::collections::{HashMap, HashSet}; /// Parse a Rust pattern source string into `syn::Pat`. Tries `let …` @@ -48,7 +50,9 @@ fn parse_pat_as_match_arm(src: &str) -> syn::Pat { pub(super) struct TypeInferFixture { pub index: WorkspaceTypeIndex, pub alias_map: HashMap>, + pub aliases_per_scope: ScopedAliasMap, pub local_symbols: HashSet, + pub local_decl_scopes: HashMap>>, pub crate_roots: HashSet, pub bindings: FlatBindings, pub file_path: String, @@ -60,7 +64,9 @@ impl TypeInferFixture { Self { index: WorkspaceTypeIndex::new(), alias_map: HashMap::new(), + aliases_per_scope: ScopedAliasMap::new(), local_symbols: HashSet::new(), + local_decl_scopes: HashMap::new(), crate_roots: HashSet::new(), bindings: FlatBindings::new(), file_path: "src/app/test.rs".to_string(), @@ -68,18 +74,25 @@ impl TypeInferFixture { } } - pub fn ctx(&self) -> InferContext<'_> { - InferContext { - workspace: &self.index, + pub fn file_scope(&self) -> FileScope<'_> { + FileScope { + path: &self.file_path, alias_map: &self.alias_map, + aliases_per_scope: &self.aliases_per_scope, local_symbols: &self.local_symbols, + local_decl_scopes: &self.local_decl_scopes, crate_root_modules: &self.crate_roots, - importing_file: &self.file_path, + } + } + + pub fn ctx<'a>(&'a self, file_scope: &'a FileScope<'a>) -> InferContext<'a> { + InferContext { + file: file_scope, + mod_stack: &[], + workspace: &self.index, bindings: &self.bindings as &dyn BindingLookup, self_type: self.self_type.clone(), - mod_stack: &[], - local_decl_scopes: None, - aliases_per_scope: None, + workspace_files: None, } } } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs index 6b09638..d419327 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs @@ -5,7 +5,7 @@ //! behaviour. use crate::adapters::analyzers::architecture::call_parity_rule::local_symbols::{ - collect_local_symbols_scoped, LocalSymbols, + build_workspace_files_map, collect_local_symbols_scoped, LocalSymbols, WorkspaceFilesInputs, }; use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::{ build_workspace_type_index, CanonicalType, WorkspaceIndexInputs, @@ -71,15 +71,26 @@ fn crate_roots(paths: &[&str]) -> HashSet { #[test] fn test_empty_workspace_produces_empty_index() { let fix = fixture(&[]); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &HashSet::new(), - crate_root_modules: &HashSet::new(), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = &HashSet::new(); + let roots = &HashSet::new(); + let wraps = &HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: cfg_test, + transparent_wrappers: wraps, + }) + }; assert!(index.struct_fields.is_empty()); assert!(index.method_returns.is_empty()); assert!(index.fn_returns.is_empty()); @@ -99,15 +110,26 @@ fn test_struct_with_named_field_is_indexed() { pub struct Session { pub id: Id } "#, )]); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &HashSet::new(), - crate_root_modules: &crate_roots(&["src/app/session.rs"]), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = &HashSet::new(); + let roots = &crate_roots(&["src/app/session.rs"]); + let wraps = &HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: cfg_test, + transparent_wrappers: wraps, + }) + }; let field = index.struct_field("crate::app::session::Session", "id"); assert_eq!( field, @@ -124,15 +146,26 @@ fn test_struct_field_with_arc_is_stripped() { pub struct Ctx { pub inner: std::sync::Arc } "#, )]); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &HashSet::new(), - crate_root_modules: &crate_roots(&["src/app/context.rs"]), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = &HashSet::new(); + let roots = &crate_roots(&["src/app/context.rs"]); + let wraps = &HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: cfg_test, + transparent_wrappers: wraps, + }) + }; let field = index.struct_field("crate::app::context::Ctx", "inner"); assert_eq!( field, @@ -143,15 +176,26 @@ fn test_struct_field_with_arc_is_stripped() { #[test] fn test_tuple_struct_is_not_indexed() { let fix = fixture(&[("src/app/foo.rs", "pub struct Id(pub String);")]); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &HashSet::new(), - crate_root_modules: &crate_roots(&["src/app/foo.rs"]), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = &HashSet::new(); + let roots = &crate_roots(&["src/app/foo.rs"]); + let wraps = &HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: cfg_test, + transparent_wrappers: wraps, + }) + }; assert!(index.struct_fields.is_empty()); } @@ -163,15 +207,26 @@ fn test_struct_field_with_opaque_type_is_skipped() { pub struct Ctx { pub x: external_crate::Unknown } "#, )]); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &HashSet::new(), - crate_root_modules: &crate_roots(&["src/app/foo.rs"]), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = &HashSet::new(); + let roots = &crate_roots(&["src/app/foo.rs"]); + let wraps = &HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: cfg_test, + transparent_wrappers: wraps, + }) + }; assert!(index.struct_fields.is_empty()); } @@ -189,15 +244,26 @@ fn test_inherent_method_with_concrete_return() { } "#, )]); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &HashSet::new(), - crate_root_modules: &crate_roots(&["src/app/session.rs"]), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = &HashSet::new(); + let roots = &crate_roots(&["src/app/session.rs"]); + let wraps = &HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: cfg_test, + transparent_wrappers: wraps, + }) + }; let ret = index.method_return("crate::app::session::Session", "diff"); assert_eq!( ret, @@ -220,15 +286,26 @@ fn test_method_returning_result_wraps() { } "#, )]); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &HashSet::new(), - crate_root_modules: &crate_roots(&["src/app/session.rs"]), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = &HashSet::new(); + let roots = &crate_roots(&["src/app/session.rs"]); + let wraps = &HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: cfg_test, + transparent_wrappers: wraps, + }) + }; let ret = index .method_return("crate::app::session::Session", "diff") .expect("method indexed"); @@ -250,15 +327,26 @@ fn test_method_with_unit_return_is_not_indexed() { impl S { pub fn bump(&self) {} } "#, )]); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &HashSet::new(), - crate_root_modules: &crate_roots(&["src/app/foo.rs"]), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = &HashSet::new(); + let roots = &crate_roots(&["src/app/foo.rs"]); + let wraps = &HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: cfg_test, + transparent_wrappers: wraps, + }) + }; assert!(index.method_returns.is_empty()); } @@ -271,15 +359,26 @@ fn test_method_with_impl_trait_return_is_not_indexed() { impl S { pub fn iter(&self) -> impl Iterator { std::iter::empty() } } "#, )]); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &HashSet::new(), - crate_root_modules: &crate_roots(&["src/app/foo.rs"]), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = &HashSet::new(); + let roots = &crate_roots(&["src/app/foo.rs"]); + let wraps = &HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: cfg_test, + transparent_wrappers: wraps, + }) + }; assert!(index.method_returns.is_empty()); } @@ -294,15 +393,26 @@ fn test_trait_impl_method_is_indexed_by_receiver_type() { impl Convert for S { fn to(&self) -> T { T } } "#, )]); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &HashSet::new(), - crate_root_modules: &crate_roots(&["src/app/foo.rs"]), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = &HashSet::new(); + let roots = &crate_roots(&["src/app/foo.rs"]); + let wraps = &HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: cfg_test, + transparent_wrappers: wraps, + }) + }; // Keyed by the concrete receiver type S, NOT by the trait. let ret = index.method_return("crate::app::foo::S", "to"); assert_eq!( @@ -322,15 +432,26 @@ fn test_free_fn_return_is_indexed() { pub fn make_session() -> Session { Session } "#, )]); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &HashSet::new(), - crate_root_modules: &crate_roots(&["src/app/make.rs"]), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = &HashSet::new(); + let roots = &crate_roots(&["src/app/make.rs"]); + let wraps = &HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: cfg_test, + transparent_wrappers: wraps, + }) + }; let ret = index.fn_return("crate::app::make::make_session"); assert_eq!( ret, @@ -346,15 +467,26 @@ fn test_generic_return_type_is_opaque_and_not_indexed() { pub fn get() -> T { unimplemented!() } "#, )]); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &HashSet::new(), - crate_root_modules: &crate_roots(&["src/app/make.rs"]), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = &HashSet::new(); + let roots = &crate_roots(&["src/app/make.rs"]); + let wraps = &HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: cfg_test, + transparent_wrappers: wraps, + }) + }; // Generic T has no alias/local-symbol entry → Opaque → skipped. assert!(index.fn_returns.is_empty()); } @@ -371,15 +503,26 @@ fn test_fn_inside_inline_mod_keys_include_mod_name() { } "#, )]); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &HashSet::new(), - crate_root_modules: &crate_roots(&["src/app/mod.rs"]), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = &HashSet::new(); + let roots = &crate_roots(&["src/app/mod.rs"]); + let wraps = &HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: cfg_test, + transparent_wrappers: wraps, + }) + }; // With inline-mod tracking the key is `crate::app::inner::make_session`, // matching how `inner::make_session()` canonicalises at a call site. assert!( @@ -402,15 +545,26 @@ fn test_fn_inside_inline_mod_resolves_inner_return_type() { } "#, )]); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &HashSet::new(), - crate_root_modules: &crate_roots(&["src/app/mod.rs"]), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = &HashSet::new(); + let roots = &crate_roots(&["src/app/mod.rs"]); + let wraps = &HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: cfg_test, + transparent_wrappers: wraps, + }) + }; // Pre-fix: `Session` was looked up against the file's top-level // local symbols (which only contained `inner`), so the return type // resolved to `Opaque` and `make` was dropped from the index. @@ -434,15 +588,26 @@ fn test_struct_field_inside_inline_mod_keys_include_mod_name() { } "#, )]); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &HashSet::new(), - crate_root_modules: &crate_roots(&["src/app/mod.rs"]), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = &HashSet::new(); + let roots = &crate_roots(&["src/app/mod.rs"]); + let wraps = &HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: cfg_test, + transparent_wrappers: wraps, + }) + }; assert!( index .struct_field("crate::app::inner::Ctx", "session") @@ -455,15 +620,26 @@ fn test_struct_field_inside_inline_mod_keys_include_mod_name() { #[test] fn test_fn_with_unit_return_is_not_indexed() { let fix = fixture(&[("src/app/foo.rs", "pub fn bump() {}")]); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &HashSet::new(), - crate_root_modules: &crate_roots(&["src/app/foo.rs"]), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = &HashSet::new(); + let roots = &crate_roots(&["src/app/foo.rs"]); + let wraps = &HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: cfg_test, + transparent_wrappers: wraps, + }) + }; assert!(index.fn_returns.is_empty()); } @@ -481,15 +657,26 @@ fn test_cfg_test_file_is_skipped() { )]); let mut cfg_test = HashSet::new(); cfg_test.insert("src/app/foo.rs".to_string()); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &cfg_test, - crate_root_modules: &crate_roots(&["src/app/foo.rs"]), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = &cfg_test; + let roots = &crate_roots(&["src/app/foo.rs"]); + let wraps = &HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: cfg_test, + transparent_wrappers: wraps, + }) + }; assert!(index.struct_fields.is_empty()); assert!(index.method_returns.is_empty()); assert!(index.fn_returns.is_empty()); @@ -510,15 +697,26 @@ fn test_trait_declaration_methods_are_indexed() { } "#, )]); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &HashSet::new(), - crate_root_modules: &crate_roots(&["src/app/ports.rs"]), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = &HashSet::new(); + let roots = &crate_roots(&["src/app/ports.rs"]); + let wraps = &HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: cfg_test, + transparent_wrappers: wraps, + }) + }; assert!(index.trait_has_method("crate::app::ports::Handler", "handle")); assert!(index.trait_has_method("crate::app::ports::Handler", "can_handle")); assert!(!index.trait_has_method("crate::app::ports::Handler", "missing")); @@ -534,15 +732,26 @@ fn test_trait_impl_is_indexed() { impl Handler for MyImpl { fn handle(&self) {} } "#, )]); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &HashSet::new(), - crate_root_modules: &crate_roots(&["src/app/foo.rs"]), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = &HashSet::new(); + let roots = &crate_roots(&["src/app/foo.rs"]); + let wraps = &HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: cfg_test, + transparent_wrappers: wraps, + }) + }; let impls = index.impls_of_trait("crate::app::foo::Handler"); assert!(impls.contains(&"crate::app::foo::MyImpl".to_string())); } @@ -561,15 +770,26 @@ fn test_multiple_impls_of_same_trait_all_indexed() { impl Handler for C { fn handle(&self) {} } "#, )]); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &HashSet::new(), - crate_root_modules: &crate_roots(&["src/app/foo.rs"]), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = &HashSet::new(); + let roots = &crate_roots(&["src/app/foo.rs"]); + let wraps = &HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: cfg_test, + transparent_wrappers: wraps, + }) + }; let impls = index.impls_of_trait("crate::app::foo::Handler"); assert_eq!(impls.len(), 3); } @@ -583,15 +803,26 @@ fn test_inherent_impl_does_not_populate_trait_impls() { impl S { pub fn method(&self) {} } "#, )]); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &HashSet::new(), - crate_root_modules: &crate_roots(&["src/app/foo.rs"]), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = &HashSet::new(); + let roots = &crate_roots(&["src/app/foo.rs"]); + let wraps = &HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: cfg_test, + transparent_wrappers: wraps, + }) + }; // Inherent impl has no trait reference, so trait_impls stays empty. assert!(index.trait_impls.is_empty()); } @@ -612,15 +843,26 @@ fn test_trait_in_one_file_impl_in_another() { "#, ), ]); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &HashSet::new(), - crate_root_modules: &crate_roots(&["src/ports/handler.rs", "src/app/session.rs"]), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = HashSet::new(); + let roots = crate_roots(&["src/ports/handler.rs", "src/app/session.rs"]); + let wraps = HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: &cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: &roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: &cfg_test, + transparent_wrappers: &wraps, + }) + }; // Trait resolved via import alias. let impls = index.impls_of_trait("crate::ports::handler::Handler"); assert!(impls.contains(&"crate::app::session::Session".to_string())); @@ -646,15 +888,26 @@ fn test_struct_in_one_file_impl_in_another() { "#, ), ]); - let index = build_workspace_type_index(&WorkspaceIndexInputs { - files: &borrowed(&fix), - aliases_per_file: &fix.aliases, - aliases_scoped_per_file: &fix.aliases_scoped, - local_symbols_per_file: &fix.local_symbols, - cfg_test_files: &HashSet::new(), - crate_root_modules: &crate_roots(&["src/app/session.rs", "src/app/impls.rs"]), - transparent_wrappers: &HashSet::new(), - }); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = HashSet::new(); + let roots = crate_roots(&["src/app/session.rs", "src/app/impls.rs"]); + let wraps = HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: &cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: &roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: &cfg_test, + transparent_wrappers: &wraps, + }) + }; // Struct indexed from its declaration file. assert!(index .struct_field("crate::app::session::Session", "id") diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/aliases.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/aliases.rs index 3d45a1a..a581f14 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/aliases.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/aliases.rs @@ -1,13 +1,12 @@ //! Type-alias collection. //! //! For every top-level `type Alias = Target;` in the -//! workspace, record `canonical_Alias → (params, Target)` as -//! `(Vec, syn::Type)`. The generic parameter names are kept -//! so use-sites like `Alias` can substitute them into -//! `Target` before resolution — without that, generic aliases like -//! `type AppResult = Result` would cache `Result` with `T` unbound and downstream `.unwrap()` would return -//! `Opaque`. +//! workspace, record `canonical_Alias → AliasDef { params, target, +//! decl_file, decl_mod_stack }`. Use-sites substitute generic args +//! into `target` and resolve the result against the alias's *own* +//! declaring scope — file-level `use crate::Store; type Repo = +//! Arc;` only resolves `Store` correctly if the resolver +//! consults the decl-site's alias map. use super::{canonical_type_key, BuildContext, WorkspaceTypeIndex}; use crate::adapters::shared::cfg_test::has_cfg_test; @@ -48,9 +47,15 @@ impl<'ast, 'i, 'c> Visit<'ast> for AliasCollector<'i, 'c> { _ => None, }) .collect(); - self.index - .type_aliases - .insert(canonical, (params, (*node.ty).clone())); + self.index.type_aliases.insert( + canonical, + super::AliasDef { + params, + target: (*node.ty).clone(), + decl_file: self.ctx.file.path.to_string(), + decl_mod_stack: self.mod_stack.clone(), + }, + ); } fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) { diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs index db048b2..1ce34db 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs @@ -106,7 +106,7 @@ fn canonical_struct_name( mod_stack: &[String], ) -> String { let mut segs: Vec = vec!["crate".to_string()]; - segs.extend(file_to_module_segments(ctx.path)); + segs.extend(file_to_module_segments(ctx.file.path)); segs.extend(mod_stack.iter().cloned()); segs.push(struct_ident.to_string()); segs.join("::") diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs index f804201..d9d372d 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs @@ -85,7 +85,7 @@ fn record_fn( /// string construction. fn canonical_fn_name(fn_ident: &str, ctx: &BuildContext<'_>, mod_stack: &[String]) -> String { let mut segs: Vec = vec!["crate".to_string()]; - segs.extend(file_to_module_segments(ctx.path)); + segs.extend(file_to_module_segments(ctx.file.path)); segs.extend(mod_stack.iter().cloned()); segs.push(fn_ident.to_string()); segs.join("::") diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs index 5bd4cf7..0eeb47e 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs @@ -57,12 +57,7 @@ impl<'ast, 'i, 'c> Visit<'ast> for MethodCollector<'i, 'c> { let resolved = resolve_impl_self_type( &node.self_ty, &CanonScope { - alias_map: self.ctx.alias_map, - local_symbols: self.ctx.local_symbols, - crate_root_modules: self.ctx.crate_root_modules, - importing_file: self.ctx.path, - local_decl_scopes: Some(self.ctx.local_decl_scopes), - aliases_per_scope: Some(self.ctx.aliases_per_scope), + file: self.ctx.file, mod_stack: &self.mod_stack, }, ); @@ -138,7 +133,7 @@ fn resolve_method_return( mod_stack: &[String], ) -> CanonicalType { if let syn::Type::Path(p) = ret_ty { - if p.qself.is_none() && p.path.segments.first().is_some_and(|s| s.ident == "Self") { + if p.qself.is_none() && p.path.segments.len() == 1 && p.path.segments[0].ident == "Self" { return CanonicalType::Path(impl_segs.to_vec()); } } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs index 66bc04a..4af1b67 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs @@ -16,6 +16,7 @@ pub mod methods; pub mod traits; use super::canonical::CanonicalType; +use crate::adapters::analyzers::architecture::call_parity_rule::local_symbols::FileScope; use crate::adapters::analyzers::architecture::call_parity_rule::workspace_graph::{ collect_local_symbols_scoped, LocalSymbols, }; @@ -26,25 +27,32 @@ use std::collections::{HashMap, HashSet}; /// Per-file resolution context passed to every collector. Owned by the /// outer build loop, borrowed into each `collect_from_file` call. pub(super) struct BuildContext<'a> { - pub path: &'a str, - pub alias_map: &'a HashMap>, - pub aliases_per_scope: &'a ScopedAliasMap, - pub local_symbols: &'a HashSet, - /// Per-name list of mod-paths-within-file where `local_symbols` - /// names are declared. Lets the resolver pick - /// `crate::::::Session` over `crate::::Session` - /// when `Session` is declared inside an inline mod. - pub local_decl_scopes: &'a HashMap>>, - pub crate_root_modules: &'a HashSet, - /// user-wrapper names peeled during resolution. Shared - /// across the whole build. + pub file: &'a FileScope<'a>, + /// All workspace `FileScope`s, keyed by file path. Lets the + /// resolver switch into an alias's declaring scope when expanding + /// imported aliases. + pub workspace_files: &'a HashMap>, + /// User-wrapper names peeled during resolution. Shared across the + /// whole build. pub transparent_wrappers: &'a HashSet, - /// type aliases already collected across the workspace. - /// `None` in pass 1 (the alias collector itself); `Some(&…)` in - /// pass 2 so fields/methods/functions/traits that reference an - /// alias are resolved through the alias target instead of caching - /// the raw alias name. - pub type_aliases: Option<&'a HashMap, syn::Type)>>, + /// Type aliases already collected across the workspace. `None` in + /// pass 1 (the alias collector itself); `Some(&…)` in pass 2. + pub type_aliases: Option<&'a HashMap>, +} + +/// Workspace type-alias entry, keyed under the alias's canonical name. +/// `target` is resolved against the alias's *own* declaring scope, not +/// the use-site's, so cross-module aliases (`use crate::store::Store; +/// type Repo = Arc;`) expand correctly. +pub struct AliasDef { + /// Generic parameter names (`["T"]` for `type AppResult = …`). + pub params: Vec, + /// Right-hand side of the alias. + pub target: syn::Type, + /// Path of the file where the alias was declared. + pub decl_file: String, + /// Mod-stack inside `decl_file` of the alias's enclosing module. + pub decl_mod_stack: Vec, } /// Build a canonical type-path key by prefixing the impl/trait segments @@ -53,7 +61,6 @@ pub(super) struct BuildContext<'a> { /// `mod inner { ... }` blocks so items declared inside them key as /// `crate::::inner::X`, matching the path a call-site like /// `inner::X` canonicalises to. -/// Operation. pub(super) fn canonical_type_key( segs: &[String], ctx: &BuildContext<'_>, @@ -63,7 +70,7 @@ pub(super) fn canonical_type_key( return segs.join("::"); } let mut out: Vec = vec!["crate".to_string()]; - out.extend(file_to_module_segments(ctx.path)); + out.extend(file_to_module_segments(ctx.file.path)); out.extend(mod_stack.iter().cloned()); out.extend(segs.iter().cloned()); out.join("::") @@ -71,26 +78,18 @@ pub(super) fn canonical_type_key( /// Build a `ResolveContext` from the shared `BuildContext` inputs — /// extracted so the per-field / per-method / per-free-fn collectors -/// don't each repeat the same construction. `type_aliases` propagates -/// through so pass-2 collectors (running after the alias-collector -/// populated them) resolve aliased types transparently. `mod_stack` is -/// the current mod-path inside `ctx.path` — pass `&[]` for top-level -/// items. -/// Operation. +/// don't each repeat the same construction. `mod_stack` is the current +/// mod-path inside `ctx.file.path` — pass `&[]` for top-level items. pub(super) fn resolve_ctx_from_build<'a>( ctx: &'a BuildContext<'a>, mod_stack: &'a [String], ) -> super::resolve::ResolveContext<'a> { super::resolve::ResolveContext { - alias_map: ctx.alias_map, - local_symbols: ctx.local_symbols, - crate_root_modules: ctx.crate_root_modules, - importing_file: ctx.path, + file: ctx.file, + mod_stack, type_aliases: ctx.type_aliases, transparent_wrappers: Some(ctx.transparent_wrappers), - local_decl_scopes: Some(ctx.local_decl_scopes), - aliases_per_scope: Some(ctx.aliases_per_scope), - mod_stack, + workspace_files: Some(ctx.workspace_files), } } @@ -111,11 +110,10 @@ pub struct WorkspaceTypeIndex { /// trait-dispatch so `dyn Trait.unrelated_method()` stays /// unresolved. pub trait_methods: HashMap>, - /// `alias_canonical → (generic_param_names, target)`. - /// Params are captured so use-sites like `Alias` can - /// substitute the params' idents in `target` before resolution. - /// Aliases without generics just have an empty `Vec`. - pub type_aliases: HashMap, syn::Type)>, + /// `alias_canonical → AliasDef`. Use-sites substitute generic args + /// into `target` and resolve the result against the alias's own + /// `decl_file` / `decl_mod_stack` scope (not the use-site's). + pub type_aliases: HashMap, /// user-configured last-ident names to treat as /// transparent single-type-param wrappers (framework extractors /// like `State` / `Data`). Mirrored from the @@ -174,11 +172,8 @@ impl WorkspaceTypeIndex { /// signature stays under the SRP param count. pub struct WorkspaceIndexInputs<'a> { pub files: &'a [(&'a str, &'a syn::File)], - pub aliases_per_file: &'a HashMap>>, - pub aliases_scoped_per_file: &'a HashMap, - pub local_symbols_per_file: &'a HashMap, + pub workspace_files: &'a HashMap>, pub cfg_test_files: &'a HashSet, - pub crate_root_modules: &'a HashSet, pub transparent_wrappers: &'a HashSet, } @@ -197,13 +192,10 @@ pub fn build_workspace_type_index(inputs: &WorkspaceIndexInputs<'_>) -> Workspac let mut index = WorkspaceTypeIndex::new(); index.transparent_wrappers = inputs.transparent_wrappers.clone(); let shared = |type_aliases| WalkInputs { - files: inputs.files, - aliases_per_file: inputs.aliases_per_file, - aliases_scoped_per_file: inputs.aliases_scoped_per_file, - local_symbols_per_file: inputs.local_symbols_per_file, cfg_test_files: inputs.cfg_test_files, - crate_root_modules: inputs.crate_root_modules, + files: inputs.files, transparent_wrappers: inputs.transparent_wrappers, + workspace_files: inputs.workspace_files, type_aliases, }; // Pass 1: aliases across all files (no alias map yet). @@ -228,22 +220,17 @@ pub fn build_workspace_type_index(inputs: &WorkspaceIndexInputs<'_>) -> Workspac index } -/// Inputs common to both index-build passes. Bundled so `walk_files` -/// doesn't exceed the SRP parameter count. +/// Inputs common to both index-build passes. struct WalkInputs<'a> { files: &'a [(&'a str, &'a syn::File)], - aliases_per_file: &'a HashMap>>, - aliases_scoped_per_file: &'a HashMap, - local_symbols_per_file: &'a HashMap, + workspace_files: &'a HashMap>, cfg_test_files: &'a HashSet, - crate_root_modules: &'a HashSet, transparent_wrappers: &'a HashSet, - type_aliases: Option<&'a HashMap, syn::Type)>>, + type_aliases: Option<&'a HashMap>, } -/// Shared file-walk scaffold for both index build passes. Skips -/// cfg-test files and files without a pre-computed alias map; hands -/// the per-file `BuildContext` to `visit`. Integration. +/// Shared file-walk scaffold for both index build passes. Reuses the +/// `workspace_files` map so each file's `FileScope` is built once. fn walk_files(inputs: &WalkInputs<'_>, index: &mut WorkspaceTypeIndex, mut visit: F) where F: FnMut(&mut WorkspaceTypeIndex, &BuildContext<'_>, &syn::File), @@ -252,24 +239,12 @@ where if inputs.cfg_test_files.contains(*path) { continue; } - let Some(alias_map) = inputs.aliases_per_file.get(*path) else { - continue; - }; - let Some(local) = inputs.local_symbols_per_file.get(*path) else { + let Some(file) = inputs.workspace_files.get(*path) else { continue; }; - let empty_scoped = HashMap::new(); - let aliases_per_scope = inputs - .aliases_scoped_per_file - .get(*path) - .unwrap_or(&empty_scoped); let ctx = BuildContext { - path, - alias_map, - aliases_per_scope, - local_symbols: &local.flat, - local_decl_scopes: &local.by_name, - crate_root_modules: inputs.crate_root_modules, + file, + workspace_files: inputs.workspace_files, transparent_wrappers: inputs.transparent_wrappers, type_aliases: inputs.type_aliases, }; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs index b997352..16be9d9 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/traits.rs @@ -107,12 +107,7 @@ fn record_trait_impl( let impl_type_canonical = resolve_impl_self_type( &node.self_ty, &CanonScope { - alias_map: ctx.alias_map, - local_symbols: ctx.local_symbols, - crate_root_modules: ctx.crate_root_modules, - importing_file: ctx.path, - local_decl_scopes: Some(ctx.local_decl_scopes), - aliases_per_scope: Some(ctx.aliases_per_scope), + file: ctx.file, mod_stack, }, ); @@ -141,12 +136,7 @@ fn resolve_trait_path( let resolved = canonicalise_type_segments_in_scope( &segs, &CanonScope { - alias_map: ctx.alias_map, - local_symbols: ctx.local_symbols, - crate_root_modules: ctx.crate_root_modules, - importing_file: ctx.path, - local_decl_scopes: Some(ctx.local_decl_scopes), - aliases_per_scope: Some(ctx.aliases_per_scope), + file: ctx.file, mod_stack, }, )?; @@ -157,7 +147,7 @@ fn resolve_trait_path( /// Operation. fn canonical_name(ident: &str, ctx: &BuildContext<'_>, mod_stack: &[String]) -> String { let mut segs: Vec = vec!["crate".to_string()]; - segs.extend(file_to_module_segments(ctx.path)); + segs.extend(file_to_module_segments(ctx.file.path)); segs.extend(mod_stack.iter().cloned()); segs.push(ident.to_string()); segs.join("::") diff --git a/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs b/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs index dd946b3..947646f 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs @@ -158,7 +158,8 @@ fn crate_root_module_of(path: &str) -> Option { } pub(crate) use super::local_symbols::{ - collect_local_symbols, collect_local_symbols_scoped, LocalSymbols, + build_workspace_files_map, collect_local_symbols, collect_local_symbols_scoped, FileScope, + LocalSymbols, }; /// Canonicalise an impl block's self-type through the same alias / @@ -257,35 +258,28 @@ pub(crate) fn build_call_graph<'ast>( .filter(|(p, _)| !cfg_test_files.contains(*p)) .map(|(path, ast)| (path.to_string(), gather_alias_map_scoped(ast))) .collect(); - let type_index = build_workspace_type_index(&WorkspaceIndexInputs { + let workspace_files = build_workspace_files_map(super::local_symbols::WorkspaceFilesInputs { files, + cfg_test_files, aliases_per_file, aliases_scoped_per_file: &aliases_scoped_per_file, local_symbols_per_file: &local_symbols_per_file, - cfg_test_files, crate_root_modules: &crate_root_modules, + }); + let type_index = build_workspace_type_index(&WorkspaceIndexInputs { + files, + workspace_files: &workspace_files, + cfg_test_files, transparent_wrappers, }); - let empty_scoped = HashMap::new(); let mut graph = CallGraph::new(); for (path, ast) in files { - if cfg_test_files.contains(*path) { - continue; - } - let Some(alias_map) = aliases_per_file.get(*path) else { - continue; - }; - let Some(local) = local_symbols_per_file.get(*path) else { + let Some(file) = workspace_files.get(*path) else { continue; }; - let aliases_per_scope = aliases_scoped_per_file.get(*path).unwrap_or(&empty_scoped); let mut collector = FileFnCollector { - path, - alias_map, - aliases_per_scope, - local_symbols: &local.flat, - local_decl_scopes: &local.by_name, - crate_root_modules: &crate_root_modules, + file, + workspace_files: &workspace_files, type_index: &type_index, impl_type_stack: Vec::new(), mod_stack: Vec::new(), @@ -313,20 +307,11 @@ fn populate_layer_cache(graph: &mut CallGraph, layers: &LayerDefinitions) { } struct FileFnCollector<'a> { - path: &'a str, - alias_map: &'a HashMap>, - aliases_per_scope: &'a ScopedAliasMap, - local_symbols: &'a HashSet, - /// Per-name list of mod-paths-within-file where the name is - /// declared. Threaded through `FnContext` so the call collector - /// resolves `Self::xxx` and unqualified local calls inside inline - /// mods to the correct `crate::::::…` canonical. - local_decl_scopes: &'a HashMap>>, - crate_root_modules: &'a HashSet, + file: &'a FileScope<'a>, + workspace_files: &'a HashMap>, type_index: &'a WorkspaceTypeIndex, /// `None` marks an unresolved self-type (trait object, `&T`, tuple) - /// whose methods we must not record — the canonical would collapse - /// to `crate::::method` and collide with free fns. + /// whose methods we must not record. impl_type_stack: Vec>>, /// Enclosing inline-mod names so fns inside `mod inner { ... }` /// record under `crate::::inner::fn`. @@ -350,20 +335,20 @@ impl<'a> FileFnCollector<'a> { // don't record; see `resolve_impl_self_type`'s doc. Some(None) => return, }; - let canonical = - canonical_fn_name(self.path, self_type.as_deref(), &self.mod_stack, fn_name); + let canonical = canonical_fn_name( + self.file.path, + self_type.as_deref(), + &self.mod_stack, + fn_name, + ); let ctx = FnContext { + file: self.file, + mod_stack: &self.mod_stack, body, signature_params: extract_signature_params(sig), self_type, - alias_map: self.alias_map, - aliases_per_scope: Some(self.aliases_per_scope), - local_symbols: self.local_symbols, - crate_root_modules: self.crate_root_modules, - importing_file: self.path, workspace_index: Some(self.type_index), - mod_stack: &self.mod_stack, - local_decl_scopes: Some(self.local_decl_scopes), + workspace_files: Some(self.workspace_files), }; let calls = collect_canonical_calls(&ctx); self.graph.add_node(&canonical); @@ -394,12 +379,7 @@ impl<'a, 'ast> Visit<'ast> for FileFnCollector<'a> { let resolved = resolve_impl_self_type( &node.self_ty, &CanonScope { - alias_map: self.alias_map, - local_symbols: self.local_symbols, - crate_root_modules: self.crate_root_modules, - importing_file: self.path, - local_decl_scopes: Some(self.local_decl_scopes), - aliases_per_scope: Some(self.aliases_per_scope), + file: self.file, mod_stack: &self.mod_stack, }, ); From 75344c07d8cced407ee0c4db1dd9007caf33d4a3 Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sat, 25 Apr 2026 19:15:53 +0200 Subject: [PATCH 13/30] fix: copilot comments --- CHANGELOG.md | 2 +- .../architecture/call_parity_rule/calls.rs | 2 + .../call_parity_rule/tests/regressions.rs | 6 +- .../type_infer/alias_substitution.rs | 79 ------------- .../type_infer/infer/access.rs | 1 + .../type_infer/infer/generics.rs | 1 + .../call_parity_rule/type_infer/mod.rs | 2 +- .../type_infer/patterns/destructure.rs | 1 + .../call_parity_rule/type_infer/resolve.rs | 67 ++++------- .../type_infer/resolve_alias.rs | 104 ++++++++++++++++++ .../type_infer/tests/resolve.rs | 92 ++++++++++++++++ .../type_infer/workspace_index/mod.rs | 1 + 12 files changed, 231 insertions(+), 127 deletions(-) delete mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/alias_substitution.rs create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve_alias.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e8398e3..47a7a5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -185,7 +185,7 @@ fallback markers rather than fabricate edges: findings** (the 5 legitimate asymmetries / dead-code items). Any drift in this count is a clear regression signal. - **`tests/regressions.rs`** — unit-level tests covering every rlm - Gruppe-2 / Gruppe-3 pattern plus Stage-2 trait-dispatch / + Group-2 / Group-3 pattern plus Stage-2 trait-dispatch / turbofish cases and Stage-3 type-alias / user-wrapper cases. Negative tests pin documented limits in place. - **~160 new unit tests** across `type_infer/tests/` covering diff --git a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs index d25ec84..f683298 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs @@ -172,6 +172,7 @@ impl<'a> CanonicalCallCollector<'a> { type_aliases: self.workspace_index.map(|w| &w.type_aliases), transparent_wrappers: self.workspace_index.map(|w| &w.transparent_wrappers), workspace_files: self.workspace_files, + alias_param_subs: None, }; resolve_type(ty, &rctx) } @@ -457,6 +458,7 @@ impl<'a> CanonicalCallCollector<'a> { type_aliases: Some(&wi.type_aliases), transparent_wrappers: Some(&wi.transparent_wrappers), workspace_files: self.workspace_files, + alias_param_subs: None, }; let name = pi.ident.to_string(); match resolve_type(pt.ty.as_ref(), &rctx) { diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs index afc36ed..acc9d3c 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs @@ -9,7 +9,7 @@ //! instead of producing a spurious edge. //! //! Coverage targets the rlm classification published in the Task 1.6 -//! brief: Gruppe-2 (method-chain ctors) and Gruppe-3 (cascading struct +//! brief: Group-2 (method-chain ctors) and Group-3 (cascading struct //! field access), plus the fast-path patterns that must stay green. use crate::adapters::analyzers::architecture::call_parity_rule::calls::{ @@ -141,7 +141,7 @@ fn run(fx: &RegFixture, index: &WorkspaceTypeIndex, fn_name: &str) -> HashSet` at a use site plus a stored alias -//! definition `type Alias = Target` into the concrete `Target` -//! with `P1 → ArgA` and `P2 → ArgB` applied. Called from -//! `resolve::resolve_generic_path` when a path canonicalises to a -//! recorded type alias — without this, generic aliases like -//! `type AppResult = Result` would cache `Result` with `T` unbound and `.unwrap()` would yield `Opaque`. - -use std::collections::HashMap; -use syn::visit_mut::VisitMut; - -// qual:api -/// Substitute an alias's generic parameters with the use-site's type -/// arguments, cloning the stored target and rewriting each `Type::Path` -/// whose single ident matches a param name. Falls back to an -/// unmodified clone when the counts don't line up — the target's -/// remaining unbound params will resolve to `Opaque` downstream, -/// matching the pre-Stage-3 behaviour. Operation. -pub(super) fn substitute_alias_args( - target: &syn::Type, - params: &[String], - use_site: &syn::Path, -) -> syn::Type { - let mut expanded = target.clone(); - if params.is_empty() { - return expanded; - } - let args = use_site_type_args(use_site); - if args.len() != params.len() { - return expanded; - } - let subs: HashMap<&str, &syn::Type> = params.iter().map(String::as_str).zip(args).collect(); - AliasSubstitutor { subs: &subs }.visit_type_mut(&mut expanded); - expanded -} - -/// Extract the use-site type arguments from the last segment of a -/// path. Lifetime/const args are skipped; only `Type` args count. -/// Operation. -fn use_site_type_args(path: &syn::Path) -> Vec<&syn::Type> { - let Some(last) = path.segments.last() else { - return Vec::new(); - }; - let syn::PathArguments::AngleBracketed(ab) = &last.arguments else { - return Vec::new(); - }; - ab.args - .iter() - .filter_map(|a| match a { - syn::GenericArgument::Type(t) => Some(t), - _ => None, - }) - .collect() -} - -/// `VisitMut` adapter that replaces single-segment type idents matching -/// an alias param with the corresponding use-site type. -struct AliasSubstitutor<'a> { - subs: &'a HashMap<&'a str, &'a syn::Type>, -} - -impl<'a> VisitMut for AliasSubstitutor<'a> { - fn visit_type_mut(&mut self, ty: &mut syn::Type) { - if let syn::Type::Path(tp) = ty { - if tp.qself.is_none() && tp.path.segments.len() == 1 { - let seg = &tp.path.segments[0]; - if matches!(seg.arguments, syn::PathArguments::None) { - if let Some(replacement) = self.subs.get(seg.ident.to_string().as_str()) { - *ty = (*replacement).clone(); - return; - } - } - } - } - syn::visit_mut::visit_type_mut(self, ty); - } -} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/access.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/access.rs index 25e6935..5cc7acd 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/access.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/access.rs @@ -63,6 +63,7 @@ pub(super) fn infer_cast(c: &syn::ExprCast, ctx: &InferContext<'_>) -> Option { /// `app`'s scope and fail. `None` falls back to using the /// use-site's scope (legacy / unit-test path). pub workspace_files: Option<&'a HashMap>>, + /// Active inside an alias body: param-name → canonical type + /// pre-resolved in the *use-site* scope. Without this, the body + /// resolves naked `T` against the alias's decl-site, which can't + /// see the use-site's symbols. `None` outside alias expansion. + pub alias_param_subs: Option<&'a HashMap>, } /// Hard recursion cap for `resolve_type_with_depth`. Guards against @@ -74,7 +79,11 @@ pub(crate) fn resolve_type(ty: &syn::Type, ctx: &ResolveContext<'_>) -> Canonica /// single depth guard — each arm is one-call delegation, own recursion /// hidden behind closures for IOSP leniency. // qual:recursive -fn resolve_type_with_depth(ty: &syn::Type, ctx: &ResolveContext<'_>, depth: u8) -> CanonicalType { +pub(super) fn resolve_type_with_depth( + ty: &syn::Type, + ctx: &ResolveContext<'_>, + depth: u8, +) -> CanonicalType { depth_guarded(depth, |next| dispatch_type(ty, ctx, next)) } @@ -95,18 +104,21 @@ where fn dispatch_type(ty: &syn::Type, ctx: &ResolveContext<'_>, next: u8) -> CanonicalType { let recurse = |t: &syn::Type| resolve_type_with_depth(t, ctx, next); let into_slice = |inner: CanonicalType| CanonicalType::Slice(Box::new(inner)); + let path = |tp: &syn::TypePath| { + lookup_alias_param(tp, ctx).unwrap_or_else(|| resolve_path(&tp.path, ctx, next)) + }; match ty { syn::Type::Reference(r) => recurse(&r.elem), syn::Type::Paren(p) => recurse(&p.elem), - syn::Type::Path(tp) => resolve_path(&tp.path, ctx, next), + syn::Type::Path(tp) => path(tp), syn::Type::Array(a) => into_slice(recurse(&a.elem)), syn::Type::Slice(s) => into_slice(recurse(&s.elem)), - syn::Type::TraitObject(tto) => resolve_bound_list(&tto.bounds, ctx), + syn::Type::TraitObject(tto) => resolve_bound_list(&tto.bounds, ctx, next), // `impl Trait` return type — the concrete type is hidden by the // compiler, but we can still extract the first non-marker trait // bound and treat the result like `dyn Trait` so trait-dispatch // over-approximation fires on the method call. - syn::Type::ImplTrait(iti) => resolve_bound_list(&iti.bounds, ctx), + syn::Type::ImplTrait(iti) => resolve_bound_list(&iti.bounds, ctx, next), _ => CanonicalType::Opaque, } } @@ -121,6 +133,7 @@ fn dispatch_type(ty: &syn::Type, ctx: &ResolveContext<'_>, next: u8) -> Canonica fn resolve_bound_list( bounds: &syn::punctuated::Punctuated, ctx: &ResolveContext<'_>, + depth: u8, ) -> CanonicalType { for bound in bounds { let syn::TypeParamBound::Trait(trait_bound) = bound else { @@ -133,7 +146,7 @@ fn resolve_bound_list( // the path-form `Future` produces, so `.await` on // the result resolves through the combinator table. if let Some(args) = future_args(&trait_bound.path) { - return wrap_future_output(args, ctx, 0); + return wrap_future_output(args, ctx, depth); } let segs: Vec = trait_bound .path @@ -238,7 +251,7 @@ fn future_output_type(args: &syn::PathArguments) -> Option<&syn::Type> { assoc.or_else(|| generic_type_arg(args, 0)) } -/// check if `name` is a user-configured transparent wrapper. +/// Check if `name` is a user-configured transparent wrapper. /// Operation: set lookup with optional presence. fn is_user_transparent(name: &str, ctx: &ResolveContext<'_>) -> bool { ctx.transparent_wrappers @@ -258,8 +271,6 @@ fn wrap_generic( where F: FnOnce(Box) -> CanonicalType, { - // `depth` already carries the +1 from `dispatch_type`'s guard — - // `resolve_type_with_depth` re-applies the guard, so pass through. let recurse = |t: &syn::Type| resolve_type_with_depth(t, ctx, depth); match generic_type_arg(args, idx) { Some(inner) => constructor(Box::new(recurse(inner))), @@ -283,11 +294,8 @@ fn peel_single_generic( } /// Resolve a non-wrapper path through the shared canonicalisation -/// pipeline. On an alias hit, substitute use-site generic args into -/// the alias target and recursively resolve it against the alias's -/// *own* declaring scope — without that, an imported alias like -/// `type Repo = Arc` would try to resolve `Store` in the -/// use-site's scope, where it isn't necessarily known. +/// pipeline. On an alias hit, delegate to `resolve_alias::expand_alias` +/// to handle param substitution + decl-site scope swap. fn resolve_generic_path(path: &syn::Path, ctx: &ResolveContext<'_>, depth: u8) -> CanonicalType { let canonicalise = |segs: &[String]| canonicalise_type_segments_in_scope(segs, &canon_scope(ctx)); @@ -297,41 +305,14 @@ fn resolve_generic_path(path: &syn::Path, ctx: &ResolveContext<'_>, depth: u8) - }; let key = resolved.join("::"); if let Some(alias) = ctx.type_aliases.and_then(|m| m.get(&key)) { - let expanded = substitute_alias_args(&alias.target, &alias.params, path); - return resolve_in_alias_scope(&expanded, alias, ctx, depth); + return expand_alias(alias, path, ctx, depth); } CanonicalType::Path(resolved) } -/// Resolve `target` (an alias body, post-substitution) against the -/// alias's declaring scope. Falls back to the use-site scope when -/// `workspace_files` lacks an entry for `decl_file` (legacy / unit- -/// test paths). Operation: scope swap + recurse. -fn resolve_in_alias_scope( - target: &syn::Type, - alias: &super::workspace_index::AliasDef, - ctx: &ResolveContext<'_>, - depth: u8, -) -> CanonicalType { - let decl_file = ctx - .workspace_files - .and_then(|files| files.get(&alias.decl_file)); - let Some(decl_file) = decl_file else { - return resolve_type_with_depth(target, ctx, depth); - }; - let decl_ctx = ResolveContext { - file: decl_file, - mod_stack: &alias.decl_mod_stack, - type_aliases: ctx.type_aliases, - transparent_wrappers: ctx.transparent_wrappers, - workspace_files: ctx.workspace_files, - }; - resolve_type_with_depth(target, &decl_ctx, depth) -} - /// Extract the type at position `idx` from angle-bracketed generic args. /// Lifetimes / const args are skipped; only type args count. -fn generic_type_arg(args: &syn::PathArguments, idx: usize) -> Option<&syn::Type> { +pub(super) fn generic_type_arg(args: &syn::PathArguments, idx: usize) -> Option<&syn::Type> { let syn::PathArguments::AngleBracketed(ab) = args else { return None; }; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve_alias.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve_alias.rs new file mode 100644 index 0000000..770cdb9 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve_alias.rs @@ -0,0 +1,104 @@ +//! Type-alias expansion for the resolver. +//! +//! Sibling of `resolve.rs`: shares `resolve_type_with_depth` for +//! recursion but factors out the alias-specific concerns +//! (use-site-arg pre-resolution, scope swap to the alias's decl-site, +//! param-ident interception in the body). +//! +//! Use-site flow for `Wrap` where `domain::type Wrap = +//! Arc`: +//! 1. `expand_alias` pre-resolves each use-site arg in the current +//! `ResolveContext` so `Session` canonicalises against the +//! use-site's imports. +//! 2. The context is rebuilt against the alias's decl-site +//! `FileScope` so symbols inside the body resolve against +//! `domain`. +//! 3. `lookup_alias_param`, invoked from `dispatch_type`'s +//! `Type::Path` arm, intercepts naked param idents in the body +//! and returns the canonical from step 1. + +use super::super::local_symbols::FileScope; +use super::canonical::CanonicalType; +use super::resolve::{generic_type_arg, resolve_type_with_depth, ResolveContext}; +use super::workspace_index::AliasDef; +use std::collections::HashMap; + +/// Expand `alias` at use-site `path`. Use-site generic args are +/// pre-resolved to canonical types in the *current* scope, then the +/// body is resolved against the alias's own decl-site scope with the +/// param-name → canonical map intercepting naked param idents in +/// `dispatch_type`. Falls back to the use-site scope when +/// `workspace_files` lacks an entry for `decl_file` (legacy / +/// unit-test paths). Operation: build subs + scope swap + recurse. +pub(super) fn expand_alias( + alias: &AliasDef, + use_site: &syn::Path, + ctx: &ResolveContext<'_>, + depth: u8, +) -> CanonicalType { + let subs = resolve_alias_param_subs(&alias.params, use_site, ctx, depth); + let decl_file = ctx + .workspace_files + .and_then(|files| files.get(&alias.decl_file)); + let (file, mod_stack): (&FileScope<'_>, &[String]) = match decl_file { + Some(f) => (f, &alias.decl_mod_stack), + None => (ctx.file, ctx.mod_stack), + }; + let alias_ctx = ResolveContext { + file, + mod_stack, + type_aliases: ctx.type_aliases, + transparent_wrappers: ctx.transparent_wrappers, + workspace_files: ctx.workspace_files, + alias_param_subs: Some(&subs), + }; + resolve_type_with_depth(&alias.target, &alias_ctx, depth) +} + +/// When inside an alias body, return the pre-resolved use-site type +/// for a bare param ident (`T`, `Output`, …). Multi-segment paths and +/// paths with arguments aren't params and pass through. Operation. +pub(super) fn lookup_alias_param( + tp: &syn::TypePath, + ctx: &ResolveContext<'_>, +) -> Option { + let subs = ctx.alias_param_subs?; + if tp.qself.is_some() || tp.path.segments.len() != 1 { + return None; + } + let seg = &tp.path.segments[0]; + if !matches!(seg.arguments, syn::PathArguments::None) { + return None; + } + subs.get(&seg.ident.to_string()).cloned() +} + +/// Pre-resolve each use-site generic argument to a canonical type in +/// the use-site scope, keyed by alias param name. Empty when arg +/// counts disagree — the alias body's unresolved params then fall +/// through `dispatch_type` and resolve via decl-site lookup, mirroring +/// pre-Stage-3 behaviour. Operation. +fn resolve_alias_param_subs( + params: &[String], + use_site: &syn::Path, + ctx: &ResolveContext<'_>, + depth: u8, +) -> HashMap { + let mut out = HashMap::new(); + if params.is_empty() { + return out; + } + let Some(last) = use_site.segments.last() else { + return out; + }; + let args: Vec<&syn::Type> = (0..) + .map_while(|i| generic_type_arg(&last.arguments, i)) + .collect(); + if args.len() != params.len() { + return out; + } + for (name, arg) in params.iter().zip(args) { + out.insert(name.clone(), resolve_type_with_depth(arg, ctx, depth)); + } + out +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs index bb15216..8c74fbe 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/resolve.rs @@ -22,6 +22,7 @@ fn ctx<'a>(file: &'a FileScope<'a>) -> ResolveContext<'a> { type_aliases: None, transparent_wrappers: None, workspace_files: None, + alias_param_subs: None, } } @@ -458,3 +459,94 @@ fn test_future_wraps_output() { ); assert!(matches!(resolved, CanonicalType::Future(_))); } + +/// Per-file scope inputs the cross-module alias test owns. `FileScope` +/// holds borrows, so the owning storage stays here and `as_scope` +/// produces a fresh borrow at call sites. +struct ScopeInputs { + path: String, + alias_map: HashMap>, + aliases_per_scope: ScopedAliasMap, + local_symbols: HashSet, + local_decl_scopes: HashMap>>, + crate_root_modules: HashSet, +} + +impl ScopeInputs { + fn new(path: &str) -> Self { + Self { + path: path.to_string(), + alias_map: HashMap::new(), + aliases_per_scope: ScopedAliasMap::new(), + local_symbols: HashSet::new(), + local_decl_scopes: HashMap::new(), + crate_root_modules: HashSet::new(), + } + } + + fn as_scope(&self) -> FileScope<'_> { + FileScope { + path: &self.path, + alias_map: &self.alias_map, + aliases_per_scope: &self.aliases_per_scope, + local_symbols: &self.local_symbols, + local_decl_scopes: &self.local_decl_scopes, + crate_root_modules: &self.crate_root_modules, + } + } +} + +#[test] +fn test_alias_generic_arg_resolves_at_use_site() { + // `domain::type Wrap = Arc` consumed from `app` as + // `Wrap`: the use-site arg `Session` must canonicalise + // against `app`'s symbols, not against `domain`'s decl-site + // scope, which doesn't know `Session`. + use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::workspace_index::AliasDef; + + let domain = ScopeInputs::new("src/domain.rs"); + let mut app = ScopeInputs::new("src/app.rs"); + app.alias_map.insert( + "Wrap".to_string(), + vec![ + "crate".to_string(), + "domain".to_string(), + "Wrap".to_string(), + ], + ); + app.local_symbols.insert("Session".to_string()); + + let mut workspace_files: HashMap> = HashMap::new(); + workspace_files.insert("src/domain.rs".to_string(), domain.as_scope()); + + let alias_target: syn::Type = syn::parse_str("Arc").expect("parse alias target"); + let mut type_aliases: HashMap = HashMap::new(); + type_aliases.insert( + "crate::domain::Wrap".to_string(), + AliasDef { + params: vec!["T".to_string()], + target: alias_target, + decl_file: "src/domain.rs".to_string(), + decl_mod_stack: Vec::new(), + }, + ); + + let app_scope = app.as_scope(); + let ty = parse_type("Wrap"); + let resolved = resolve_type( + &ty, + &ResolveContext { + file: &app_scope, + mod_stack: &[], + type_aliases: Some(&type_aliases), + transparent_wrappers: None, + workspace_files: Some(&workspace_files), + alias_param_subs: None, + }, + ); + assert_eq!( + resolved, + CanonicalType::path(["crate", "app", "Session"]), + "alias generic args must resolve at the use-site, got {resolved:?}" + ); +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs index 4af1b67..773b5b2 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs @@ -90,6 +90,7 @@ pub(super) fn resolve_ctx_from_build<'a>( type_aliases: ctx.type_aliases, transparent_wrappers: Some(ctx.transparent_wrappers), workspace_files: Some(ctx.workspace_files), + alias_param_subs: None, } } From 8a511c8b9953d76cb9d965d6354a478aa23aeba6 Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sat, 25 Apr 2026 19:39:03 +0200 Subject: [PATCH 14/30] fix: copilot comments --- CHANGELOG.md | 15 +++ .../architecture/call_parity_rule/calls.rs | 7 + .../call_parity_rule/tests/regressions.rs | 123 +++++++++++++++++- .../call_parity_rule/type_infer/resolve.rs | 7 + .../type_infer/workspace_index/mod.rs | 4 - 5 files changed, 149 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47a7a5d..2dd9c5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,14 @@ additive and the legacy fast-path stays intact as a safety net. ``` was reported as "not delegating to application" even though it obviously did. +- **`self` receiver resolution inside impl methods.** Signature seeding + only iterated typed `FnArg::Typed` params, never the `self` receiver. + As a result `self.helper()` and `self.field.method()` fell through to + `:…` even when the enclosing impl's canonical type was known + via `self_type`. The collector now binds `self` to the impl's + canonical segments alongside the typed params, so ordinary + method-internal delegation routes through `method_returns` / + `struct_fields` like any other receiver. ### Added - **`call_parity_rule::type_infer`** — new module implementing shallow @@ -174,6 +182,13 @@ fallback markers rather than fabricate edges: `impl Trait` hides the concrete type by design. Methods declared on the trait resolve via trait-dispatch over-approximation; inherent methods stay `:name`. +- `fn make() -> impl Future + Handler { … }` — multi-bound + intersection returns. `CanonicalType` carries one type per receiver, + so `resolve_bound_list` keeps the first non-marker bound only; + `.await` propagation *or* trait-dispatch fires, never both. Marker + traits (`Send` / `Sync` / `Unpin` / `Copy` / `Clone` / `Sized` / + `Debug` / `Display`) are filtered first, so the common + `impl Future + Send` shape is unaffected. - Arbitrary proc-macros that alter the call graph without being in `transparent_macros` config. User-annotate via `// qual:allow(architecture)` on the enclosing fn. diff --git a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs index f683298..0de1afa 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs @@ -123,6 +123,13 @@ impl<'a> CanonicalCallCollector<'a> { } fn seed_signature_bindings(&mut self) { + // `self` is `FnArg::Receiver` and never appears in + // `signature_params`. Seed it explicitly so `self.helper()` and + // `self.field.method()` route through `method_returns` / + // `struct_fields` instead of collapsing to `:…`. + if let Some(self_canonical) = self.self_type_canonical.clone() { + self.bindings[0].insert("self".to_string(), self_canonical); + } let params = self.signature_params.clone(); for (name, ty) in ¶ms { // When workspace_index is available, use the full resolver: diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs index acc9d3c..0af84d8 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs @@ -121,6 +121,32 @@ fn sig_params(sig: &syn::Signature) -> Vec<(String, &syn::Type)> { /// workspace index. Returns the set of canonical call targets. fn run(fx: &RegFixture, index: &WorkspaceTypeIndex, fn_name: &str) -> HashSet { let f = find_fn(&fx.file, fn_name); + run_with_self(fx, index, &f.sig, &f.block, None) +} + +/// Run an `impl Type { fn name(&self, …) { … } }` body through the +/// collector with `self_type` set to crate-rooted segments. The +/// caller passes the canonical path so the test can bind to whichever +/// module the workspace index models for `Type` (e.g. Session lives +/// under `crate::app::session::*` in `rlm_index()`). +fn run_impl_method( + fx: &RegFixture, + index: &WorkspaceTypeIndex, + type_name: &str, + fn_name: &str, + self_segs: Vec, +) -> HashSet { + let (_, f) = find_impl_method(&fx.file, type_name, fn_name); + run_with_self(fx, index, &f.sig, &f.block, Some(self_segs)) +} + +fn run_with_self( + fx: &RegFixture, + index: &WorkspaceTypeIndex, + sig: &syn::Signature, + body: &syn::Block, + self_type: Option>, +) -> HashSet { let ctx = FnContext { file: &FileScope { path: "src/cli/handlers.rs", @@ -131,15 +157,52 @@ fn run(fx: &RegFixture, index: &WorkspaceTypeIndex, fn_name: &str) -> HashSet( + file: &'a syn::File, + type_name: &str, + fn_name: &str, +) -> (Vec, &'a syn::ImplItemFn) { + file.items + .iter() + .filter_map(|item| match item { + syn::Item::Impl(i) => Some(i), + _ => None, + }) + .find_map(|item_impl| { + let segs = impl_self_segments(item_impl)?; + if segs.last().map(String::as_str) != Some(type_name) { + return None; + } + item_impl.items.iter().find_map(|it| match it { + syn::ImplItem::Fn(f) if f.sig.ident == fn_name => Some((segs.clone(), f)), + _ => None, + }) + }) + .unwrap_or_else(|| panic!("impl {type_name}::{fn_name} not in fixture")) +} + +fn impl_self_segments(item: &syn::ItemImpl) -> Option> { + let syn::Type::Path(p) = item.self_ty.as_ref() else { + return None; + }; + Some( + p.path + .segments + .iter() + .map(|s| s.ident.to_string()) + .collect(), + ) +} + // ═══════════════════════════════════════════════════════════════════ // Positive: rlm Group-2 patterns (method-chain ctors) // ═══════════════════════════════════════════════════════════════════ @@ -292,6 +355,60 @@ fn rlm_group3_ctx_field_access_via_let() { assert!(calls.contains("crate::app::session::Session::diff")); } +// ═══════════════════════════════════════════════════════════════════ +// Positive: self receiver inside impl methods +// ═══════════════════════════════════════════════════════════════════ + +#[test] +fn self_method_call_resolves_via_impl_type() { + // `impl Session { fn run(&self) { self.diff() } }` — `self` must + // bind to the enclosing impl's canonical type so `self.diff()` + // routes through `method_returns[Session::diff]` instead of + // collapsing to `:diff`. + let fx = parse( + r#" + impl Session { + pub fn run(&self) { + self.diff(); + } + } + "#, + ); + let self_segs = vec![ + "crate".to_string(), + "app".to_string(), + "session".to_string(), + "Session".to_string(), + ]; + let calls = run_impl_method(&fx, &rlm_index(), "Session", "run", self_segs); + assert!( + calls.contains("crate::app::session::Session::diff"), + "self.diff() must route through workspace_index, got {calls:?}" + ); +} + +#[test] +fn self_field_access_resolves_via_impl_type() { + // `self.session.diff()` — Self::session field, then Session::diff. + // Needs both the Self → Ctx binding and the field-type lookup + // chain to fire. + let fx = parse( + r#" + impl Ctx { + pub fn run(&self) { + self.session.diff(); + } + } + "#, + ); + let self_segs = vec!["crate".to_string(), "app".to_string(), "Ctx".to_string()]; + let calls = run_impl_method(&fx, &rlm_index(), "Ctx", "run", self_segs); + assert!( + calls.contains("crate::app::session::Session::diff"), + "self.session.diff() must chain through field type, got {calls:?}" + ); +} + // ═══════════════════════════════════════════════════════════════════ // Positive: free-fn return-type chain // ═══════════════════════════════════════════════════════════════════ diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs index 89069b2..9a96c52 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs @@ -130,6 +130,13 @@ fn dispatch_type(ty: &syn::Type, ctx: &ResolveContext<'_>, next: u8) -> Canonica /// can't be canonicalised (external crates not in the workspace) — so /// `dyn ExternalTrait + LocalTrait` still dispatches via `LocalTrait`. /// Yields `Opaque` if no resolvable trait bound exists. Operation. +/// +/// **Limit:** when multiple non-marker bounds resolve (e.g. +/// `impl Future + Handler`), only the first one +/// shapes the result. `CanonicalType` carries one type per receiver, +/// so callers see either `Future` *or* `TraitBound(Handler)` +/// but not both. Workaround: split the return into two methods, or +/// suppress the resulting Check A/B finding with `qual:allow`. fn resolve_bound_list( bounds: &syn::punctuated::Punctuated, ctx: &ResolveContext<'_>, diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs index 773b5b2..9ee8cf9 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs @@ -17,11 +17,7 @@ pub mod traits; use super::canonical::CanonicalType; use crate::adapters::analyzers::architecture::call_parity_rule::local_symbols::FileScope; -use crate::adapters::analyzers::architecture::call_parity_rule::workspace_graph::{ - collect_local_symbols_scoped, LocalSymbols, -}; use crate::adapters::analyzers::architecture::forbidden_rule::file_to_module_segments; -use crate::adapters::shared::use_tree::ScopedAliasMap; use std::collections::{HashMap, HashSet}; /// Per-file resolution context passed to every collector. Owned by the From 799c99c5358f45becb5a2b618245f79bc766912b Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:30:33 +0200 Subject: [PATCH 15/30] fix: copilot comments --- README.md | 1 + .../type_infer/combinators.rs | 21 ++++--- .../type_infer/patterns/destructure.rs | 19 +++++- .../type_infer/tests/combinators.rs | 12 ++++ .../type_infer/tests/patterns_destructure.rs | 14 +++++ .../type_infer/tests/workspace_index.rs | 48 ++++++++++++++ .../type_infer/workspace_index/methods.rs | 62 ++++++++++++++++--- 7 files changed, 161 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index dadbc88..0495047 100644 --- a/README.md +++ b/README.md @@ -924,6 +924,7 @@ Known limits (documented, with clear workarounds): - **Unannotated generics** `let x = get(); x.m()` where `get() -> T` — use turbofish `get::()` or `let x: T = get();`. - **`impl Trait` inherent methods** — `fn make() -> impl Handler; make().trait_method()` resolves to every workspace impl of `Handler::trait_method` via over-approximation, but an inherent method not declared on `Handler` can't be reached (the concrete type is hidden by design). +- **Multi-bound `impl Trait` / `dyn Trait` returns** — `fn make() -> impl Future + Handler` keeps only the first non-marker bound, so `.await` propagation *or* trait-dispatch fires, never both. Marker traits (`Send`/`Sync`/`Unpin`/`Copy`/`Clone`/`Sized`/`Debug`/`Display`) are filtered first, so `impl Future + Send` is unaffected. Workaround: split the return into two methods, or `qual:allow(architecture)` on the call-site. - **Arbitrary proc-macros** not listed in `transparent_macros` — `// qual:allow(architecture)` on the enclosing fn is the escape. diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/combinators.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/combinators.rs index 010f370..9879abc 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/combinators.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/combinators.rs @@ -38,14 +38,19 @@ fn result_combinator(method: &str, inner: &CanonicalType) -> Option { Some(inner.clone()) } - // Transformations that preserve Ok-type - "map_err" | "or_else" => Some(CanonicalType::Result(Box::new(inner.clone()))), + // Transformations + observers that preserve Result. + // `inspect` / `inspect_err` hand the closure a borrow and + // return self — the closure's body type doesn't change the + // wrapper, so they stay resolved. + "map_err" | "or_else" | "inspect" | "inspect_err" => { + Some(CanonicalType::Result(Box::new(inner.clone()))) + } // Extract the Ok-side as Option "ok" => Some(CanonicalType::Option(Box::new(inner.clone()))), // Extract the Err-side — E is opaque, so Option. "err" => Some(CanonicalType::Option(Box::new(CanonicalType::Opaque))), - // Closure-dependent (change T or E via user closure) → unresolved. - "map" | "and_then" | "inspect" | "inspect_err" => None, + // Closure-dependent (change T via user closure) → unresolved. + "map" | "and_then" => None, _ => None, } } @@ -59,11 +64,13 @@ fn option_combinator(method: &str, inner: &CanonicalType) -> Option "ok_or" | "ok_or_else" => Some(CanonicalType::Result(Box::new(inner.clone()))), - // Preserve Option + // Preserve Option. `inspect` is an observer — closure type + // doesn't change the wrapper, so it stays resolved alongside + // the structural preservers. "or" | "or_else" | "filter" | "take" | "replace" | "as_ref" | "as_mut" | "cloned" - | "copied" => Some(CanonicalType::Option(Box::new(inner.clone()))), + | "copied" | "inspect" => Some(CanonicalType::Option(Box::new(inner.clone()))), // Closure-dependent → unresolved. - "map" | "and_then" | "inspect" | "map_or" | "map_or_else" => None, + "map" | "and_then" | "map_or" | "map_or_else" => None, _ => None, } } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs index ffc8a42..b4a8f61 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs @@ -184,13 +184,30 @@ fn bind_slice( _ => CanonicalType::Opaque, }; for elem in &s.elems { - if matches!(elem, syn::Pat::Rest(_)) { + if is_rest_binding(elem) { continue; } collect(elem, &elem_type, ctx, out); } } +/// `..` and `rest @ ..` both denote the slice-tail; neither binds an +/// element. `syn` parses the named form as `Pat::Ident` with a +/// `subpat` of `Pat::Rest`, which the surrounding walk would +/// otherwise treat as a regular ident binding to the element type. +fn is_rest_binding(pat: &syn::Pat) -> bool { + if matches!(pat, syn::Pat::Rest(_)) { + return true; + } + let syn::Pat::Ident(pi) = pat else { + return false; + }; + let Some((_, sub)) = pi.subpat.as_ref() else { + return false; + }; + matches!(sub.as_ref(), syn::Pat::Rest(_)) +} + /// `Pat::Or(a | b | c)` — in valid Rust all branches bind the same /// names, so recording the first branch is equivalent. Operation. fn bind_or_first( diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs index 5af3ead..dc7a4a5 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs @@ -70,6 +70,17 @@ fn test_result_or_else_preserves_ok_type() { ); } +#[test] +fn test_result_inspect_variants_preserve_wrapper() { + // `.inspect(|t| …)` and `.inspect_err(|e| …)` are observers — they + // hand the closure a borrow and return the *same* `Result`, + // independent of the closure's return type. Stays resolved. + let res = CanonicalType::Result(Box::new(t())); + let expected = Some(CanonicalType::Result(Box::new(t()))); + assert_eq!(combinator_return(&res, "inspect"), expected); + assert_eq!(combinator_return(&res, "inspect_err"), expected); +} + #[test] fn test_result_map_is_unresolved() { // `.map(|x| ...)` depends on the closure — unresolved by design. @@ -123,6 +134,7 @@ fn test_option_preserve_wrapper_methods() { let opt = CanonicalType::Option(Box::new(t())); for method in [ "or", "or_else", "filter", "take", "replace", "as_ref", "as_mut", "cloned", "copied", + "inspect", ] { assert_eq!( combinator_return(&opt, method), diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs index 776a673..8b4e314 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs @@ -204,6 +204,20 @@ fn test_slice_pattern_skips_rest() { assert_eq!(b[0].0, "first"); } +#[test] +fn test_slice_pattern_skips_named_rest_binding() { + // `rest @ ..` parses as `Pat::Ident { subpat: Some(Pat::Rest) }`. + // It must not bind `rest` to the element type — the correct type + // would be `Slice(T)`, which is out of Stage 1 scope. Skip + // entirely so a downstream `rest.method()` falls through to + // `:name` instead of resolving against `T`. + let f = TypeInferFixture::new(); + let vec_type = CanonicalType::Slice(Box::new(CanonicalType::path(["crate", "T"]))); + let b = bindings(&f, "[first, rest @ ..]", vec_type); + assert_eq!(b.len(), 1, "expected only `first`, got {b:?}"); + assert_eq!(b[0].0, "first"); +} + // ── Pat::Or ────────────────────────────────────────────────────── #[test] diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs index d419327..331b888 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/workspace_index.rs @@ -318,6 +318,54 @@ fn test_method_returning_result_wraps() { } } +#[test] +fn test_method_returning_result_self_substitutes_inner() { + // `Session::open() -> Result` must store + // `Result`, not `Result`. Without nested-Self + // substitution, downstream chains like + // `Session::open().unwrap().diff()` lose the receiver type at + // `.unwrap()` and fall back to `:diff`. + let fix = fixture(&[( + "src/app/session.rs", + r#" + pub struct Session; + pub struct Error; + impl Session { + pub fn open() -> Result { unimplemented!() } + } + "#, + )]); + let borrowed_files = borrowed(&fix); + let index = { + let cfg_test = &HashSet::new(); + let roots = &crate_roots(&["src/app/session.rs"]); + let wraps = &HashSet::new(); + let workspace_files = build_workspace_files_map(WorkspaceFilesInputs { + files: &borrowed_files, + cfg_test_files: cfg_test, + aliases_per_file: &fix.aliases, + aliases_scoped_per_file: &fix.aliases_scoped, + local_symbols_per_file: &fix.local_symbols, + crate_root_modules: roots, + }); + build_workspace_type_index(&WorkspaceIndexInputs { + files: &borrowed_files, + workspace_files: &workspace_files, + cfg_test_files: cfg_test, + transparent_wrappers: wraps, + }) + }; + let ret = index + .method_return("crate::app::session::Session", "open") + .expect("method indexed"); + let session = CanonicalType::path(["crate", "app", "session", "Session"]); + assert_eq!( + ret, + &CanonicalType::Result(Box::new(session)), + "Result must store Result, got {ret:?}" + ); +} + #[test] fn test_method_with_unit_return_is_not_indexed() { let fix = fixture(&[( diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs index 0eeb47e..10d10e1 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs @@ -19,6 +19,7 @@ use crate::adapters::analyzers::architecture::call_parity_rule::bindings::CanonS use crate::adapters::analyzers::architecture::call_parity_rule::workspace_graph::resolve_impl_self_type; use crate::adapters::shared::cfg_test::has_cfg_test; use syn::visit::Visit; +use syn::visit_mut::VisitMut; /// Walk `ast` and populate `index.method_returns`. Integration: delegates /// to the nested visitor. @@ -122,20 +123,65 @@ fn record_method( .insert((receiver_canonical, method_name), ret); } -/// Resolve a method's return type, substituting bare `Self` (and -/// `Self::Inner` paths) with the enclosing impl's canonical self-type. -/// Without this, `pub fn open() -> Self` on `impl Session` would index -/// as `Opaque` because the resolver doesn't know what `Self` refers to. +/// Resolve a method's return type, substituting bare `Self` with the +/// enclosing impl's canonical self-type. The walk is recursive so +/// wrapper return types (`Result`, `Option`, +/// `Vec`) project the inner `Self` correctly — without it the +/// resolver yields `Result` and downstream chains like +/// `Session::open().unwrap().diff()` lose their receiver type. +/// Multi-segment paths (`Self::Output`, `Self::Inner`) keep the raw +/// segments and resolve as before — associated-type resolution stays +/// out of scope. fn resolve_method_return( ret_ty: &syn::Type, impl_segs: &[String], ctx: &BuildContext<'_>, mod_stack: &[String], ) -> CanonicalType { - if let syn::Type::Path(p) = ret_ty { - if p.qself.is_none() && p.path.segments.len() == 1 && p.path.segments[0].ident == "Self" { - return CanonicalType::Path(impl_segs.to_vec()); + let substituted = substitute_bare_self(ret_ty, impl_segs); + resolve_type(&substituted, &resolve_ctx_from_build(ctx, mod_stack)) +} + +/// Clone `ret_ty` and rewrite every bare `Self` ident path to the +/// impl's canonical segments. Operation: clone + visit-mut walk. +fn substitute_bare_self(ret_ty: &syn::Type, impl_segs: &[String]) -> syn::Type { + let mut out = ret_ty.clone(); + let Ok(replacement) = syn::parse_str::(&impl_segs.join("::")) else { + return out; + }; + SelfPathRewriter { replacement }.visit_type_mut(&mut out); + out +} + +/// `VisitMut` adapter that replaces each `Type::Path` whose path is a +/// single bare `Self` with the impl's canonical path. Multi-segment +/// `Self::Output` is intentionally left alone. +struct SelfPathRewriter { + replacement: syn::Path, +} + +impl VisitMut for SelfPathRewriter { + fn visit_type_mut(&mut self, ty: &mut syn::Type) { + if let syn::Type::Path(tp) = ty { + if is_bare_self_path(tp) { + *ty = syn::Type::Path(syn::TypePath { + qself: None, + path: self.replacement.clone(), + }); + return; + } } + syn::visit_mut::visit_type_mut(self, ty); + } +} + +/// True when `tp` is `Self` with no qself, no further segments, and +/// no path arguments — the only shape that maps unambiguously to the +/// enclosing impl's self-type. Operation. +fn is_bare_self_path(tp: &syn::TypePath) -> bool { + if tp.qself.is_some() || tp.path.segments.len() != 1 { + return false; } - resolve_type(ret_ty, &resolve_ctx_from_build(ctx, mod_stack)) + let seg = &tp.path.segments[0]; + seg.ident == "Self" && matches!(seg.arguments, syn::PathArguments::None) } From 69ef1581fccf7e7aa1934c1e42fe951429b4a5e3 Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:31:49 +0200 Subject: [PATCH 16/30] fix: copilot comments --- .../architecture/call_parity_rule/calls.rs | 21 +++- .../call_parity_rule/tests/calls.rs | 21 ++-- .../call_parity_rule/tests/regressions.rs | 96 ++++++++++++++----- .../call_parity_rule/type_infer/mod.rs | 1 + .../call_parity_rule/type_infer/self_subst.rs | 61 ++++++++++++ .../type_infer/tests/combinators.rs | 5 +- .../type_infer/tests/infer_access.rs | 15 +-- .../type_infer/tests/infer_call.rs | 38 ++++---- .../type_infer/tests/patterns_destructure.rs | 20 ++-- .../type_infer/tests/patterns_iterator.rs | 5 +- .../type_infer/workspace_index/fields.rs | 4 +- .../type_infer/workspace_index/methods.rs | 50 +--------- .../type_infer/workspace_index/mod.rs | 56 +++++++++-- 13 files changed, 258 insertions(+), 135 deletions(-) create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/self_subst.rs diff --git a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs index 0de1afa..0212baa 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/calls.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/calls.rs @@ -19,6 +19,7 @@ use super::bindings::{canonical_from_type, extract_let_binding, normalize_alias_expansion}; use super::local_symbols::{scope_for_local, FileScope}; use super::type_infer::resolve::{resolve_type, ResolveContext}; +use super::type_infer::self_subst::substitute_bare_self; use super::type_infer::{ extract_bindings, extract_for_bindings, infer_type, BindingLookup, CanonicalType, InferContext, WorkspaceTypeIndex, @@ -170,8 +171,11 @@ impl<'a> CanonicalCallCollector<'a> { /// Resolve a parameter / closure-arg type through the full /// scope-aware pipeline (alias expansion, transparent wrappers, - /// trait-bound extraction, inline-mod resolution). Used by both - /// signature seeding and closure-param seeding. + /// trait-bound extraction, inline-mod resolution). Pre-substitutes + /// bare `Self` with `self_type_canonical` so impl-body declarations + /// like `fn merge(&self, other: Self)` and typed closure params + /// resolve to the enclosing impl type. Used by both signature + /// seeding and closure-param seeding. fn resolve_param_type(&self, ty: &syn::Type) -> CanonicalType { let rctx = ResolveContext { file: self.file, @@ -181,7 +185,10 @@ impl<'a> CanonicalCallCollector<'a> { workspace_files: self.workspace_files, alias_param_subs: None, }; - resolve_type(ty, &rctx) + match self.self_type_canonical.as_deref() { + Some(impl_segs) => resolve_type(&substitute_bare_self(ty, impl_segs), &rctx), + None => resolve_type(ty, &rctx), + } } fn enter_scope(&mut self) { @@ -468,7 +475,13 @@ impl<'a> CanonicalCallCollector<'a> { alias_param_subs: None, }; let name = pi.ident.to_string(); - match resolve_type(pt.ty.as_ref(), &rctx) { + let resolved = match self.self_type_canonical.as_deref() { + Some(impl_segs) => { + resolve_type(&substitute_bare_self(pt.ty.as_ref(), impl_segs), &rctx) + } + None => resolve_type(pt.ty.as_ref(), &rctx), + }; + match resolved { CanonicalType::Path(segs) => self.install_path_binding(name, segs), other => self.install_non_path_binding(name, other), } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/calls.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/calls.rs index dc394a3..2079048 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/calls.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/calls.rs @@ -884,11 +884,9 @@ fn test_inference_fallback_resolves_rlm_pattern() { ); let mut index = WorkspaceTypeIndex::new(); // `Session::open()` returns Result. - index.method_returns.insert( - ( - "crate::app::session::Session".to_string(), - "open".to_string(), - ), + index.insert_method_return( + "crate::app::session::Session", + "open", CanonicalType::Result(Box::new(CanonicalType::path([ "crate", "app", "session", "Session", ]))), @@ -920,8 +918,9 @@ fn test_inference_fallback_resolves_field_access() { "#, ); let mut index = WorkspaceTypeIndex::new(); - index.struct_fields.insert( - ("crate::app::Ctx".to_string(), "session".to_string()), + index.insert_struct_field( + "crate::app::Ctx", + "session", CanonicalType::path(["crate", "app", "Session"]), ); let fs = fctx.file_scope("src/cli/handlers.rs"); @@ -949,11 +948,9 @@ fn test_inference_fallback_on_result_unwrap_chain() { "#, ); let mut index = WorkspaceTypeIndex::new(); - index.method_returns.insert( - ( - "crate::app::session::Session".to_string(), - "open".to_string(), - ), + index.insert_method_return( + "crate::app::session::Session", + "open", CanonicalType::Result(Box::new(CanonicalType::path([ "crate", "app", "session", "Session", ]))), diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs index 0af84d8..8a7a58d 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs @@ -55,34 +55,29 @@ fn rlm_index() -> WorkspaceTypeIndex { let error = CanonicalType::path(["crate", "app", "Error"]); let mut index = WorkspaceTypeIndex::new(); // Session::open() -> Result - index.method_returns.insert( - (SESSION_PATH.to_string(), "open".to_string()), + index.insert_method_return( + SESSION_PATH, + "open", CanonicalType::Result(Box::new(session.clone())), ); // Session::open_cwd() -> Result - index.method_returns.insert( - (SESSION_PATH.to_string(), "open_cwd".to_string()), + index.insert_method_return( + SESSION_PATH, + "open_cwd", CanonicalType::Result(Box::new(session.clone())), ); // Session::diff() -> Response - index.method_returns.insert( - (SESSION_PATH.to_string(), "diff".to_string()), - response.clone(), - ); + index.insert_method_return(SESSION_PATH, "diff", response.clone()); // Session::files() -> Response - index.method_returns.insert( - (SESSION_PATH.to_string(), "files".to_string()), - response.clone(), - ); + index.insert_method_return(SESSION_PATH, "files", response.clone()); // Session::insert() -> Result - index.method_returns.insert( - (SESSION_PATH.to_string(), "insert".to_string()), + index.insert_method_return( + SESSION_PATH, + "insert", CanonicalType::Result(Box::new(response.clone())), ); // Ctx { session: Session } - index - .struct_fields - .insert((CTX_PATH.to_string(), "session".to_string()), session); + index.insert_struct_field(CTX_PATH, "session", session); // Free fn make_session() -> Result index.fn_returns.insert( "crate::app::make_session".to_string(), @@ -409,6 +404,60 @@ fn self_field_access_resolves_via_impl_type() { ); } +#[test] +fn signature_param_typed_self_resolves() { + // `fn merge(&self, other: Self)` inside `impl Session` — `other` + // is declared as `Self`, must bind to `Session` so `other.diff()` + // routes through `method_returns`. + let fx = parse( + r#" + impl Session { + pub fn merge(&self, other: Self) { + other.diff(); + } + } + "#, + ); + let self_segs = vec![ + "crate".to_string(), + "app".to_string(), + "session".to_string(), + "Session".to_string(), + ]; + let calls = run_impl_method(&fx, &rlm_index(), "Session", "merge", self_segs); + assert!( + calls.contains("crate::app::session::Session::diff"), + "param `other: Self` must resolve to Session, got {calls:?}" + ); +} + +#[test] +fn let_annotation_self_resolves() { + // `let other: Self = make();` inside `impl Session` — annotation + // must substitute Self before resolving. + let fx = parse( + r#" + impl Session { + pub fn run(&self) { + let other: Self = make(); + other.diff(); + } + } + "#, + ); + let self_segs = vec![ + "crate".to_string(), + "app".to_string(), + "session".to_string(), + "Session".to_string(), + ]; + let calls = run_impl_method(&fx, &rlm_index(), "Session", "run", self_segs); + assert!( + calls.contains("crate::app::session::Session::diff"), + "`let other: Self = …` must bind to Session, got {calls:?}" + ); +} + // ═══════════════════════════════════════════════════════════════════ // Positive: free-fn return-type chain // ═══════════════════════════════════════════════════════════════════ @@ -716,8 +765,9 @@ fn user_wrapper_is_peeled_on_signature_param() { ); let db = CanonicalType::path(["crate", "app", "Db"]); let mut index = WorkspaceTypeIndex::new(); - index.method_returns.insert( - ("crate::app::Db".to_string(), "query".to_string()), + index.insert_method_return( + "crate::app::Db", + "query", CanonicalType::path(["crate", "app", "Rows"]), ); // Register `State` as a transparent wrapper. @@ -783,11 +833,9 @@ fn type_alias_expands_to_target_via_signature_param() { }, ); // Store::read() method. - index.method_returns.insert( - ( - "crate::cli::handlers::Store".to_string(), - "read".to_string(), - ), + index.insert_method_return( + "crate::cli::handlers::Store", + "read", CanonicalType::path(["crate", "cli", "handlers", "Data"]), ); // Include `DbRef` in local symbols so the alias key resolves. diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/mod.rs index 65fa28e..73e83d4 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/mod.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/mod.rs @@ -16,6 +16,7 @@ pub mod infer; pub mod patterns; pub mod resolve; mod resolve_alias; +pub(crate) mod self_subst; pub mod workspace_index; // qual:api diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/self_subst.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/self_subst.rs new file mode 100644 index 0000000..29b8fce --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/self_subst.rs @@ -0,0 +1,61 @@ +//! Bare-`Self` substitution for `syn::Type`. +//! +//! Used by both the workspace-index method-return collector and the +//! call collector to rewrite `Self` to the enclosing impl's canonical +//! segments before handing off to `resolve_type`. Without this, +//! wrapper return types (`Result`, `Option`) and +//! impl-body declarations (`fn merge(&self, other: Self)`, +//! `let other: Self = …`) collapse the inner `Self` to `Opaque` +//! because the resolver itself has no impl context. +//! +//! Multi-segment paths like `Self::Output` are left untouched — +//! associated-type resolution is out of scope and would need its own +//! type-bound walker. + +use syn::visit_mut::VisitMut; + +// qual:api +/// Clone `ty` and rewrite every bare `Self` ident path to `impl_segs`. +/// Returns the input untouched when `impl_segs.join("::")` doesn't +/// parse as a path (defensive — real impl segments always do). +pub(crate) fn substitute_bare_self(ty: &syn::Type, impl_segs: &[String]) -> syn::Type { + let mut out = ty.clone(); + let Ok(replacement) = syn::parse_str::(&impl_segs.join("::")) else { + return out; + }; + SelfPathRewriter { replacement }.visit_type_mut(&mut out); + out +} + +/// `VisitMut` adapter that replaces each `Type::Path` whose path is a +/// single bare `Self` with the impl's canonical path. Multi-segment +/// `Self::Output` is intentionally left alone. +struct SelfPathRewriter { + replacement: syn::Path, +} + +impl VisitMut for SelfPathRewriter { + fn visit_type_mut(&mut self, ty: &mut syn::Type) { + if let syn::Type::Path(tp) = ty { + if is_bare_self_path(tp) { + *ty = syn::Type::Path(syn::TypePath { + qself: None, + path: self.replacement.clone(), + }); + return; + } + } + syn::visit_mut::visit_type_mut(self, ty); + } +} + +/// True when `tp` is `Self` with no qself, no further segments, and +/// no path arguments — the only shape that maps unambiguously to the +/// enclosing impl's self-type. Operation. +fn is_bare_self_path(tp: &syn::TypePath) -> bool { + if tp.qself.is_some() || tp.path.segments.len() != 1 { + return false; + } + let seg = &tp.path.segments[0]; + seg.ident == "Self" && matches!(seg.arguments, syn::PathArguments::None) +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs index dc7a4a5..4f6b85d 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs @@ -190,8 +190,9 @@ fn test_result_chain_unwrap_then_field() { use std::collections::{HashMap, HashSet}; let mut index = WorkspaceTypeIndex::new(); - index.struct_fields.insert( - ("crate::app::Session".to_string(), "id".to_string()), + index.insert_struct_field( + "crate::app::Session", + "id", CanonicalType::path(["crate", "app", "Id"]), ); let mut bindings = FlatBindings::new(); diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_access.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_access.rs index 04a7254..fa0a23e 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_access.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_access.rs @@ -18,8 +18,9 @@ fn test_field_access_on_bound_struct() { let mut f = TypeInferFixture::new(); f.bindings .insert("ctx", CanonicalType::path(["crate", "app", "Ctx"])); - f.index.struct_fields.insert( - ("crate::app::Ctx".to_string(), "session".to_string()), + f.index.insert_struct_field( + "crate::app::Ctx", + "session", CanonicalType::path(["crate", "app", "Session"]), ); let t = infer(&f, "ctx.session").expect("field resolved"); @@ -31,12 +32,14 @@ fn test_nested_field_access() { let mut f = TypeInferFixture::new(); f.bindings .insert("ctx", CanonicalType::path(["crate", "app", "Ctx"])); - f.index.struct_fields.insert( - ("crate::app::Ctx".to_string(), "session".to_string()), + f.index.insert_struct_field( + "crate::app::Ctx", + "session", CanonicalType::path(["crate", "app", "Session"]), ); - f.index.struct_fields.insert( - ("crate::app::Session".to_string(), "id".to_string()), + f.index.insert_struct_field( + "crate::app::Session", + "id", CanonicalType::path(["crate", "app", "Id"]), ); let t = infer(&f, "ctx.session.id").expect("nested field resolved"); diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_call.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_call.rs index 39a523a..81ea2d6 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_call.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/infer_call.rs @@ -72,8 +72,9 @@ fn test_call_unknown_fn_is_none() { fn test_call_type_ctor_resolves_via_method_returns() { let mut f = TypeInferFixture::new(); f.local_symbols.insert("Session".to_string()); - f.index.method_returns.insert( - ("crate::app::test::Session".to_string(), "open".to_string()), + f.index.insert_method_return( + "crate::app::test::Session", + "open", CanonicalType::path(["crate", "app", "test", "Session"]), ); let t = infer(&f, "Session::open()").expect("assoc fn resolved"); @@ -92,11 +93,9 @@ fn test_call_ctor_via_alias() { "Session".to_string(), ], ); - f.index.method_returns.insert( - ( - "crate::app::session::Session".to_string(), - "new".to_string(), - ), + f.index.insert_method_return( + "crate::app::session::Session", + "new", CanonicalType::path(["crate", "app", "session", "Session"]), ); let t = infer(&f, "Session::new()").expect("alias resolved"); @@ -110,8 +109,9 @@ fn test_call_ctor_via_alias() { fn test_call_ctor_returning_result() { let mut f = TypeInferFixture::new(); f.local_symbols.insert("Session".to_string()); - f.index.method_returns.insert( - ("crate::app::test::Session".to_string(), "open".to_string()), + f.index.insert_method_return( + "crate::app::test::Session", + "open", CanonicalType::Result(Box::new(CanonicalType::path([ "crate", "app", "test", "Session", ]))), @@ -130,8 +130,9 @@ fn test_call_self_substitutes_to_impl_type() { "app".to_string(), "Session".to_string(), ]); - f.index.method_returns.insert( - ("crate::app::Session".to_string(), "new".to_string()), + f.index.insert_method_return( + "crate::app::Session", + "new", CanonicalType::path(["crate", "app", "Session"]), ); let t = infer(&f, "Self::new()").expect("Self::new resolved"); @@ -151,8 +152,9 @@ fn test_method_call_with_bound_receiver() { let mut f = TypeInferFixture::new(); f.bindings .insert("session", CanonicalType::path(["crate", "app", "Session"])); - f.index.method_returns.insert( - ("crate::app::Session".to_string(), "diff".to_string()), + f.index.insert_method_return( + "crate::app::Session", + "diff", CanonicalType::path(["crate", "app", "Response"]), ); let t = infer(&f, "session.diff()").expect("method resolved"); @@ -167,8 +169,9 @@ fn test_method_call_chained_via_fn_return() { "crate::app::test::make_session".to_string(), CanonicalType::path(["crate", "app", "Session"]), ); - f.index.method_returns.insert( - ("crate::app::Session".to_string(), "diff".to_string()), + f.index.insert_method_return( + "crate::app::Session", + "diff", CanonicalType::path(["crate", "app", "Response"]), ); let t = infer(&f, "make_session().diff()").expect("chain resolved"); @@ -219,8 +222,9 @@ fn test_method_call_on_reference_strips_and_resolves() { let mut f = TypeInferFixture::new(); f.bindings .insert("s", CanonicalType::path(["crate", "app", "Session"])); - f.index.method_returns.insert( - ("crate::app::Session".to_string(), "diff".to_string()), + f.index.insert_method_return( + "crate::app::Session", + "diff", CanonicalType::path(["crate", "app", "Response"]), ); let t = infer(&f, "(&s).diff()").expect("ref receiver resolved"); diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs index 8b4e314..ac69b3b 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs @@ -119,8 +119,9 @@ fn test_none_pattern_binds_nothing() { #[test] fn test_struct_pattern_binds_field_by_name() { let mut f = TypeInferFixture::new(); - f.index.struct_fields.insert( - ("crate::app::Ctx".to_string(), "session".to_string()), + f.index.insert_struct_field( + "crate::app::Ctx", + "session", CanonicalType::path(["crate", "app", "Session"]), ); let matched = CanonicalType::path(["crate", "app", "Ctx"]); @@ -133,8 +134,9 @@ fn test_struct_pattern_binds_field_by_name() { #[test] fn test_struct_pattern_with_aliased_field() { let mut f = TypeInferFixture::new(); - f.index.struct_fields.insert( - ("crate::app::Ctx".to_string(), "session".to_string()), + f.index.insert_struct_field( + "crate::app::Ctx", + "session", CanonicalType::path(["crate", "app", "Session"]), ); let matched = CanonicalType::path(["crate", "app", "Ctx"]); @@ -159,8 +161,9 @@ fn test_struct_pattern_missing_field_binds_opaque() { #[test] fn test_struct_pattern_with_rest() { let mut f = TypeInferFixture::new(); - f.index.struct_fields.insert( - ("crate::app::Ctx".to_string(), "a".to_string()), + f.index.insert_struct_field( + "crate::app::Ctx", + "a", CanonicalType::path(["crate", "app", "A"]), ); let matched = CanonicalType::path(["crate", "app", "Ctx"]); @@ -235,8 +238,9 @@ fn test_or_pattern_uses_first_branch_bindings() { #[test] fn test_nested_some_struct() { let mut f = TypeInferFixture::new(); - f.index.struct_fields.insert( - ("crate::app::Ctx".to_string(), "id".to_string()), + f.index.insert_struct_field( + "crate::app::Ctx", + "id", CanonicalType::path(["crate", "app", "Id"]), ); // matched: Option; pattern unwraps Some to Ctx then binds id. diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_iterator.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_iterator.rs index 320d719..4216cc5 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_iterator.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_iterator.rs @@ -47,8 +47,9 @@ fn test_for_over_opaque_binds_opaque() { #[test] fn test_for_with_destructuring_pattern() { let mut f = TypeInferFixture::new(); - f.index.struct_fields.insert( - ("crate::app::Handler".to_string(), "id".to_string()), + f.index.insert_struct_field( + "crate::app::Handler", + "id", CanonicalType::path(["crate", "app", "Id"]), ); let vec_type = CanonicalType::Slice(Box::new(CanonicalType::path(["crate", "app", "Handler"]))); diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs index 1ce34db..c0092ab 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/fields.rs @@ -93,9 +93,7 @@ fn record_field( if matches!(field_type, CanonicalType::Opaque) { return; } - index - .struct_fields - .insert((canonical.to_string(), ident.to_string()), field_type); + index.insert_struct_field(canonical, ident.to_string(), field_type); } /// Build `crate::::::` from a diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs index 10d10e1..17169d9 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs @@ -14,12 +14,12 @@ use super::super::canonical::CanonicalType; use super::super::resolve::resolve_type; +use super::super::self_subst::substitute_bare_self; use super::{canonical_type_key, resolve_ctx_from_build, BuildContext, WorkspaceTypeIndex}; use crate::adapters::analyzers::architecture::call_parity_rule::bindings::CanonScope; use crate::adapters::analyzers::architecture::call_parity_rule::workspace_graph::resolve_impl_self_type; use crate::adapters::shared::cfg_test::has_cfg_test; use syn::visit::Visit; -use syn::visit_mut::VisitMut; /// Walk `ast` and populate `index.method_returns`. Integration: delegates /// to the nested visitor. @@ -118,9 +118,7 @@ fn record_method( }; let receiver_canonical = canonical_type_key(impl_segs, ctx, mod_stack); let method_name = node.sig.ident.to_string(); - index - .method_returns - .insert((receiver_canonical, method_name), ret); + index.insert_method_return(receiver_canonical, method_name, ret); } /// Resolve a method's return type, substituting bare `Self` with the @@ -141,47 +139,3 @@ fn resolve_method_return( let substituted = substitute_bare_self(ret_ty, impl_segs); resolve_type(&substituted, &resolve_ctx_from_build(ctx, mod_stack)) } - -/// Clone `ret_ty` and rewrite every bare `Self` ident path to the -/// impl's canonical segments. Operation: clone + visit-mut walk. -fn substitute_bare_self(ret_ty: &syn::Type, impl_segs: &[String]) -> syn::Type { - let mut out = ret_ty.clone(); - let Ok(replacement) = syn::parse_str::(&impl_segs.join("::")) else { - return out; - }; - SelfPathRewriter { replacement }.visit_type_mut(&mut out); - out -} - -/// `VisitMut` adapter that replaces each `Type::Path` whose path is a -/// single bare `Self` with the impl's canonical path. Multi-segment -/// `Self::Output` is intentionally left alone. -struct SelfPathRewriter { - replacement: syn::Path, -} - -impl VisitMut for SelfPathRewriter { - fn visit_type_mut(&mut self, ty: &mut syn::Type) { - if let syn::Type::Path(tp) = ty { - if is_bare_self_path(tp) { - *ty = syn::Type::Path(syn::TypePath { - qself: None, - path: self.replacement.clone(), - }); - return; - } - } - syn::visit_mut::visit_type_mut(self, ty); - } -} - -/// True when `tp` is `Self` with no qself, no further segments, and -/// no path arguments — the only shape that maps unambiguously to the -/// enclosing impl's self-type. Operation. -fn is_bare_self_path(tp: &syn::TypePath) -> bool { - if tp.qself.is_some() || tp.path.segments.len() != 1 { - return false; - } - let seg = &tp.path.segments[0]; - seg.ident == "Self" && matches!(seg.arguments, syn::PathArguments::None) -} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs index 9ee8cf9..8424ba9 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/mod.rs @@ -91,12 +91,20 @@ pub(super) fn resolve_ctx_from_build<'a>( } /// Lookup tables populated from one walk over the workspace. +/// +/// `struct_fields` and `method_returns` use a nested map shape +/// (outer keyed by canonical type, inner by field/method) so the hot +/// `infer_field` / `infer_method_call` paths can probe with `&str`s +/// against the inner map without allocating a `(String, String)` key +/// per lookup. The dedicated `insert_struct_field` / +/// `insert_method_return` helpers keep call-sites tidy in production +/// and tests. #[derive(Default)] pub struct WorkspaceTypeIndex { - /// `(struct_canonical, field_name) → canonical field type`. - pub struct_fields: HashMap<(String, String), CanonicalType>, - /// `(receiver_type_canonical, method_name) → canonical return type`. - pub method_returns: HashMap<(String, String), CanonicalType>, + /// `struct_canonical → {field_name → canonical field type}`. + pub struct_fields: HashMap>, + /// `receiver_canonical → {method_name → canonical return type}`. + pub method_returns: HashMap>, /// `canonical_free_fn_name → canonical return type`. pub fn_returns: HashMap, /// `trait_canonical → [impl_type_canonical, …]`. Every @@ -124,17 +132,47 @@ impl WorkspaceTypeIndex { } // qual:api - /// Look up a struct field's canonical type. Operation. + /// Look up a struct field's canonical type. Two `&str` probes + /// against the nested map — no allocation. Operation. pub fn struct_field(&self, type_canonical: &str, field: &str) -> Option<&CanonicalType> { - self.struct_fields - .get(&(type_canonical.to_string(), field.to_string())) + self.struct_fields.get(type_canonical)?.get(field) } // qual:api - /// Look up a method's return type. Operation. + /// Look up a method's return type. Two `&str` probes against the + /// nested map — no allocation. Operation. pub fn method_return(&self, receiver_canonical: &str, method: &str) -> Option<&CanonicalType> { + self.method_returns.get(receiver_canonical)?.get(method) + } + + // qual:api + /// Insert a `(type, field) → canonical` entry. Builds the nested + /// map shape on demand. Operation. + pub fn insert_struct_field( + &mut self, + type_canonical: impl Into, + field: impl Into, + ty: CanonicalType, + ) { + self.struct_fields + .entry(type_canonical.into()) + .or_default() + .insert(field.into(), ty); + } + + // qual:api + /// Insert a `(receiver, method) → canonical` entry. Builds the + /// nested map shape on demand. Operation. + pub fn insert_method_return( + &mut self, + receiver_canonical: impl Into, + method: impl Into, + ret: CanonicalType, + ) { self.method_returns - .get(&(receiver_canonical.to_string(), method.to_string())) + .entry(receiver_canonical.into()) + .or_default() + .insert(method.into(), ret); } // qual:api From 1b504d8cfe50187a0d5ffc4962cfd35a502b7491 Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:47:37 +0200 Subject: [PATCH 17/30] fix: copilot comments --- .../call_parity_rule/tests/regressions.rs | 83 +++++++++++++++++++ .../type_infer/infer/access.rs | 6 +- .../type_infer/infer/generics.rs | 6 +- .../type_infer/patterns/destructure.rs | 6 +- 4 files changed, 98 insertions(+), 3 deletions(-) diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs index 8a7a58d..cd5d03f 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs @@ -458,6 +458,89 @@ fn let_annotation_self_resolves() { ); } +#[test] +fn turbofish_self_inside_impl_resolves() { + // `let s = get::(); s.diff();` inside `impl Session`. The + // turbofish-as-return-type fallback must substitute Self before + // resolving the type argument so the binding pins to Session. + let fx = parse( + r#" + impl Session { + pub fn run(&self) { + let s = get::(); + s.diff(); + } + } + "#, + ); + let self_segs = vec![ + "crate".to_string(), + "app".to_string(), + "session".to_string(), + "Session".to_string(), + ]; + let calls = run_impl_method(&fx, &rlm_index(), "Session", "run", self_segs); + assert!( + calls.contains("crate::app::session::Session::diff"), + "`get::()` turbofish must resolve to Session, got {calls:?}" + ); +} + +#[test] +fn annotated_destructuring_self_resolves() { + // `let Some(other): Option = maybe() else { return; };` — + // the annotation goes through `bind_annotated` in the destructure + // walker, which must substitute Self before resolving. + let fx = parse( + r#" + impl Session { + pub fn run(&self) { + let Some(other): Option = maybe() else { return; }; + other.diff(); + } + } + "#, + ); + let self_segs = vec![ + "crate".to_string(), + "app".to_string(), + "session".to_string(), + "Session".to_string(), + ]; + let calls = run_impl_method(&fx, &rlm_index(), "Session", "run", self_segs); + assert!( + calls.contains("crate::app::session::Session::diff"), + "annotated destructuring with Self must bind to Session, got {calls:?}" + ); +} + +#[test] +fn cast_as_self_resolves() { + // `(expr as Self).diff()` inside `impl Session` — `infer_cast` + // resolves the target type, which must substitute Self. + let fx = parse( + r#" + impl Session { + pub fn run(&self) { + let s = (raw() as Self); + s.diff(); + } + } + "#, + ); + let self_segs = vec![ + "crate".to_string(), + "app".to_string(), + "session".to_string(), + "Session".to_string(), + ]; + let calls = run_impl_method(&fx, &rlm_index(), "Session", "run", self_segs); + assert!( + calls.contains("crate::app::session::Session::diff"), + "`as Self` cast must resolve to Session, got {calls:?}" + ); +} + // ═══════════════════════════════════════════════════════════════════ // Positive: free-fn return-type chain // ═══════════════════════════════════════════════════════════════════ diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/access.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/access.rs index 5cc7acd..894668d 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/access.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/access.rs @@ -10,6 +10,7 @@ use super::super::canonical::CanonicalType; use super::super::resolve::{resolve_type, ResolveContext}; +use super::super::self_subst::substitute_bare_self; use super::InferContext; /// `base.field` — recurse on `base`, then look up the field in the @@ -65,7 +66,10 @@ pub(super) fn infer_cast(c: &syn::ExprCast, ctx: &InferContext<'_>) -> Option resolve_type(&substitute_bare_self(&c.ty, impl_segs), &rctx), + None => resolve_type(&c.ty, &rctx), + }; if ty.is_opaque() { None } else { diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/generics.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/generics.rs index 713e2ea..5ac7035 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/generics.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/infer/generics.rs @@ -10,6 +10,7 @@ use super::super::canonical::CanonicalType; use super::super::resolve::{resolve_type, ResolveContext}; +use super::super::self_subst::substitute_bare_self; use super::InferContext; /// Fallback after `fn_returns` / `method_returns` miss: use the @@ -39,7 +40,10 @@ pub(super) fn turbofish_return_type( workspace_files: ctx.workspace_files, alias_param_subs: None, }; - let resolved = resolve_type(first_ty, &rctx); + let resolved = match ctx.self_type.as_deref() { + Some(impl_segs) => resolve_type(&substitute_bare_self(first_ty, impl_segs), &rctx), + None => resolve_type(first_ty, &rctx), + }; if matches!(resolved, CanonicalType::Opaque) { return None; } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs index b4a8f61..6c81d00 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs @@ -9,6 +9,7 @@ use super::super::canonical::CanonicalType; use super::super::infer::InferContext; use super::super::resolve::{resolve_type, ResolveContext}; +use super::super::self_subst::substitute_bare_self; // qual:api /// Extract `(binding_name, canonical_type)` pairs from a pattern matched @@ -94,7 +95,10 @@ fn bind_annotated( workspace_files: ctx.workspace_files, alias_param_subs: None, }; - resolve_type(ty, &rctx) + match ctx.self_type.as_deref() { + Some(impl_segs) => resolve_type(&substitute_bare_self(ty, impl_segs), &rctx), + None => resolve_type(ty, &rctx), + } }; let annotated = resolve(&pt.ty); collect(&pt.pat, &annotated, ctx, out); From b6644fe4cfd6b9bf7a58e5bb381894faf0b10c57 Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:10:21 +0200 Subject: [PATCH 18/30] fix: copilot comments --- .../architecture/call_parity_rule/bindings.rs | 6 -- .../architecture/call_parity_rule/pub_fns.rs | 21 +++++-- .../call_parity_rule/tests/pub_fns.rs | 58 +++++++++++++++++++ .../type_infer/combinators.rs | 7 ++- .../type_infer/patterns/destructure.rs | 16 +++-- .../type_infer/tests/combinators.rs | 12 ++++ .../type_infer/tests/patterns_destructure.rs | 17 ++++++ 7 files changed, 120 insertions(+), 17 deletions(-) diff --git a/src/adapters/analyzers/architecture/call_parity_rule/bindings.rs b/src/adapters/analyzers/architecture/call_parity_rule/bindings.rs index 0f4083a..209530f 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/bindings.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/bindings.rs @@ -146,12 +146,6 @@ pub(crate) fn canonicalise_type_segments_in_scope( full.extend_from_slice(segments); return Some(full); } - if file.local_decl_scopes.is_empty() { - let mut full = vec!["crate".to_string()]; - full.extend(file_to_module_segments(file.path)); - full.extend_from_slice(segments); - return Some(full); - } } if file.crate_root_modules.contains(&segments[0]) { let mut full = vec!["crate".to_string()]; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs index e79bb06..3864609 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs @@ -97,6 +97,7 @@ pub(crate) fn collect_pub_fns_by_layer<'ast>( visible_types: &visible_types, impl_stack: Vec::new(), mod_stack: Vec::new(), + enclosing_mod_visible: true, }; collector.visit_file(ast); out.entry(layer).or_default().extend(collector.found); @@ -135,8 +136,11 @@ fn collect_visible_type_names_workspace( } /// Walk a slice of items, inserting visible type-name idents and -/// recursing into non-cfg-test inline mods. Operation: closure-hidden -/// recursion through nested `mod` blocks. +/// recursing into non-cfg-test inline mods. Recursion is gated on the +/// inline mod's own visibility — `mod private { pub struct X; }` +/// keeps `X` out of the workspace-visible set so its impl methods +/// don't leak into Check A/B as adapter surface. Operation: +/// closure-hidden recursion through nested `mod` blocks. // qual:recursive fn collect_visible_type_names_in_items(items: &[syn::Item], out: &mut HashSet) { let recurse = |inner: &[syn::Item], out: &mut HashSet| { @@ -159,7 +163,7 @@ fn collect_visible_type_names_in_items(items: &[syn::Item], out: &mut HashSet { out.insert(t.ident.to_string()); } - syn::Item::Mod(m) if !has_cfg_test(&m.attrs) => { + syn::Item::Mod(m) if is_visible(&m.vis) && !has_cfg_test(&m.attrs) => { if let Some((_, inner)) = m.content.as_ref() { recurse(inner, out); } @@ -186,6 +190,12 @@ struct PubFnCollector<'ast, 'vis> { impl_stack: Vec<(Vec, bool)>, /// Names of enclosing inline `mod inner { ... }` blocks. mod_stack: Vec, + /// True when every enclosing inline `mod` carries a visibility + /// modifier. False as soon as any ancestor is private. Top-level + /// items are always visible. Without this, `mod private { pub fn + /// helper() {} }` would record `helper` even though it's not + /// reachable from outside the parent module. + enclosing_mod_visible: bool, } impl<'ast, 'vis> PubFnCollector<'ast, 'vis> { @@ -231,7 +241,7 @@ fn is_test_fn(attrs: &[syn::Attribute]) -> bool { impl<'ast, 'vis> Visit<'ast> for PubFnCollector<'ast, 'vis> { fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) { - if is_visible(&node.vis) && !is_test_fn(&node.attrs) { + if self.enclosing_mod_visible && is_visible(&node.vis) && !is_test_fn(&node.attrs) { let line = syn::spanned::Spanned::span(&node.sig.ident).start().line; let name = node.sig.ident.to_string(); self.record_fn(name, line, &node.block, &node.sig); @@ -288,8 +298,11 @@ impl<'ast, 'vis> Visit<'ast> for PubFnCollector<'ast, 'vis> { if has_cfg_test(&node.attrs) { return; } + let parent_visible = self.enclosing_mod_visible; + self.enclosing_mod_visible = parent_visible && is_visible(&node.vis); self.mod_stack.push(node.ident.to_string()); syn::visit::visit_item_mod(self, node); self.mod_stack.pop(); + self.enclosing_mod_visible = parent_visible; } } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs index 4e85654..2fd45b7 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs @@ -284,3 +284,61 @@ fn test_collect_pub_fns_skips_unmatched_files() { ); } } + +#[test] +fn test_collect_pub_fns_skips_pub_fn_inside_private_inline_mod() { + // `mod private { pub fn helper() {} }` — `helper` is `pub` but + // its parent `mod private` has inherited visibility, so the fn + // isn't reachable from outside the parent module. Must not be + // recorded as adapter / target surface. + let file = parse( + r#" + mod private { + pub fn helper() {} + } + pub fn visible_top() {} + "#, + ); + let files = vec![("src/cli/handlers.rs", &file)]; + let by_layer = { + let aliases = aliases_from_files(&files); + collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + }; + let cli = names_for_layer(&by_layer, "cli"); + assert!( + cli.contains("visible_top"), + "top-level pub fn must be recorded, got {cli:?}" + ); + assert!( + !cli.contains("helper"), + "pub fn inside private inline mod must be skipped, got {cli:?}" + ); +} + +#[test] +fn test_collect_pub_fns_skips_impl_method_on_type_in_private_inline_mod() { + // `mod private { pub struct Hidden; impl Hidden { pub fn op() {} } }` + // — `Hidden` is pub but only inside a private mod, so its + // workspace-visible-types entry must NOT register, and the impl + // method `op` must not appear as adapter surface. + let file = parse( + r#" + mod private { + pub struct Hidden; + impl Hidden { + pub fn op(&self) {} + } + } + "#, + ); + let files = vec![("src/cli/handlers.rs", &file)]; + let by_layer = { + let aliases = aliases_from_files(&files); + collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + }; + let cli = names_for_layer(&by_layer, "cli"); + assert!( + !cli.contains("op"), + "impl method on type in private mod must be skipped, got {cli:?}" + ); +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/combinators.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/combinators.rs index 9879abc..64c1252 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/combinators.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/combinators.rs @@ -41,8 +41,11 @@ fn result_combinator(method: &str, inner: &CanonicalType) -> Option. // `inspect` / `inspect_err` hand the closure a borrow and // return self — the closure's body type doesn't change the - // wrapper, so they stay resolved. - "map_err" | "or_else" | "inspect" | "inspect_err" => { + // wrapper, so they stay resolved. `as_ref` / `as_mut` return + // `Result<&T, &E>` / `Result<&mut T, &mut E>`; since + // `resolve_type` strips references, they reduce to the same + // `Result` shape. + "map_err" | "or_else" | "inspect" | "inspect_err" | "as_ref" | "as_mut" => { Some(CanonicalType::Result(Box::new(inner.clone()))) } // Extract the Ok-side as Option diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs index 6c81d00..53b35ca 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/patterns/destructure.rs @@ -35,7 +35,7 @@ fn collect( out: &mut Vec<(String, CanonicalType)>, ) { match pat { - syn::Pat::Ident(pi) => bind_ident(pi, matched_type, out), + syn::Pat::Ident(pi) => bind_ident(pi, matched_type, ctx, out), syn::Pat::Type(pt) => bind_annotated(pt, ctx, out), syn::Pat::Reference(r) => collect(&r.pat, matched_type, ctx, out), syn::Pat::Paren(p) => collect(&p.pat, matched_type, ctx, out), @@ -51,7 +51,10 @@ fn collect( /// `Pat::Ident(x)` — the simplest case: bind the name to the matched /// type. `Opaque` bindings are still recorded so shadowing works /// correctly (a later `ctx.bindings.lookup(x)` sees `Some(Opaque)`, -/// which callers can treat as "known unresolvable"). +/// which callers can treat as "known unresolvable"). When the ident +/// carries an `@` subpattern (`whole @ Some(s)`), the subpattern is +/// matched against the same `matched_type` so nested bindings (`s`) +/// also get extracted. /// /// Syn-level ambiguity: `None` as a pattern is represented as /// `Pat::Ident("None")`, not a distinct variant pattern. We specifically @@ -63,13 +66,16 @@ fn collect( fn bind_ident( pi: &syn::PatIdent, matched_type: &CanonicalType, + ctx: &InferContext<'_>, out: &mut Vec<(String, CanonicalType)>, ) { let name = pi.ident.to_string(); - if is_variant_like(&name, matched_type) { - return; + if !is_variant_like(&name, matched_type) { + out.push((name, matched_type.clone())); + } + if let Some((_, subpat)) = pi.subpat.as_ref() { + collect(subpat, matched_type, ctx, out); } - out.push((name, matched_type.clone())); } /// True for identifiers that are unambiguously a stdlib enum-variant diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs index 4f6b85d..b261d17 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/combinators.rs @@ -81,6 +81,18 @@ fn test_result_inspect_variants_preserve_wrapper() { assert_eq!(combinator_return(&res, "inspect_err"), expected); } +#[test] +fn test_result_as_ref_as_mut_preserve_wrapper() { + // `Result::as_ref()` returns `Result<&T, &E>` and `Result::as_mut` + // returns `Result<&mut T, &mut E>`. The resolver strips references, + // so for inference purposes both keep the receiver's `Result` + // shape — `open().as_ref().unwrap().diff()` must stay resolvable. + let res = CanonicalType::Result(Box::new(t())); + let expected = Some(CanonicalType::Result(Box::new(t()))); + assert_eq!(combinator_return(&res, "as_ref"), expected); + assert_eq!(combinator_return(&res, "as_mut"), expected); +} + #[test] fn test_result_map_is_unresolved() { // `.map(|x| ...)` depends on the closure — unresolved by design. diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs index ac69b3b..ce92558 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs @@ -31,6 +31,23 @@ fn test_wildcard_binds_nothing() { assert!(bindings(&f, "_", CanonicalType::path(["crate", "T"])).is_empty()); } +#[test] +fn test_at_subpattern_walks_inner_pattern() { + // `whole @ Some(s)` against `Option` must bind both + // `whole` (full Option) and `s` (the unwrapped Session). + // Without recursion into the subpattern, `s` would be lost or + // tombstoned later as Opaque. + let f = TypeInferFixture::new(); + let session = CanonicalType::path(["crate", "app", "Session"]); + let opt = CanonicalType::Option(Box::new(session.clone())); + let b = bindings(&f, "whole @ Some(s)", opt.clone()); + assert_eq!(b.len(), 2, "expected `whole` + `s`, got {b:?}"); + assert_eq!(b[0].0, "whole"); + assert_eq!(b[0].1, opt); + assert_eq!(b[1].0, "s"); + assert_eq!(b[1].1, session); +} + // ── Pat::Type (explicit annotation) ────────────────────────────── #[test] From ce0ab4cf80e931dc2f826b9c83558d6caa69e939 Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:15:28 +0200 Subject: [PATCH 19/30] fix: copilot comments --- .../architecture/call_parity_rule/pub_fns.rs | 65 ++++++++++- .../call_parity_rule/tests/pub_fns.rs | 103 ++++++++++++++++++ .../analyzers/architecture/compiled.rs | 8 +- .../analyzers/architecture/tests/compiled.rs | 29 +++++ src/adapters/config/architecture.rs | 10 +- 5 files changed, 204 insertions(+), 11 deletions(-) diff --git a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs index 3864609..02148c0 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs @@ -139,8 +139,13 @@ fn collect_visible_type_names_workspace( /// recursing into non-cfg-test inline mods. Recursion is gated on the /// inline mod's own visibility — `mod private { pub struct X; }` /// keeps `X` out of the workspace-visible set so its impl methods -/// don't leak into Check A/B as adapter surface. Operation: -/// closure-hidden recursion through nested `mod` blocks. +/// don't leak into Check A/B as adapter surface. `pub use` items +/// register their leaf names so re-exported types +/// (`pub use private::Hidden;`) still count as workspace-visible +/// surface even when the source module is private. Glob re-exports +/// (`pub use foo::*`) are intentionally skipped — without expanding +/// the source module we can't statically tell which idents leak. +/// Operation: closure-hidden recursion through nested `mod` blocks. // qual:recursive fn collect_visible_type_names_in_items(items: &[syn::Item], out: &mut HashSet) { let recurse = |inner: &[syn::Item], out: &mut HashSet| { @@ -163,6 +168,9 @@ fn collect_visible_type_names_in_items(items: &[syn::Item], out: &mut HashSet { out.insert(t.ident.to_string()); } + syn::Item::Use(u) if is_visible(&u.vis) => { + collect_use_tree_names(&u.tree, out); + } syn::Item::Mod(m) if is_visible(&m.vis) && !has_cfg_test(&m.attrs) => { if let Some((_, inner)) = m.content.as_ref() { recurse(inner, out); @@ -173,6 +181,29 @@ fn collect_visible_type_names_in_items(items: &[syn::Item], out: &mut HashSet) { + match tree { + syn::UseTree::Path(p) => collect_use_tree_names(&p.tree, out), + syn::UseTree::Name(n) => { + out.insert(n.ident.to_string()); + } + syn::UseTree::Rename(r) => { + out.insert(r.rename.to_string()); + } + syn::UseTree::Group(g) => { + for sub in &g.items { + collect_use_tree_names(sub, out); + } + } + syn::UseTree::Glob(_) => {} + } +} + /// Workspace-walker — visits items, tracks impl-type visibility /// for nested impl methods, collects pub fn metadata. struct PubFnCollector<'ast, 'vis> { @@ -227,10 +258,23 @@ impl<'ast, 'vis> PubFnCollector<'ast, 'vis> { } /// Visibility modifier counts as "visible for the check" iff it's -/// anything other than the implicit (no-modifier) case. See D-5 for -/// the rationale. +/// `pub`, `pub(crate)`, `pub(super)`, or `pub(in )` for any +/// non-`self` path. `Inherited` and `pub(self)` / `pub(in self)` +/// (which Rust treats as equivalent to inherited visibility) both +/// stay out of scope. See D-5 for the rationale. fn is_visible(vis: &Visibility) -> bool { - !matches!(vis, Visibility::Inherited) + match vis { + Visibility::Inherited => false, + Visibility::Restricted(r) => !is_self_restricted(&r.path), + _ => true, + } +} + +/// True when a `pub(in path)` restriction targets `self` — i.e. +/// `pub(self)` or `pub(in self)`. Both compile to a single-segment +/// path of `self`. Operation. +fn is_self_restricted(path: &syn::Path) -> bool { + path.leading_colon.is_none() && path.segments.len() == 1 && path.segments[0].ident == "self" } /// True iff the `#[test]` / `#[cfg(test)]` attribute set would make @@ -283,7 +327,16 @@ impl<'ast, 'vis> Visit<'ast> for PubFnCollector<'ast, 'vis> { } fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) { - if self.current_impl_visible() && is_visible(&node.vis) && !is_test_fn(&node.attrs) { + // Gate on enclosing-mod visibility too: `visible_types` keys on + // bare type idents, so a name collision with a public sibling + // would otherwise leak `mod private { impl Session { … } }`'s + // methods into the surface. The mod-stack flag fixes that + // without restructuring `visible_types` to canonical paths. + let visible = self.enclosing_mod_visible + && self.current_impl_visible() + && is_visible(&node.vis) + && !is_test_fn(&node.attrs); + if visible { let line = syn::spanned::Spanned::span(&node.sig.ident).start().line; let name = node.sig.ident.to_string(); self.record_fn(name, line, &node.block, &node.sig); diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs index 2fd45b7..63217ae 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs @@ -315,6 +315,32 @@ fn test_collect_pub_fns_skips_pub_fn_inside_private_inline_mod() { ); } +#[test] +fn test_collect_pub_fns_treats_pub_self_as_private() { + // `pub(self) fn helper()` is semantically private — equivalent to + // inherited visibility. Must not be recorded. + let file = parse( + r#" + pub(self) fn helper() {} + pub fn visible() {} + "#, + ); + let files = vec![("src/cli/handlers.rs", &file)]; + let by_layer = { + let aliases = aliases_from_files(&files); + collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + }; + let cli = names_for_layer(&by_layer, "cli"); + assert!( + cli.contains("visible"), + "plain `pub fn` must be recorded, got {cli:?}" + ); + assert!( + !cli.contains("helper"), + "`pub(self) fn` is private-equivalent and must be skipped, got {cli:?}" + ); +} + #[test] fn test_collect_pub_fns_skips_impl_method_on_type_in_private_inline_mod() { // `mod private { pub struct Hidden; impl Hidden { pub fn op() {} } }` @@ -342,3 +368,80 @@ fn test_collect_pub_fns_skips_impl_method_on_type_in_private_inline_mod() { "impl method on type in private mod must be skipped, got {cli:?}" ); } + +#[test] +fn test_collect_pub_fns_records_pub_use_reexport_with_qualified_impl() { + // `pub use private::Hidden;` with the impl at file level + // (qualified `impl private::Hidden { … }`) — the re-export adds + // `Hidden` to the workspace-visible-types set, and the impl + // itself is OUTSIDE the private mod, so its methods get recorded. + // + // Limit (not covered): `impl Hidden { … }` *inside* `mod + // private { … }` stays out — the enclosing-mod visibility gate + // (which prevents short-name collisions with public siblings) + // takes precedence. Document at call-site rather than special- + // case the resolver. Workaround: lift the impl out of the + // private mod or make the mod itself `pub`. + let file = parse( + r#" + mod private { + pub struct Hidden; + } + impl private::Hidden { + pub fn op(&self) {} + } + pub use private::Hidden; + "#, + ); + let files = vec![("src/cli/handlers.rs", &file)]; + let by_layer = { + let aliases = aliases_from_files(&files); + collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + }; + let cli = names_for_layer(&by_layer, "cli"); + assert!( + cli.contains("op"), + "re-exported type with file-level impl must be recorded, got {cli:?}" + ); +} + +#[test] +fn test_collect_pub_fns_skips_impl_methods_under_short_name_collision() { + // The visible-types set is keyed by short ident, so two distinct + // types named `Session` (one public, one in a private inline mod) + // collide. Without enclosing-mod gating on impl methods, the + // private one's impl method would still register because the + // short name "Session" lives in visible_types via the public + // sibling. Gate on enclosing-mod visibility to keep the private + // method out. + let file = parse( + r#" + pub mod api { + pub struct Session; + impl Session { + pub fn run(&self) {} + } + } + mod internal { + pub struct Session; + impl Session { + pub fn cleanup(&self) {} + } + } + "#, + ); + let files = vec![("src/cli/handlers.rs", &file)]; + let by_layer = { + let aliases = aliases_from_files(&files); + collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + }; + let cli = names_for_layer(&by_layer, "cli"); + assert!( + cli.contains("run"), + "public-mod impl method must be recorded, got {cli:?}" + ); + assert!( + !cli.contains("cleanup"), + "private-mod impl method must not leak via short-name collision, got {cli:?}" + ); +} diff --git a/src/adapters/analyzers/architecture/compiled.rs b/src/adapters/analyzers/architecture/compiled.rs index 1229038..d274902 100644 --- a/src/adapters/analyzers/architecture/compiled.rs +++ b/src/adapters/analyzers/architecture/compiled.rs @@ -140,10 +140,12 @@ fn compile_call_parity( } /// Return the last `::`-separated segment of a path-like wrapper entry -/// so users can write `axum::extract::State` or just `State` and both -/// match resolver lookups keyed on the type's last ident. Operation. +/// with any generic-arg suffix stripped, so users can write +/// `axum::extract::State`, plain `State`, or even `State` and all +/// match resolver lookups keyed on the type's bare ident. Operation. fn last_path_segment(path: &str) -> &str { - path.rsplit("::").next().unwrap_or(path) + let after_path = path.rsplit("::").next().unwrap_or(path); + after_path.split('<').next().unwrap_or(after_path) } /// Stage 3 starter-pack: prepend these common framework attribute-macro diff --git a/src/adapters/analyzers/architecture/tests/compiled.rs b/src/adapters/analyzers/architecture/tests/compiled.rs index e519418..064afb8 100644 --- a/src/adapters/analyzers/architecture/tests/compiled.rs +++ b/src/adapters/analyzers/architecture/tests/compiled.rs @@ -246,6 +246,35 @@ fn compile_call_parity_rejects_invalid_exclude_glob() { ); } +#[test] +fn compile_call_parity_normalises_transparent_wrappers() { + // Resolver lookups are keyed on the bare type ident, so config + // values may include path prefixes (`axum::extract::State`) and + // generic suffixes (`State`); both must reduce to `State`. + let mut cfg = call_parity_cfg(); + let mut cp = minimal_call_parity(); + cp.transparent_wrappers = vec![ + " State ".to_string(), + "axum::extract::Extension".to_string(), + "Json".to_string(), + "actix_web::web::Data".to_string(), + ]; + cfg.call_parity = Some(cp); + let c = compile_architecture(&cfg).expect("compile"); + let wrappers = c.call_parity.unwrap().transparent_wrappers; + assert!(wrappers.contains("State"), "wrappers = {wrappers:?}"); + assert!(wrappers.contains("Extension"), "wrappers = {wrappers:?}"); + assert!(wrappers.contains("Json"), "wrappers = {wrappers:?}"); + assert!(wrappers.contains("Data"), "wrappers = {wrappers:?}"); + // No leftover entries with `<` or whitespace. + for w in &wrappers { + assert!( + !w.contains('<') && w == w.trim(), + "wrapper key not normalised: {w:?}" + ); + } +} + // ── LayerDefinitions::layer_of_crate_path (Task 2) ────────────── fn layers_for_crate_path() -> LayerDefinitions { diff --git a/src/adapters/config/architecture.rs b/src/adapters/config/architecture.rs index ccbe9ff..5a4f33a 100644 --- a/src/adapters/config/architecture.rs +++ b/src/adapters/config/architecture.rs @@ -274,10 +274,16 @@ pub struct CallParityConfig { /// Stage 3 — user-defined transparent wrapper types. These are /// peeled during receiver-type resolution just like `Arc`, `Box`, /// `Rc`, `Cow`. Typical candidates are framework extractor types: - /// Axum's `State` / `Extension` / `Json`, Actix's - /// `Data`, tower's `Router`. Without an entry here, + /// Axum's `State` / `Extension` / `Json`, Actix's `Data`, tower's + /// `Router`. Without an entry here, /// `fn h(State(db): State) { db.query() }` leaves `db` /// unresolved. + /// + /// Entries are matched on the bare type ident — both path prefixes + /// (`axum::extract::State`) and generic suffixes (`State`) are + /// stripped during compile, so `"State"`, `"axum::extract::State"`, + /// and `"State"` all key on `State` at lookup time. Prefer the + /// bare-ident form for clarity. #[serde(default)] pub transparent_wrappers: Vec, From ab114002e3d39891d61343fd99d73b0e2853a823 Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:43:29 +0200 Subject: [PATCH 20/30] fix: copilot comments --- .../call_parity_rule/local_symbols.rs | 16 +- .../architecture/call_parity_rule/pub_fns.rs | 236 +++++++++++------- .../call_parity_rule/tests/pub_fns.rs | 100 ++++++-- .../analyzers/architecture/compiled.rs | 16 +- .../analyzers/architecture/tests/compiled.rs | 4 + 5 files changed, 250 insertions(+), 122 deletions(-) diff --git a/src/adapters/analyzers/architecture/call_parity_rule/local_symbols.rs b/src/adapters/analyzers/architecture/call_parity_rule/local_symbols.rs index d256c1a..0ab30e3 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/local_symbols.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/local_symbols.rs @@ -95,11 +95,21 @@ pub(crate) fn build_workspace_files_map<'a>( } // qual:api -/// Flat top-level + nested name set. Backward-compatible shape for -/// callers that don't track mod scope. Operation: project flat view. +/// Top-level-only name set for callers that don't track mod scope. +/// Names declared exclusively inside nested inline `mod`s are +/// reachable through `collect_local_symbols_scoped` only — exposing +/// them flat would let the legacy resolution path (which falls back +/// to "treat any hit as top-level" when `local_decl_scopes` is empty) +/// produce bogus `crate::::Inner` paths for inner-module-only +/// names. Operation: project the names with at least one top-level +/// declaration scope. pub(crate) fn collect_local_symbols(ast: &syn::File) -> HashSet { let scoped = collect_local_symbols_scoped(ast); - scoped.flat + scoped + .by_name + .into_iter() + .filter_map(|(name, scopes)| scopes.iter().any(|p| p.is_empty()).then_some(name)) + .collect() } // qual:api diff --git a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs index 02148c0..4154b9d 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs @@ -17,12 +17,11 @@ //! //! See Task 2 in the v1.1.0 plan for the full test list. -use super::bindings::CanonScope; +use super::bindings::{canonicalise_type_segments_in_scope, CanonScope}; use super::local_symbols::{collect_local_symbols_scoped, FileScope, LocalSymbols}; use super::signature_params::extract_signature_params; -use super::workspace_graph::{ - collect_crate_root_modules, impl_self_ty_segments, resolve_impl_self_type, -}; +use super::workspace_graph::{collect_crate_root_modules, resolve_impl_self_type}; +use crate::adapters::analyzers::architecture::forbidden_rule::file_to_module_segments; use crate::adapters::analyzers::architecture::layer_rule::LayerDefinitions; use crate::adapters::shared::cfg_test::{has_cfg_test, has_test_attr}; use crate::adapters::shared::use_tree::gather_alias_map_scoped; @@ -61,8 +60,13 @@ pub(crate) fn collect_pub_fns_by_layer<'ast>( layers: &LayerDefinitions, cfg_test_files: &HashSet, ) -> HashMap>> { - let visible_types = collect_visible_type_names_workspace(files, cfg_test_files); let crate_root_modules = collect_crate_root_modules(files); + let visible_canonicals = collect_visible_type_canonicals_workspace( + files, + cfg_test_files, + aliases_per_file, + &crate_root_modules, + ); let empty_aliases = HashMap::new(); let mut out: HashMap>> = HashMap::new(); for (path, ast) in files { @@ -94,7 +98,7 @@ pub(crate) fn collect_pub_fns_by_layer<'ast>( file_path: path.to_string(), file: &file, found: Vec::new(), - visible_types: &visible_types, + visible_canonicals: &visible_canonicals, impl_stack: Vec::new(), mod_stack: Vec::new(), enclosing_mod_visible: true, @@ -105,75 +109,89 @@ pub(crate) fn collect_pub_fns_by_layer<'ast>( out } -/// Collect every visible (non-inherited-visibility) type name across -/// the whole non-test workspace. Recurses into non-test inline `mod` -/// blocks so types declared inside `pub mod inner { pub struct S; }` -/// are recognised — without that, impls on `S` would be dropped as -/// "private" by Check B's visibility filter. +/// Collect every publicly named type's canonical path across the +/// whole non-test workspace. The set members are +/// `crate::::::` joined by `::`. +/// Compared canonically against the impl self-type's resolved path, +/// so two distinct types sharing a short ident (`api::Session` vs +/// `internal::Session`) don't collide, and `mod private { pub struct +/// Hidden; } pub use private::Hidden;` correctly registers the +/// source-canonical of `Hidden` via the re-export. /// -/// Impls on the same type name get counted as visible regardless of -/// which file the impl lives in — so `pub struct Session` in -/// `src/app/session.rs` and its `impl Session` in a companion file -/// both contribute to the check. -/// -/// The matching is string-equality on the last segment of the impl's -/// self-type path. Two distinct types with the same name in different -/// files / mods both match; that's MVP-level imprecision — false -/// positives (over-counting) rather than false negatives. +/// Impls on the same canonical path get counted as visible regardless +/// of which file the impl lives in — so `pub struct Session` in one +/// file and `impl crate::app::Session` in a companion file both +/// resolve to the same canonical and register together. /// Integration: per-file delegate to recursive collector. -fn collect_visible_type_names_workspace( +fn collect_visible_type_canonicals_workspace( files: &[(&str, &syn::File)], cfg_test_files: &HashSet, + aliases_per_file: &HashMap>>, + crate_root_modules: &HashSet, ) -> HashSet { let mut out = HashSet::new(); + let empty_aliases = HashMap::new(); for (path, ast) in files { if cfg_test_files.contains(*path) { continue; } - collect_visible_type_names_in_items(&ast.items, &mut out); + let alias_map = aliases_per_file.get(*path).unwrap_or(&empty_aliases); + let LocalSymbols { flat, by_name } = collect_local_symbols_scoped(ast); + let aliases_per_scope = gather_alias_map_scoped(ast); + let file_scope = FileScope { + path, + alias_map, + aliases_per_scope: &aliases_per_scope, + local_symbols: &flat, + local_decl_scopes: &by_name, + crate_root_modules, + }; + collect_visible_type_canonicals_in_items(&ast.items, &[], &file_scope, &mut out); } out } -/// Walk a slice of items, inserting visible type-name idents and -/// recursing into non-cfg-test inline mods. Recursion is gated on the -/// inline mod's own visibility — `mod private { pub struct X; }` -/// keeps `X` out of the workspace-visible set so its impl methods -/// don't leak into Check A/B as adapter surface. `pub use` items -/// register their leaf names so re-exported types -/// (`pub use private::Hidden;`) still count as workspace-visible -/// surface even when the source module is private. Glob re-exports +/// Walk a slice of items, inserting publicly named types' canonical +/// paths and recursing into non-cfg-test, visible inline mods. `pub +/// use` items resolve their leaves through the workspace alias / +/// local-symbol pipeline so re-exported source-canonicals enter the +/// set even when the source module itself is private. Glob re-exports /// (`pub use foo::*`) are intentionally skipped — without expanding /// the source module we can't statically tell which idents leak. /// Operation: closure-hidden recursion through nested `mod` blocks. // qual:recursive -fn collect_visible_type_names_in_items(items: &[syn::Item], out: &mut HashSet) { - let recurse = |inner: &[syn::Item], out: &mut HashSet| { - collect_visible_type_names_in_items(inner, out); +fn collect_visible_type_canonicals_in_items( + items: &[syn::Item], + mod_stack: &[String], + file_scope: &FileScope<'_>, + out: &mut HashSet, +) { + let recurse = |inner: &[syn::Item], next: &[String], out: &mut HashSet| { + collect_visible_type_canonicals_in_items(inner, next, file_scope, out); + }; + let add_decl = |ident: &syn::Ident, out: &mut HashSet| { + out.insert(canonical_for_decl( + file_scope.path, + mod_stack, + &ident.to_string(), + )); + }; + let collect_use = |tree: &syn::UseTree, out: &mut HashSet| { + walk_use_tree_canonicals(tree, &mut Vec::new(), file_scope, mod_stack, out); }; for item in items { match item { - syn::Item::Struct(s) if is_visible(&s.vis) => { - out.insert(s.ident.to_string()); - } - syn::Item::Enum(e) if is_visible(&e.vis) => { - out.insert(e.ident.to_string()); - } - syn::Item::Union(u) if is_visible(&u.vis) => { - out.insert(u.ident.to_string()); - } - syn::Item::Trait(t) if is_visible(&t.vis) => { - out.insert(t.ident.to_string()); - } - syn::Item::Type(t) if is_visible(&t.vis) => { - out.insert(t.ident.to_string()); - } - syn::Item::Use(u) if is_visible(&u.vis) => { - collect_use_tree_names(&u.tree, out); - } + syn::Item::Struct(s) if is_visible(&s.vis) => add_decl(&s.ident, out), + syn::Item::Enum(e) if is_visible(&e.vis) => add_decl(&e.ident, out), + syn::Item::Union(u) if is_visible(&u.vis) => add_decl(&u.ident, out), + syn::Item::Trait(t) if is_visible(&t.vis) => add_decl(&t.ident, out), + syn::Item::Type(t) if is_visible(&t.vis) => add_decl(&t.ident, out), + syn::Item::Use(u) if is_visible(&u.vis) => collect_use(&u.tree, out), syn::Item::Mod(m) if is_visible(&m.vis) && !has_cfg_test(&m.attrs) => { if let Some((_, inner)) = m.content.as_ref() { - recurse(inner, out); + let mut next = mod_stack.to_vec(); + next.push(m.ident.to_string()); + recurse(inner, &next, out); } } _ => {} @@ -181,23 +199,65 @@ fn collect_visible_type_names_in_items(items: &[syn::Item], out: &mut HashSet::::` joined as a +/// single string — the canonical key both `visible_canonicals` and +/// `resolve_impl_self_type` agree on. Operation: pure string assembly. +fn canonical_for_decl(file_path: &str, mod_stack: &[String], ident: &str) -> String { + let mut segs = vec!["crate".to_string()]; + segs.extend(file_to_module_segments(file_path)); + segs.extend(mod_stack.iter().cloned()); + segs.push(ident.to_string()); + segs.join("::") +} + +/// Recursive walk over a `pub use` tree, accumulating the path +/// segments as we descend so each leaf carries the full source path. +/// Each leaf is canonicalised through the workspace alias / local- +/// symbol pipeline; the result enters `visible_canonicals` so the +/// source type's canonical is recognised even if its module is +/// private. The leaf's *source* ident is what gets resolved — the +/// rename only affects how callers name the type, while impl methods +/// still record under the source-canonical. Operation: closure-hidden +/// descent into nested `Group`s and `Path`s. // qual:recursive -fn collect_use_tree_names(tree: &syn::UseTree, out: &mut HashSet) { +fn walk_use_tree_canonicals( + tree: &syn::UseTree, + prefix: &mut Vec, + file_scope: &FileScope<'_>, + mod_stack: &[String], + out: &mut HashSet, +) { + let recurse = |sub: &syn::UseTree, prefix: &mut Vec, out: &mut HashSet| { + walk_use_tree_canonicals(sub, prefix, file_scope, mod_stack, out); + }; + let resolve = |segs: &[String], out: &mut HashSet| { + let scope = CanonScope { + file: file_scope, + mod_stack, + }; + if let Some(canonical) = canonicalise_type_segments_in_scope(segs, &scope) { + out.insert(canonical.join("::")); + } + }; match tree { - syn::UseTree::Path(p) => collect_use_tree_names(&p.tree, out), + syn::UseTree::Path(p) => { + prefix.push(p.ident.to_string()); + recurse(&p.tree, prefix, out); + prefix.pop(); + } syn::UseTree::Name(n) => { - out.insert(n.ident.to_string()); + prefix.push(n.ident.to_string()); + resolve(prefix, out); + prefix.pop(); } syn::UseTree::Rename(r) => { - out.insert(r.rename.to_string()); + prefix.push(r.ident.to_string()); + resolve(prefix, out); + prefix.pop(); } syn::UseTree::Group(g) => { for sub in &g.items { - collect_use_tree_names(sub, out); + recurse(sub, prefix, out); } } syn::UseTree::Glob(_) => {} @@ -214,9 +274,11 @@ struct PubFnCollector<'ast, 'vis> { file_path: String, file: &'vis FileScope<'vis>, found: Vec>, - /// Workspace-wide set of type names whose declaration carries a - /// visibility modifier. Shared across files. - visible_types: &'vis HashSet, + /// Workspace-wide set of canonical paths of publicly named types. + /// `crate::::::` joined as one + /// string, comparable directly against `resolve_impl_self_type`'s + /// output. Shared across files. + visible_canonicals: &'vis HashSet, /// Stack of enclosing `impl` blocks: `(self-type segments, is-visible)`. impl_stack: Vec<(Vec, bool)>, /// Names of enclosing inline `mod inner { ... }` blocks. @@ -294,25 +356,16 @@ impl<'ast, 'vis> Visit<'ast> for PubFnCollector<'ast, 'vis> { } fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) { - // Visibility is still name-based (last segment of the raw impl - // header), but the self-type we store runs through the same - // canonicalisation pipeline as receiver-tracked method calls - // — otherwise `use crate::app::Session; impl Session { ... }` - // would produce `crate::::Session::method` for - // Check B while the call collector resolves caller sites to - // `crate::app::Session::method`, and every method on an - // imported type would be silently "unreached". - let raw_segs = impl_self_ty_segments(&node.self_ty); - let visible = raw_segs - .as_ref() - .and_then(|segs| segs.last()) - .is_some_and(|name| self.visible_types.contains(name)); - // `visible` already gates on the raw-segments path (last segment - // in the workspace-wide visible-types set), so unresolved - // self-types (trait objects, references) bring `visible=false` - // with them and the method is skipped regardless. Coalescing - // `None` to an empty segment list here is safe because it's - // never read under that flag. + // Resolve the impl's self-type through the same canonicalisation + // pipeline used by receiver-tracked method calls, then probe + // the workspace `visible_canonicals` set with the joined path. + // Canonical comparison handles short-name collisions + // (`api::Session` vs `internal::Session`), private-mod impls + // for top-level pub types (`mod methods { impl super::Session + // … }`), and re-exports (`pub use private::Hidden`) uniformly. + // Unresolved self-types (trait objects, references) bring an + // empty segment list with `visible=false` and the methods + // are skipped regardless. let canonical_segs = resolve_impl_self_type( &node.self_ty, &CanonScope { @@ -321,22 +374,19 @@ impl<'ast, 'vis> Visit<'ast> for PubFnCollector<'ast, 'vis> { }, ) .unwrap_or_default(); + let visible = !canonical_segs.is_empty() + && self.visible_canonicals.contains(&canonical_segs.join("::")); self.impl_stack.push((canonical_segs, visible)); syn::visit::visit_item_impl(self, node); self.impl_stack.pop(); } fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) { - // Gate on enclosing-mod visibility too: `visible_types` keys on - // bare type idents, so a name collision with a public sibling - // would otherwise leak `mod private { impl Session { … } }`'s - // methods into the surface. The mod-stack flag fixes that - // without restructuring `visible_types` to canonical paths. - let visible = self.enclosing_mod_visible - && self.current_impl_visible() - && is_visible(&node.vis) - && !is_test_fn(&node.attrs); - if visible { + // No enclosing-mod-visible gate here: `visible_canonicals` + // already encodes whether the type is reachable, so impls in + // private modules for publicly named types record correctly + // and impls on private types are filtered uniformly. + if self.current_impl_visible() && is_visible(&node.vis) && !is_test_fn(&node.attrs) { let line = syn::spanned::Spanned::span(&node.sig.ident).start().line; let name = node.sig.ident.to_string(); self.record_fn(name, line, &node.block, &node.sig); diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs index 63217ae..580f851 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs @@ -155,12 +155,14 @@ fn test_collect_pub_fns_collects_pub_impl_methods_for_pub_type() { #[test] fn test_collect_pub_fns_recognises_impl_across_files() { // Regression: `pub struct Session` in one file, `impl Session { pub - // fn search() }` in another — the workspace-wide visible-type set - // must let the impl methods through, otherwise Check B misses - // legitimate target-layer API. + // fn search() }` in another — both sides resolve to the same + // canonical via the impl-file's `use` statement, so Check B sees + // the impl methods as adapter surface. (Without the `use`, the + // impl wouldn't compile in real Rust either.) let decl_file = parse("pub struct Session;"); let impl_file = parse( r#" + use crate::application::session::Session; impl Session { pub fn search(&self) {} } @@ -369,19 +371,77 @@ fn test_collect_pub_fns_skips_impl_method_on_type_in_private_inline_mod() { ); } +#[test] +fn test_collect_pub_fns_records_impl_in_private_mod_for_public_type() { + // `pub struct Session` at file level, but its `impl` block lives + // inside a private inline mod via `super::Session`. Rust treats + // `s.diff()` as callable from any caller that can name `Session`, + // so the public type's pub inherent methods must be recorded as + // adapter surface — even though the impl block itself sits in a + // private mod. + let file = parse( + r#" + pub struct Session; + mod methods { + impl super::Session { + pub fn diff(&self) {} + } + } + "#, + ); + let files = vec![("src/application/session.rs", &file)]; + let by_layer = { + let aliases = aliases_from_files(&files); + collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + }; + let app = names_for_layer(&by_layer, "application"); + assert!( + app.contains("diff"), + "impl in private mod for public type must be recorded, got {app:?}" + ); +} + +#[test] +fn test_collect_pub_fns_records_renamed_reexport_impl_methods() { + // `pub use private::Hidden as PublicHidden;` re-exports the + // source type under a new name. The impl uses the original + // `Hidden`, so visibility must resolve through the re-export + // path — short-name matching against `PublicHidden` would miss + // the impl. Recording must work via the source-canonical path + // that both sides agree on. + let file = parse( + r#" + mod private { + pub struct Hidden; + } + impl private::Hidden { + pub fn op(&self) {} + } + pub use private::Hidden as PublicHidden; + "#, + ); + let files = vec![("src/cli/handlers.rs", &file)]; + let by_layer = { + let aliases = aliases_from_files(&files); + collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + }; + let cli = names_for_layer(&by_layer, "cli"); + assert!( + cli.contains("op"), + "renamed re-export must still expose impl method, got {cli:?}" + ); +} + #[test] fn test_collect_pub_fns_records_pub_use_reexport_with_qualified_impl() { // `pub use private::Hidden;` with the impl at file level - // (qualified `impl private::Hidden { … }`) — the re-export adds - // `Hidden` to the workspace-visible-types set, and the impl - // itself is OUTSIDE the private mod, so its methods get recorded. - // - // Limit (not covered): `impl Hidden { … }` *inside* `mod - // private { … }` stays out — the enclosing-mod visibility gate - // (which prevents short-name collisions with public siblings) - // takes precedence. Document at call-site rather than special- - // case the resolver. Workaround: lift the impl out of the - // private mod or make the mod itself `pub`. + // (qualified `impl private::Hidden { … }`) — the re-export + // resolves to the source-canonical `crate::file::private::Hidden` + // and registers in `visible_canonicals`. The impl resolves to + // the same canonical, so the methods record. With canonical-path + // matching, impls *inside* `mod private` for the same re-exported + // type also record correctly (the mod's own visibility no longer + // gates impl methods). let file = parse( r#" mod private { @@ -407,13 +467,13 @@ fn test_collect_pub_fns_records_pub_use_reexport_with_qualified_impl() { #[test] fn test_collect_pub_fns_skips_impl_methods_under_short_name_collision() { - // The visible-types set is keyed by short ident, so two distinct - // types named `Session` (one public, one in a private inline mod) - // collide. Without enclosing-mod gating on impl methods, the - // private one's impl method would still register because the - // short name "Session" lives in visible_types via the public - // sibling. Gate on enclosing-mod visibility to keep the private - // method out. + // Two distinct types named `Session` (one public, one in a + // private inline mod) must NOT collide. The canonical-path-based + // `visible_canonicals` set keys on full paths, so + // `crate::cli::handlers::api::Session` and + // `crate::cli::handlers::internal::Session` are distinct entries + // — only the former is recorded (private mod's recursion is + // skipped during the collection pass). let file = parse( r#" pub mod api { diff --git a/src/adapters/analyzers/architecture/compiled.rs b/src/adapters/analyzers/architecture/compiled.rs index d274902..a9afe15 100644 --- a/src/adapters/analyzers/architecture/compiled.rs +++ b/src/adapters/analyzers/architecture/compiled.rs @@ -139,13 +139,17 @@ fn compile_call_parity( })) } -/// Return the last `::`-separated segment of a path-like wrapper entry -/// with any generic-arg suffix stripped, so users can write -/// `axum::extract::State`, plain `State`, or even `State` and all -/// match resolver lookups keyed on the type's bare ident. Operation. +/// Return the bare ident from a path-like wrapper entry: strip any +/// generic-arg suffix first (so a path-qualified arg like +/// `axum::extract::State` doesn't break the +/// segment split on the inner `::`), then take the last +/// `::`-separated segment. Operation. fn last_path_segment(path: &str) -> &str { - let after_path = path.rsplit("::").next().unwrap_or(path); - after_path.split('<').next().unwrap_or(after_path) + let without_generics = path.split('<').next().unwrap_or(path); + without_generics + .rsplit("::") + .next() + .unwrap_or(without_generics) } /// Stage 3 starter-pack: prepend these common framework attribute-macro diff --git a/src/adapters/analyzers/architecture/tests/compiled.rs b/src/adapters/analyzers/architecture/tests/compiled.rs index 064afb8..db29f76 100644 --- a/src/adapters/analyzers/architecture/tests/compiled.rs +++ b/src/adapters/analyzers/architecture/tests/compiled.rs @@ -258,6 +258,10 @@ fn compile_call_parity_normalises_transparent_wrappers() { "axum::extract::Extension".to_string(), "Json".to_string(), "actix_web::web::Data".to_string(), + // Path-qualified generic arg: the `::` lives inside the + // generic, so naive last-`::`-split picks `Db>` instead of + // `State`. Must strip `<…>` before splitting. + "axum::extract::State".to_string(), ]; cfg.call_parity = Some(cp); let c = compile_architecture(&cfg).expect("compile"); From bf4efde02f11345aad148f8f947399a434e488dd Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sun, 26 Apr 2026 00:08:06 +0200 Subject: [PATCH 21/30] fix: copilot comments --- CHANGELOG.md | 10 + README.md | 1 + .../architecture/call_parity_rule/mod.rs | 1 + .../architecture/call_parity_rule/pub_fns.rs | 180 +------------- .../call_parity_rule/pub_fns_visibility.rs | 225 ++++++++++++++++++ .../call_parity_rule/tests/pub_fns.rs | 62 +++++ .../analyzers/architecture/compiled.rs | 7 +- .../analyzers/architecture/tests/compiled.rs | 2 + 8 files changed, 308 insertions(+), 180 deletions(-) create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dd9c5c..a78c0de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -189,6 +189,16 @@ fallback markers rather than fabricate edges: traits (`Send` / `Sync` / `Unpin` / `Copy` / `Clone` / `Sized` / `Debug` / `Display`) are filtered first, so the common `impl Future + Send` shape is unaffected. +- `pub mod outer { … pub use self::private::Hidden; }` followed by + `fn h(x: outer::Hidden) { x.op() }` — the receiver-type resolver + doesn't follow workspace-wide `pub use` re-exports inside nested + modules, so the parameter resolves to `crate::…::outer::Hidden` + while methods on the impl (inside `mod private`) are indexed under + `crate::…::outer::private::Hidden`. Visibility recognises both + paths, but the call-graph edge collapses to `:op`. + Workaround: write the impl at the file-level qualified path + (`impl outer::Hidden { … }`) so impl-canonical and caller-canonical + agree, or `qual:allow(architecture)` at the call-site. - Arbitrary proc-macros that alter the call graph without being in `transparent_macros` config. User-annotate via `// qual:allow(architecture)` on the enclosing fn. diff --git a/README.md b/README.md index 0495047..0b543f8 100644 --- a/README.md +++ b/README.md @@ -925,6 +925,7 @@ Known limits (documented, with clear workarounds): — use turbofish `get::()` or `let x: T = get();`. - **`impl Trait` inherent methods** — `fn make() -> impl Handler; make().trait_method()` resolves to every workspace impl of `Handler::trait_method` via over-approximation, but an inherent method not declared on `Handler` can't be reached (the concrete type is hidden by design). - **Multi-bound `impl Trait` / `dyn Trait` returns** — `fn make() -> impl Future + Handler` keeps only the first non-marker bound, so `.await` propagation *or* trait-dispatch fires, never both. Marker traits (`Send`/`Sync`/`Unpin`/`Copy`/`Clone`/`Sized`/`Debug`/`Display`) are filtered first, so `impl Future + Send` is unaffected. Workaround: split the return into two methods, or `qual:allow(architecture)` on the call-site. +- **Caller-side `pub use` path-following.** `pub mod outer { mod private { pub struct Hidden; impl Hidden { pub fn op() } } pub use self::private::Hidden; }` with a caller `fn h(x: outer::Hidden) { x.op() }` resolves the parameter to `crate::…::outer::Hidden` while the impl is keyed under `crate::…::outer::private::Hidden`. Visibility is recognised on both paths, but the call-graph edge goes to `:op` because the resolver doesn't follow workspace-wide `pub use` re-exports inside nested modules. Workaround: write `impl outer::Hidden { … }` at the file-level qualified path so impl-canonical and caller-canonical agree, or `qual:allow(architecture)` at the call-site. - **Arbitrary proc-macros** not listed in `transparent_macros` — `// qual:allow(architecture)` on the enclosing fn is the escape. diff --git a/src/adapters/analyzers/architecture/call_parity_rule/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/mod.rs index d787c36..eccb78f 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/mod.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/mod.rs @@ -19,6 +19,7 @@ pub mod check_a; pub mod check_b; pub(crate) mod local_symbols; pub mod pub_fns; +mod pub_fns_visibility; pub(crate) mod signature_params; pub mod type_infer; pub mod workspace_graph; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs index 4154b9d..2f12b54 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs @@ -17,18 +17,17 @@ //! //! See Task 2 in the v1.1.0 plan for the full test list. -use super::bindings::{canonicalise_type_segments_in_scope, CanonScope}; +use super::bindings::CanonScope; use super::local_symbols::{collect_local_symbols_scoped, FileScope, LocalSymbols}; +use super::pub_fns_visibility::{collect_visible_type_canonicals_workspace, is_visible}; use super::signature_params::extract_signature_params; use super::workspace_graph::{collect_crate_root_modules, resolve_impl_self_type}; -use crate::adapters::analyzers::architecture::forbidden_rule::file_to_module_segments; use crate::adapters::analyzers::architecture::layer_rule::LayerDefinitions; use crate::adapters::shared::cfg_test::{has_cfg_test, has_test_attr}; use crate::adapters::shared::use_tree::gather_alias_map_scoped; use crate::adapters::shared::use_tree::ScopedAliasMap; use std::collections::{HashMap, HashSet}; use syn::visit::Visit; -use syn::Visibility; /// Shape used by both Check A and Check B — we need the fn name to /// build the canonical-call-target string, the body to walk, and the @@ -109,161 +108,6 @@ pub(crate) fn collect_pub_fns_by_layer<'ast>( out } -/// Collect every publicly named type's canonical path across the -/// whole non-test workspace. The set members are -/// `crate::::::` joined by `::`. -/// Compared canonically against the impl self-type's resolved path, -/// so two distinct types sharing a short ident (`api::Session` vs -/// `internal::Session`) don't collide, and `mod private { pub struct -/// Hidden; } pub use private::Hidden;` correctly registers the -/// source-canonical of `Hidden` via the re-export. -/// -/// Impls on the same canonical path get counted as visible regardless -/// of which file the impl lives in — so `pub struct Session` in one -/// file and `impl crate::app::Session` in a companion file both -/// resolve to the same canonical and register together. -/// Integration: per-file delegate to recursive collector. -fn collect_visible_type_canonicals_workspace( - files: &[(&str, &syn::File)], - cfg_test_files: &HashSet, - aliases_per_file: &HashMap>>, - crate_root_modules: &HashSet, -) -> HashSet { - let mut out = HashSet::new(); - let empty_aliases = HashMap::new(); - for (path, ast) in files { - if cfg_test_files.contains(*path) { - continue; - } - let alias_map = aliases_per_file.get(*path).unwrap_or(&empty_aliases); - let LocalSymbols { flat, by_name } = collect_local_symbols_scoped(ast); - let aliases_per_scope = gather_alias_map_scoped(ast); - let file_scope = FileScope { - path, - alias_map, - aliases_per_scope: &aliases_per_scope, - local_symbols: &flat, - local_decl_scopes: &by_name, - crate_root_modules, - }; - collect_visible_type_canonicals_in_items(&ast.items, &[], &file_scope, &mut out); - } - out -} - -/// Walk a slice of items, inserting publicly named types' canonical -/// paths and recursing into non-cfg-test, visible inline mods. `pub -/// use` items resolve their leaves through the workspace alias / -/// local-symbol pipeline so re-exported source-canonicals enter the -/// set even when the source module itself is private. Glob re-exports -/// (`pub use foo::*`) are intentionally skipped — without expanding -/// the source module we can't statically tell which idents leak. -/// Operation: closure-hidden recursion through nested `mod` blocks. -// qual:recursive -fn collect_visible_type_canonicals_in_items( - items: &[syn::Item], - mod_stack: &[String], - file_scope: &FileScope<'_>, - out: &mut HashSet, -) { - let recurse = |inner: &[syn::Item], next: &[String], out: &mut HashSet| { - collect_visible_type_canonicals_in_items(inner, next, file_scope, out); - }; - let add_decl = |ident: &syn::Ident, out: &mut HashSet| { - out.insert(canonical_for_decl( - file_scope.path, - mod_stack, - &ident.to_string(), - )); - }; - let collect_use = |tree: &syn::UseTree, out: &mut HashSet| { - walk_use_tree_canonicals(tree, &mut Vec::new(), file_scope, mod_stack, out); - }; - for item in items { - match item { - syn::Item::Struct(s) if is_visible(&s.vis) => add_decl(&s.ident, out), - syn::Item::Enum(e) if is_visible(&e.vis) => add_decl(&e.ident, out), - syn::Item::Union(u) if is_visible(&u.vis) => add_decl(&u.ident, out), - syn::Item::Trait(t) if is_visible(&t.vis) => add_decl(&t.ident, out), - syn::Item::Type(t) if is_visible(&t.vis) => add_decl(&t.ident, out), - syn::Item::Use(u) if is_visible(&u.vis) => collect_use(&u.tree, out), - syn::Item::Mod(m) if is_visible(&m.vis) && !has_cfg_test(&m.attrs) => { - if let Some((_, inner)) = m.content.as_ref() { - let mut next = mod_stack.to_vec(); - next.push(m.ident.to_string()); - recurse(inner, &next, out); - } - } - _ => {} - } - } -} - -/// Build `crate::::::` joined as a -/// single string — the canonical key both `visible_canonicals` and -/// `resolve_impl_self_type` agree on. Operation: pure string assembly. -fn canonical_for_decl(file_path: &str, mod_stack: &[String], ident: &str) -> String { - let mut segs = vec!["crate".to_string()]; - segs.extend(file_to_module_segments(file_path)); - segs.extend(mod_stack.iter().cloned()); - segs.push(ident.to_string()); - segs.join("::") -} - -/// Recursive walk over a `pub use` tree, accumulating the path -/// segments as we descend so each leaf carries the full source path. -/// Each leaf is canonicalised through the workspace alias / local- -/// symbol pipeline; the result enters `visible_canonicals` so the -/// source type's canonical is recognised even if its module is -/// private. The leaf's *source* ident is what gets resolved — the -/// rename only affects how callers name the type, while impl methods -/// still record under the source-canonical. Operation: closure-hidden -/// descent into nested `Group`s and `Path`s. -// qual:recursive -fn walk_use_tree_canonicals( - tree: &syn::UseTree, - prefix: &mut Vec, - file_scope: &FileScope<'_>, - mod_stack: &[String], - out: &mut HashSet, -) { - let recurse = |sub: &syn::UseTree, prefix: &mut Vec, out: &mut HashSet| { - walk_use_tree_canonicals(sub, prefix, file_scope, mod_stack, out); - }; - let resolve = |segs: &[String], out: &mut HashSet| { - let scope = CanonScope { - file: file_scope, - mod_stack, - }; - if let Some(canonical) = canonicalise_type_segments_in_scope(segs, &scope) { - out.insert(canonical.join("::")); - } - }; - match tree { - syn::UseTree::Path(p) => { - prefix.push(p.ident.to_string()); - recurse(&p.tree, prefix, out); - prefix.pop(); - } - syn::UseTree::Name(n) => { - prefix.push(n.ident.to_string()); - resolve(prefix, out); - prefix.pop(); - } - syn::UseTree::Rename(r) => { - prefix.push(r.ident.to_string()); - resolve(prefix, out); - prefix.pop(); - } - syn::UseTree::Group(g) => { - for sub in &g.items { - recurse(sub, prefix, out); - } - } - syn::UseTree::Glob(_) => {} - } -} - /// Workspace-walker — visits items, tracks impl-type visibility /// for nested impl methods, collects pub fn metadata. struct PubFnCollector<'ast, 'vis> { @@ -319,26 +163,6 @@ impl<'ast, 'vis> PubFnCollector<'ast, 'vis> { } } -/// Visibility modifier counts as "visible for the check" iff it's -/// `pub`, `pub(crate)`, `pub(super)`, or `pub(in )` for any -/// non-`self` path. `Inherited` and `pub(self)` / `pub(in self)` -/// (which Rust treats as equivalent to inherited visibility) both -/// stay out of scope. See D-5 for the rationale. -fn is_visible(vis: &Visibility) -> bool { - match vis { - Visibility::Inherited => false, - Visibility::Restricted(r) => !is_self_restricted(&r.path), - _ => true, - } -} - -/// True when a `pub(in path)` restriction targets `self` — i.e. -/// `pub(self)` or `pub(in self)`. Both compile to a single-segment -/// path of `self`. Operation. -fn is_self_restricted(path: &syn::Path) -> bool { - path.leading_colon.is_none() && path.segments.len() == 1 && path.segments[0].ident == "self" -} - /// True iff the `#[test]` / `#[cfg(test)]` attribute set would make /// this fn a test-harness item (excluded from the check). fn is_test_fn(attrs: &[syn::Attribute]) -> bool { diff --git a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs new file mode 100644 index 0000000..0009cfc --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs @@ -0,0 +1,225 @@ +//! Workspace-wide canonical-path collection for publicly named types. +//! +//! `pub_fns` consults this set to decide whether an `impl Type { … }` +//! exposes its methods to external callers. Members are +//! `crate::::::` strings — directly +//! comparable against `resolve_impl_self_type`'s output, so two +//! distinct types sharing a short ident don't collide and re-exports / +//! type-aliases bridge to their source canonicals. + +use super::bindings::{canonicalise_type_segments_in_scope, CanonScope}; +use super::local_symbols::{collect_local_symbols_scoped, FileScope, LocalSymbols}; +use crate::adapters::analyzers::architecture::forbidden_rule::file_to_module_segments; +use crate::adapters::shared::cfg_test::has_cfg_test; +use crate::adapters::shared::use_tree::gather_alias_map_scoped; +use std::collections::{HashMap, HashSet}; +use syn::Visibility; + +/// Visibility modifier counts as "visible for the check" iff it's +/// `pub`, `pub(crate)`, `pub(super)`, or `pub(in )` for any +/// non-`self` path. `Inherited` and `pub(self)` / `pub(in self)` +/// (which Rust treats as equivalent to inherited visibility) both +/// stay out of scope. +pub(super) fn is_visible(vis: &Visibility) -> bool { + match vis { + Visibility::Inherited => false, + Visibility::Restricted(r) => !is_self_restricted(&r.path), + _ => true, + } +} + +fn is_self_restricted(path: &syn::Path) -> bool { + path.leading_colon.is_none() && path.segments.len() == 1 && path.segments[0].ident == "self" +} + +// qual:api +/// Collect every publicly named type's canonical path across the +/// whole non-test workspace. Integration: per-file delegate to +/// recursive collector. +pub(super) fn collect_visible_type_canonicals_workspace( + files: &[(&str, &syn::File)], + cfg_test_files: &HashSet, + aliases_per_file: &HashMap>>, + crate_root_modules: &HashSet, +) -> HashSet { + let mut out = HashSet::new(); + let empty_aliases = HashMap::new(); + for (path, ast) in files { + if cfg_test_files.contains(*path) { + continue; + } + let alias_map = aliases_per_file.get(*path).unwrap_or(&empty_aliases); + let LocalSymbols { flat, by_name } = collect_local_symbols_scoped(ast); + let aliases_per_scope = gather_alias_map_scoped(ast); + let file_scope = FileScope { + path, + alias_map, + aliases_per_scope: &aliases_per_scope, + local_symbols: &flat, + local_decl_scopes: &by_name, + crate_root_modules, + }; + collect_in_items(&ast.items, &[], &file_scope, &mut out); + } + out +} + +/// Walk a slice of items, inserting publicly named types' canonical +/// paths and recursing into non-cfg-test, visible inline mods. `pub +/// use` items resolve their leaves through the workspace alias / +/// local-symbol pipeline so re-exported source-canonicals enter the +/// set even when the source module itself is private. Glob re-exports +/// (`pub use foo::*`) are intentionally skipped — without expanding +/// the source module we can't statically tell which idents leak. +/// Operation: closure-hidden recursion through nested `mod` blocks. +// qual:recursive +fn collect_in_items( + items: &[syn::Item], + mod_stack: &[String], + file_scope: &FileScope<'_>, + out: &mut HashSet, +) { + let recurse = |inner: &[syn::Item], next: &[String], out: &mut HashSet| { + collect_in_items(inner, next, file_scope, out); + }; + let add_decl = |ident: &syn::Ident, out: &mut HashSet| { + out.insert(canonical_for_decl( + file_scope.path, + mod_stack, + &ident.to_string(), + )); + }; + let collect_use = |tree: &syn::UseTree, out: &mut HashSet| { + walk_use_tree(tree, &mut Vec::new(), file_scope, mod_stack, out); + }; + let add_alias_target = |ty: &syn::Type, out: &mut HashSet| { + register_alias_target(ty, file_scope, mod_stack, out); + }; + for item in items { + match item { + syn::Item::Struct(s) if is_visible(&s.vis) => add_decl(&s.ident, out), + syn::Item::Enum(e) if is_visible(&e.vis) => add_decl(&e.ident, out), + syn::Item::Union(u) if is_visible(&u.vis) => add_decl(&u.ident, out), + syn::Item::Trait(t) if is_visible(&t.vis) => add_decl(&t.ident, out), + syn::Item::Type(t) if is_visible(&t.vis) => { + add_decl(&t.ident, out); + add_alias_target(&t.ty, out); + } + syn::Item::Use(u) if is_visible(&u.vis) => collect_use(&u.tree, out), + syn::Item::Mod(m) if is_visible(&m.vis) && !has_cfg_test(&m.attrs) => { + if let Some((_, inner)) = m.content.as_ref() { + let mut next = mod_stack.to_vec(); + next.push(m.ident.to_string()); + recurse(inner, &next, out); + } + } + _ => {} + } + } +} + +/// `pub type Public = private::Hidden;` — a public alias can expose +/// methods declared on a hidden source type. Resolve the alias's +/// target type-path through the workspace canonicaliser and add the +/// resolved canonical so impls keyed on the source type are +/// recognised when callers go through the alias. Non-path targets +/// (`pub type Repo = Arc;`) don't expose target methods +/// directly (the wrapper is what callers see), so they're skipped. +/// Operation. +fn register_alias_target( + ty: &syn::Type, + file_scope: &FileScope<'_>, + mod_stack: &[String], + out: &mut HashSet, +) { + let syn::Type::Path(p) = ty else { + return; + }; + let segs: Vec = p + .path + .segments + .iter() + .map(|s| s.ident.to_string()) + .collect(); + let scope = CanonScope { + file: file_scope, + mod_stack, + }; + if let Some(canonical) = canonicalise_type_segments_in_scope(&segs, &scope) { + out.insert(canonical.join("::")); + } +} + +/// Build `crate::::::` joined as a +/// single string — the canonical key both `visible_canonicals` and +/// `resolve_impl_self_type` agree on. Operation: pure string assembly. +pub(super) fn canonical_for_decl(file_path: &str, mod_stack: &[String], ident: &str) -> String { + let mut segs = vec!["crate".to_string()]; + segs.extend(file_to_module_segments(file_path)); + segs.extend(mod_stack.iter().cloned()); + segs.push(ident.to_string()); + segs.join("::") +} + +/// Recursive walk over a `pub use` tree. For each leaf, register +/// *two* canonicals in `out`: +/// - The source-canonical: the leaf's full source path resolved +/// through the workspace alias / local-symbol pipeline. Catches +/// impls written against the original declaration site. +/// - The export-canonical: the current scope plus the exported name +/// (rename target if present). Catches impls written against the +/// re-export path (`impl outer::Hidden` after `pub use +/// self::private::Hidden`). +/// +/// Operation: closure-hidden descent into nested `Group`s and +/// `Path`s. +// qual:recursive +fn walk_use_tree( + tree: &syn::UseTree, + prefix: &mut Vec, + file_scope: &FileScope<'_>, + mod_stack: &[String], + out: &mut HashSet, +) { + let recurse = |sub: &syn::UseTree, prefix: &mut Vec, out: &mut HashSet| { + walk_use_tree(sub, prefix, file_scope, mod_stack, out); + }; + let resolve_source = |segs: &[String], out: &mut HashSet| { + let scope = CanonScope { + file: file_scope, + mod_stack, + }; + if let Some(canonical) = canonicalise_type_segments_in_scope(segs, &scope) { + out.insert(canonical.join("::")); + } + }; + let add_export = |exported: &str, out: &mut HashSet| { + out.insert(canonical_for_decl(file_scope.path, mod_stack, exported)); + }; + match tree { + syn::UseTree::Path(p) => { + prefix.push(p.ident.to_string()); + recurse(&p.tree, prefix, out); + prefix.pop(); + } + syn::UseTree::Name(n) => { + let leaf = n.ident.to_string(); + prefix.push(leaf.clone()); + resolve_source(prefix, out); + prefix.pop(); + add_export(&leaf, out); + } + syn::UseTree::Rename(r) => { + prefix.push(r.ident.to_string()); + resolve_source(prefix, out); + prefix.pop(); + add_export(&r.rename.to_string(), out); + } + syn::UseTree::Group(g) => { + for sub in &g.items { + recurse(sub, prefix, out); + } + } + syn::UseTree::Glob(_) => {} + } +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs index 580f851..2b1c36f 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs @@ -401,6 +401,68 @@ fn test_collect_pub_fns_records_impl_in_private_mod_for_public_type() { ); } +#[test] +fn test_collect_pub_fns_records_impl_via_nested_pub_use_export_path() { + // `pub mod outer { pub use self::private::Hidden; }` re-exports + // `Hidden` at `crate::file::outer::Hidden`. An impl written + // against the export path must be recognised — visible_canonicals + // needs both the source path *and* the export path so impl + // resolution doesn't miss it. + let file = parse( + r#" + pub mod outer { + mod private { + pub struct Hidden; + } + pub use self::private::Hidden; + } + impl outer::Hidden { + pub fn op(&self) {} + } + "#, + ); + let files = vec![("src/cli/handlers.rs", &file)]; + let by_layer = { + let aliases = aliases_from_files(&files); + collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + }; + let cli = names_for_layer(&by_layer, "cli"); + assert!( + cli.contains("op"), + "impl on nested-mod re-export path must be recorded, got {cli:?}" + ); +} + +#[test] +fn test_collect_pub_fns_records_impl_via_pub_type_alias() { + // `pub type Public = private::Hidden;` exposes a hidden source + // type's methods through the alias. Receiver-type inference + // already resolves `Public` to its target, so the only piece + // missing for Check B was visibility — register the target's + // canonical alongside the alias path. + let file = parse( + r#" + mod private { + pub struct Hidden; + impl Hidden { + pub fn op(&self) {} + } + } + pub type Public = private::Hidden; + "#, + ); + let files = vec![("src/cli/handlers.rs", &file)]; + let by_layer = { + let aliases = aliases_from_files(&files); + collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + }; + let cli = names_for_layer(&by_layer, "cli"); + assert!( + cli.contains("op"), + "impl on pub-type-alias target must be recorded, got {cli:?}" + ); +} + #[test] fn test_collect_pub_fns_records_renamed_reexport_impl_methods() { // `pub use private::Hidden as PublicHidden;` re-exports the diff --git a/src/adapters/analyzers/architecture/compiled.rs b/src/adapters/analyzers/architecture/compiled.rs index a9afe15..4e41792 100644 --- a/src/adapters/analyzers/architecture/compiled.rs +++ b/src/adapters/analyzers/architecture/compiled.rs @@ -143,13 +143,16 @@ fn compile_call_parity( /// generic-arg suffix first (so a path-qualified arg like /// `axum::extract::State` doesn't break the /// segment split on the inner `::`), then take the last -/// `::`-separated segment. Operation. +/// `::`-separated segment, trimming whitespace at each step so +/// stylistic spaces (`"State "`) don't survive into the lookup +/// key. Operation. fn last_path_segment(path: &str) -> &str { - let without_generics = path.split('<').next().unwrap_or(path); + let without_generics = path.split('<').next().unwrap_or(path).trim(); without_generics .rsplit("::") .next() .unwrap_or(without_generics) + .trim() } /// Stage 3 starter-pack: prepend these common framework attribute-macro diff --git a/src/adapters/analyzers/architecture/tests/compiled.rs b/src/adapters/analyzers/architecture/tests/compiled.rs index db29f76..d875a46 100644 --- a/src/adapters/analyzers/architecture/tests/compiled.rs +++ b/src/adapters/analyzers/architecture/tests/compiled.rs @@ -262,6 +262,8 @@ fn compile_call_parity_normalises_transparent_wrappers() { // generic, so naive last-`::`-split picks `Db>` instead of // `State`. Must strip `<…>` before splitting. "axum::extract::State".to_string(), + // Whitespace before `<` survives the split; must trim again. + "Json ".to_string(), ]; cfg.call_parity = Some(cp); let c = compile_architecture(&cfg).expect("compile"); From ab3449d20d547a5a1a283ea94ee5e339fb0568da Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sun, 26 Apr 2026 00:21:30 +0200 Subject: [PATCH 22/30] fix: copilot comments --- CHANGELOG.md | 12 ++++ README.md | 2 + .../call_parity_rule/pub_fns_visibility.rs | 56 ++++++++++++++++--- .../call_parity_rule/tests/pub_fns.rs | 30 ++++++++++ 4 files changed, 91 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a78c0de..c69c38b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -199,6 +199,18 @@ fallback markers rather than fabricate edges: Workaround: write the impl at the file-level qualified path (`impl outer::Hidden { … }`) so impl-canonical and caller-canonical agree, or `qual:allow(architecture)` at the call-site. +- `mod private { pub type Public = Hidden; … } pub use private::Public;` + — re-exported type aliases declared inside private modules are + *not* followed into their target. The visibility pass skips private + modules wholesale, so `Public`'s target type never enters the + visible-canonicals set. Workaround: lift the type alias to the + parent module (`pub use private::Hidden; pub type Public = Hidden;`). +- `pub use internal::helper as Hidden;` where `helper` is a function — + the visibility pass treats every `pub use` leaf as a type export, + so a same-named private `struct Hidden` collides with the function + re-export and its impl methods get recorded as adapter surface. + Workaround: rename to avoid the collision, or + `qual:allow(architecture)` on the affected impl. - Arbitrary proc-macros that alter the call graph without being in `transparent_macros` config. User-annotate via `// qual:allow(architecture)` on the enclosing fn. diff --git a/README.md b/README.md index 0b543f8..302cdf6 100644 --- a/README.md +++ b/README.md @@ -926,6 +926,8 @@ Known limits (documented, with clear workarounds): - **`impl Trait` inherent methods** — `fn make() -> impl Handler; make().trait_method()` resolves to every workspace impl of `Handler::trait_method` via over-approximation, but an inherent method not declared on `Handler` can't be reached (the concrete type is hidden by design). - **Multi-bound `impl Trait` / `dyn Trait` returns** — `fn make() -> impl Future + Handler` keeps only the first non-marker bound, so `.await` propagation *or* trait-dispatch fires, never both. Marker traits (`Send`/`Sync`/`Unpin`/`Copy`/`Clone`/`Sized`/`Debug`/`Display`) are filtered first, so `impl Future + Send` is unaffected. Workaround: split the return into two methods, or `qual:allow(architecture)` on the call-site. - **Caller-side `pub use` path-following.** `pub mod outer { mod private { pub struct Hidden; impl Hidden { pub fn op() } } pub use self::private::Hidden; }` with a caller `fn h(x: outer::Hidden) { x.op() }` resolves the parameter to `crate::…::outer::Hidden` while the impl is keyed under `crate::…::outer::private::Hidden`. Visibility is recognised on both paths, but the call-graph edge goes to `:op` because the resolver doesn't follow workspace-wide `pub use` re-exports inside nested modules. Workaround: write `impl outer::Hidden { … }` at the file-level qualified path so impl-canonical and caller-canonical agree, or `qual:allow(architecture)` at the call-site. +- **Re-exported type aliases inside private modules.** `mod private { pub type Public = Hidden; … } pub use private::Public;` doesn't follow into the alias's target — private modules aren't walked by the visibility pass, so the alias's source type stays out of `visible_canonicals`. Workaround: lift the type alias to the parent module (`pub use private::Hidden; pub type Public = Hidden;`) so both the alias declaration and its target are processed. +- **Type-vs-value namespace ambiguity in `pub use`.** A `pub use internal::helper as Hidden;` re-export adds `Hidden` as a workspace-visible *type* canonical without checking whether the leaf is actually a type. If the same scope has a private `struct Hidden`, its impl methods get registered as adapter surface even though the `pub use` only exported a function. Workaround: rename to avoid the value/type collision, or `qual:allow(architecture)` on the affected impl. - **Arbitrary proc-macros** not listed in `transparent_macros` — `// qual:allow(architecture)` on the enclosing fn is the escape. diff --git a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs index 0009cfc..e3f8c1a 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs @@ -118,21 +118,22 @@ fn collect_in_items( } } -/// `pub type Public = private::Hidden;` — a public alias can expose -/// methods declared on a hidden source type. Resolve the alias's -/// target type-path through the workspace canonicaliser and add the -/// resolved canonical so impls keyed on the source type are -/// recognised when callers go through the alias. Non-path targets -/// (`pub type Repo = Arc;`) don't expose target methods -/// directly (the wrapper is what callers see), so they're skipped. -/// Operation. +/// `pub type Public = private::Hidden;` (or `Box`, +/// `Arc<…>`, etc.) — a public alias can expose methods declared on +/// a hidden source type. Peel any transparent stdlib wrapper +/// (`Box` / `Arc` / `Rc` / `Cow`), `&` references, and parens to +/// reach the inner type-path, then resolve through the workspace +/// canonicaliser and add the result. Mirrors the receiver-type +/// resolver, so impls keyed on the source type are recognised +/// regardless of whether the alias is a bare path or a wrapper- +/// wrapped one. Operation. fn register_alias_target( ty: &syn::Type, file_scope: &FileScope<'_>, mod_stack: &[String], out: &mut HashSet, ) { - let syn::Type::Path(p) = ty else { + let Some(p) = peel_to_inner_path(ty) else { return; }; let segs: Vec = p @@ -150,6 +151,43 @@ fn register_alias_target( } } +/// Recursively peel transparent wrappers + references to reach the +/// inner `TypePath`. Returns `None` for types we can't reduce +/// (`RwLock`, `Mutex`, `dyn Trait`, tuples, …) — those don't expose +/// inner methods through Deref. +// qual:recursive +fn peel_to_inner_path(ty: &syn::Type) -> Option<&syn::TypePath> { + match ty { + syn::Type::Reference(r) => peel_to_inner_path(&r.elem), + syn::Type::Paren(p) => peel_to_inner_path(&p.elem), + syn::Type::Path(p) => match transparent_wrapper_inner(p) { + Some(inner) => peel_to_inner_path(inner), + None => Some(p), + }, + _ => None, + } +} + +/// If `tp`'s last segment is a Deref-transparent stdlib wrapper +/// (`Box`/`Arc`/`Rc`/`Cow`), return its first generic type arg so the +/// caller can peel further. Returns `None` for non-wrapper paths or +/// wrappers without a positional type arg. Operation. +fn transparent_wrapper_inner(tp: &syn::TypePath) -> Option<&syn::Type> { + const STDLIB_TRANSPARENT: &[&str] = &["Box", "Arc", "Rc", "Cow"]; + let last = tp.path.segments.last()?; + let name = last.ident.to_string(); + if !STDLIB_TRANSPARENT.contains(&name.as_str()) { + return None; + } + let syn::PathArguments::AngleBracketed(ab) = &last.arguments else { + return None; + }; + ab.args.iter().find_map(|arg| match arg { + syn::GenericArgument::Type(t) => Some(t), + _ => None, + }) +} + /// Build `crate::::::` joined as a /// single string — the canonical key both `visible_canonicals` and /// `resolve_impl_self_type` agree on. Operation: pure string assembly. diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs index 2b1c36f..b03a0e9 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs @@ -433,6 +433,36 @@ fn test_collect_pub_fns_records_impl_via_nested_pub_use_export_path() { ); } +#[test] +fn test_collect_pub_fns_records_impl_via_pub_type_alias_through_wrapper() { + // `pub type Public = Box;` — the alias target + // is wrapped in a Deref-transparent smart pointer. Receiver + // resolution peels Box/Arc/Rc/Cow, so the visible-types pass + // must do the same to reach the inner `private::Hidden` and + // recognise its impl methods as adapter surface. + let file = parse( + r#" + mod private { + pub struct Hidden; + impl Hidden { + pub fn op(&self) {} + } + } + pub type Public = Box; + "#, + ); + let files = vec![("src/cli/handlers.rs", &file)]; + let by_layer = { + let aliases = aliases_from_files(&files); + collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + }; + let cli = names_for_layer(&by_layer, "cli"); + assert!( + cli.contains("op"), + "wrapper-alias target's impl method must be recorded, got {cli:?}" + ); +} + #[test] fn test_collect_pub_fns_records_impl_via_pub_type_alias() { // `pub type Public = private::Hidden;` exposes a hidden source From 303aa585db5ec0f03be0be9ad0c0161c5fb3b3aa Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sun, 26 Apr 2026 00:50:30 +0200 Subject: [PATCH 23/30] fix: copilot comments --- CHANGELOG.md | 10 + README.md | 1 + .../architecture/call_parity_rule/mod.rs | 2 + .../architecture/call_parity_rule/pub_fns.rs | 2 + .../call_parity_rule/pub_fns_alias_chain.rs | 138 ++++++++++ .../call_parity_rule/pub_fns_visibility.rs | 120 ++++++--- .../call_parity_rule/tests/pub_fns.rs | 241 ++++++++++++++++-- .../call_parity_rule/tests/support.rs | 8 +- 8 files changed, 464 insertions(+), 58 deletions(-) create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/pub_fns_alias_chain.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c69c38b..b904fd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -211,6 +211,16 @@ fallback markers rather than fabricate edges: re-export and its impl methods get recorded as adapter surface. Workaround: rename to avoid the collision, or `qual:allow(architecture)` on the affected impl. +- `pub type Public = private::Hidden; impl Public { pub fn op() }` — + the impl method is indexed under `crate::…::Public::op` (impl + self-type via path canonicaliser), but a caller `fn h(x: Public) + { x.op() }` resolves `x` via type-alias expansion to + `crate::…::private::Hidden` and emits a `Hidden::op` edge. + Visibility sees `Public`, but the edges disagree so Check B + flags `Public::op` as unreached. Workaround: write `impl + private::Hidden { … }` directly so impl-canonical and + caller-canonical agree, or `qual:allow(architecture)` on the + affected impl. - Arbitrary proc-macros that alter the call graph without being in `transparent_macros` config. User-annotate via `// qual:allow(architecture)` on the enclosing fn. diff --git a/README.md b/README.md index 302cdf6..ec7f9dd 100644 --- a/README.md +++ b/README.md @@ -928,6 +928,7 @@ Known limits (documented, with clear workarounds): - **Caller-side `pub use` path-following.** `pub mod outer { mod private { pub struct Hidden; impl Hidden { pub fn op() } } pub use self::private::Hidden; }` with a caller `fn h(x: outer::Hidden) { x.op() }` resolves the parameter to `crate::…::outer::Hidden` while the impl is keyed under `crate::…::outer::private::Hidden`. Visibility is recognised on both paths, but the call-graph edge goes to `:op` because the resolver doesn't follow workspace-wide `pub use` re-exports inside nested modules. Workaround: write `impl outer::Hidden { … }` at the file-level qualified path so impl-canonical and caller-canonical agree, or `qual:allow(architecture)` at the call-site. - **Re-exported type aliases inside private modules.** `mod private { pub type Public = Hidden; … } pub use private::Public;` doesn't follow into the alias's target — private modules aren't walked by the visibility pass, so the alias's source type stays out of `visible_canonicals`. Workaround: lift the type alias to the parent module (`pub use private::Hidden; pub type Public = Hidden;`) so both the alias declaration and its target are processed. - **Type-vs-value namespace ambiguity in `pub use`.** A `pub use internal::helper as Hidden;` re-export adds `Hidden` as a workspace-visible *type* canonical without checking whether the leaf is actually a type. If the same scope has a private `struct Hidden`, its impl methods get registered as adapter surface even though the `pub use` only exported a function. Workaround: rename to avoid the value/type collision, or `qual:allow(architecture)` on the affected impl. +- **`impl Alias { … }` with caller-side alias expansion.** `pub type Public = private::Hidden; impl Public { pub fn op(&self) {} }` indexes the method under `crate::…::Public::op` (impl self-type goes through the path canonicaliser), while a caller `fn h(x: Public) { x.op() }` resolves `x` via type-alias expansion to `crate::…::private::Hidden` and produces a `Hidden::op` edge. Visibility recognises `Public`, but the call-graph edges and the indexed method canonical disagree, so Check B reports `Public::op` as unreached. Workaround: declare the `impl` against the source type (`impl private::Hidden { … }`) so impl-canonical and caller-canonical agree, or `qual:allow(architecture)` on the affected impl. - **Arbitrary proc-macros** not listed in `transparent_macros` — `// qual:allow(architecture)` on the enclosing fn is the escape. diff --git a/src/adapters/analyzers/architecture/call_parity_rule/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/mod.rs index eccb78f..cef229c 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/mod.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/mod.rs @@ -19,6 +19,7 @@ pub mod check_a; pub mod check_b; pub(crate) mod local_symbols; pub mod pub_fns; +mod pub_fns_alias_chain; mod pub_fns_visibility; pub(crate) mod signature_params; pub mod type_infer; @@ -62,6 +63,7 @@ pub fn collect_findings( &aliases_per_file, &compiled.layers, &cfg_test_files, + &cp.transparent_wrappers, ); let graph = workspace_graph::build_call_graph( &refs, diff --git a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs index 2f12b54..f62adb0 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs @@ -58,6 +58,7 @@ pub(crate) fn collect_pub_fns_by_layer<'ast>( aliases_per_file: &HashMap>>, layers: &LayerDefinitions, cfg_test_files: &HashSet, + transparent_wrappers: &HashSet, ) -> HashMap>> { let crate_root_modules = collect_crate_root_modules(files); let visible_canonicals = collect_visible_type_canonicals_workspace( @@ -65,6 +66,7 @@ pub(crate) fn collect_pub_fns_by_layer<'ast>( cfg_test_files, aliases_per_file, &crate_root_modules, + transparent_wrappers, ); let empty_aliases = HashMap::new(); let mut out: HashMap>> = HashMap::new(); diff --git a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_alias_chain.rs b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_alias_chain.rs new file mode 100644 index 0000000..289d992 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_alias_chain.rs @@ -0,0 +1,138 @@ +//! Workspace-wide alias-chain pre-pass. +//! +//! Walks every file (including private modules) and records each +//! `type Alias = Target;` declaration as a `(alias_canonical → +//! target_canonical)` edge. The visibility collector chases this map +//! after registering an alias's immediate target so chains like +//! `pub type Public = Inner; type Inner = private::Hidden;` reach +//! the source type even when intermediate aliases are private. + +use super::bindings::{canonicalise_type_segments_in_scope, CanonScope}; +use super::local_symbols::{collect_local_symbols_scoped, FileScope, LocalSymbols}; +use super::pub_fns_visibility::{canonical_for_decl, peel_to_inner_path}; +use crate::adapters::shared::cfg_test::has_cfg_test; +use crate::adapters::shared::use_tree::gather_alias_map_scoped; +use std::collections::{HashMap, HashSet}; + +/// Build the workspace-wide alias-chain map. Per-file delegate to the +/// unconditional walker. Operation. +pub(super) fn collect_alias_chain( + files: &[(&str, &syn::File)], + cfg_test_files: &HashSet, + aliases_per_file: &HashMap>>, + crate_root_modules: &HashSet, + transparent_wrappers: &HashSet, +) -> HashMap { + let mut chain = HashMap::new(); + let empty_aliases = HashMap::new(); + for (path, ast) in files { + if cfg_test_files.contains(*path) { + continue; + } + let alias_map = aliases_per_file.get(*path).unwrap_or(&empty_aliases); + let LocalSymbols { flat, by_name } = collect_local_symbols_scoped(ast); + let aliases_per_scope = gather_alias_map_scoped(ast); + let file_scope = FileScope { + path, + alias_map, + aliases_per_scope: &aliases_per_scope, + local_symbols: &flat, + local_decl_scopes: &by_name, + crate_root_modules, + }; + walk_alias_chain( + &ast.items, + &[], + &file_scope, + transparent_wrappers, + &mut chain, + ); + } + chain +} + +/// Recursive walk that records every `type X = Y;` declaration — +/// regardless of `X`'s visibility or its enclosing module's +/// visibility — into the alias-chain map. Operation: closure-hidden +/// descent into all `mod` blocks (cfg-test still skipped). +// qual:recursive +fn walk_alias_chain( + items: &[syn::Item], + mod_stack: &[String], + file_scope: &FileScope<'_>, + transparent_wrappers: &HashSet, + chain: &mut HashMap, +) { + let recurse = |inner: &[syn::Item], next: &[String], chain: &mut HashMap| { + walk_alias_chain(inner, next, file_scope, transparent_wrappers, chain); + }; + for item in items { + match item { + syn::Item::Type(t) => { + let alias_canonical = + canonical_for_decl(file_scope.path, mod_stack, &t.ident.to_string()); + if let Some(target) = resolve_alias_target_canonical( + &t.ty, + file_scope, + mod_stack, + transparent_wrappers, + ) { + chain.insert(alias_canonical, target); + } + } + syn::Item::Mod(m) if !has_cfg_test(&m.attrs) => { + if let Some((_, inner)) = m.content.as_ref() { + let mut next = mod_stack.to_vec(); + next.push(m.ident.to_string()); + recurse(inner, &next, chain); + } + } + _ => {} + } + } +} + +/// Peel-and-canonicalise an alias's target type, returning the +/// resolved canonical path joined as a string. Shared by the +/// chain-builder and the visibility walker so both agree on what +/// `pub type Public = Box` reduces to. Operation. +pub(super) fn resolve_alias_target_canonical( + ty: &syn::Type, + file_scope: &FileScope<'_>, + mod_stack: &[String], + transparent_wrappers: &HashSet, +) -> Option { + let p = peel_to_inner_path(ty, transparent_wrappers)?; + let segs: Vec = p + .path + .segments + .iter() + .map(|s| s.ident.to_string()) + .collect(); + let scope = CanonScope { + file: file_scope, + mod_stack, + }; + canonicalise_type_segments_in_scope(&segs, &scope).map(|c| c.join("::")) +} + +/// Follow an alias chain from `start` through `alias_chain` until a +/// fixed point or cycle is reached, inserting every intermediate +/// canonical into `out`. `seen` guards against `type A = B; type B +/// = A;` cycles. Operation. +pub(super) fn chase_alias_chain( + start: &str, + alias_chain: &HashMap, + out: &mut HashSet, +) { + let mut current = start.to_string(); + let mut seen: HashSet = HashSet::new(); + seen.insert(current.clone()); + while let Some(next) = alias_chain.get(¤t) { + if !seen.insert(next.clone()) { + break; + } + out.insert(next.clone()); + current = next.clone(); + } +} diff --git a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs index e3f8c1a..e54c531 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs @@ -9,6 +9,9 @@ use super::bindings::{canonicalise_type_segments_in_scope, CanonScope}; use super::local_symbols::{collect_local_symbols_scoped, FileScope, LocalSymbols}; +use super::pub_fns_alias_chain::{ + chase_alias_chain, collect_alias_chain, resolve_alias_target_canonical, +}; use crate::adapters::analyzers::architecture::forbidden_rule::file_to_module_segments; use crate::adapters::shared::cfg_test::has_cfg_test; use crate::adapters::shared::use_tree::gather_alias_map_scoped; @@ -32,17 +35,64 @@ fn is_self_restricted(path: &syn::Path) -> bool { path.leading_colon.is_none() && path.segments.len() == 1 && path.segments[0].ident == "self" } +/// Workspace-wide context shared across both passes (alias-chain +/// pre-pass and visible-canonicals collection). Bundles the user +/// transparent-wrapper set and the alias-chain map so functions that +/// need both don't end up with sprawling parameter lists. +struct WalkCtx<'a> { + transparent_wrappers: &'a HashSet, + alias_chain: &'a HashMap, +} + // qual:api /// Collect every publicly named type's canonical path across the -/// whole non-test workspace. Integration: per-file delegate to -/// recursive collector. +/// whole non-test workspace. Integration: pre-builds the +/// alias-chain map, then per-file delegates to the recursive +/// collector. pub(super) fn collect_visible_type_canonicals_workspace( files: &[(&str, &syn::File)], cfg_test_files: &HashSet, aliases_per_file: &HashMap>>, crate_root_modules: &HashSet, + transparent_wrappers: &HashSet, ) -> HashSet { + let alias_chain = collect_alias_chain( + files, + cfg_test_files, + aliases_per_file, + crate_root_modules, + transparent_wrappers, + ); + let ctx = WalkCtx { + transparent_wrappers, + alias_chain: &alias_chain, + }; let mut out = HashSet::new(); + for_each_file_scope( + files, + cfg_test_files, + aliases_per_file, + crate_root_modules, + |file_scope, ast| { + collect_in_items(&ast.items, &[], file_scope, &ctx, &mut out); + }, + ); + out +} + +/// Build a `FileScope` for each non-cfg-test workspace file and call +/// `body` with it plus the AST. Centralises the per-file construction +/// boilerplate the visibility pass and (via `pub_fns_alias_chain`) +/// the alias-chain pre-pass share. Operation. +fn for_each_file_scope( + files: &[(&str, &syn::File)], + cfg_test_files: &HashSet, + aliases_per_file: &HashMap>>, + crate_root_modules: &HashSet, + mut body: F, +) where + F: FnMut(&FileScope<'_>, &syn::File), +{ let empty_aliases = HashMap::new(); for (path, ast) in files { if cfg_test_files.contains(*path) { @@ -59,9 +109,8 @@ pub(super) fn collect_visible_type_canonicals_workspace( local_decl_scopes: &by_name, crate_root_modules, }; - collect_in_items(&ast.items, &[], &file_scope, &mut out); + body(&file_scope, ast); } - out } /// Walk a slice of items, inserting publicly named types' canonical @@ -77,10 +126,11 @@ fn collect_in_items( items: &[syn::Item], mod_stack: &[String], file_scope: &FileScope<'_>, + ctx: &WalkCtx<'_>, out: &mut HashSet, ) { let recurse = |inner: &[syn::Item], next: &[String], out: &mut HashSet| { - collect_in_items(inner, next, file_scope, out); + collect_in_items(inner, next, file_scope, ctx, out); }; let add_decl = |ident: &syn::Ident, out: &mut HashSet| { out.insert(canonical_for_decl( @@ -93,7 +143,7 @@ fn collect_in_items( walk_use_tree(tree, &mut Vec::new(), file_scope, mod_stack, out); }; let add_alias_target = |ty: &syn::Type, out: &mut HashSet| { - register_alias_target(ty, file_scope, mod_stack, out); + register_alias_target(ty, file_scope, mod_stack, ctx, out); }; for item in items { match item { @@ -120,35 +170,23 @@ fn collect_in_items( /// `pub type Public = private::Hidden;` (or `Box`, /// `Arc<…>`, etc.) — a public alias can expose methods declared on -/// a hidden source type. Peel any transparent stdlib wrapper -/// (`Box` / `Arc` / `Rc` / `Cow`), `&` references, and parens to -/// reach the inner type-path, then resolve through the workspace -/// canonicaliser and add the result. Mirrors the receiver-type -/// resolver, so impls keyed on the source type are recognised -/// regardless of whether the alias is a bare path or a wrapper- -/// wrapped one. Operation. +/// a hidden source type. Resolve the alias's immediate target through +/// the peel-and-canonicalise pipeline, then chase any further chain +/// entries. Operation. fn register_alias_target( ty: &syn::Type, file_scope: &FileScope<'_>, mod_stack: &[String], + ctx: &WalkCtx<'_>, out: &mut HashSet, ) { - let Some(p) = peel_to_inner_path(ty) else { + let Some(immediate) = + resolve_alias_target_canonical(ty, file_scope, mod_stack, ctx.transparent_wrappers) + else { return; }; - let segs: Vec = p - .path - .segments - .iter() - .map(|s| s.ident.to_string()) - .collect(); - let scope = CanonScope { - file: file_scope, - mod_stack, - }; - if let Some(canonical) = canonicalise_type_segments_in_scope(&segs, &scope) { - out.insert(canonical.join("::")); - } + out.insert(immediate.clone()); + chase_alias_chain(&immediate, ctx.alias_chain, out); } /// Recursively peel transparent wrappers + references to reach the @@ -156,27 +194,37 @@ fn register_alias_target( /// (`RwLock`, `Mutex`, `dyn Trait`, tuples, …) — those don't expose /// inner methods through Deref. // qual:recursive -fn peel_to_inner_path(ty: &syn::Type) -> Option<&syn::TypePath> { +pub(super) fn peel_to_inner_path<'a>( + ty: &'a syn::Type, + transparent_wrappers: &HashSet, +) -> Option<&'a syn::TypePath> { match ty { - syn::Type::Reference(r) => peel_to_inner_path(&r.elem), - syn::Type::Paren(p) => peel_to_inner_path(&p.elem), - syn::Type::Path(p) => match transparent_wrapper_inner(p) { - Some(inner) => peel_to_inner_path(inner), + syn::Type::Reference(r) => peel_to_inner_path(&r.elem, transparent_wrappers), + syn::Type::Paren(p) => peel_to_inner_path(&p.elem, transparent_wrappers), + syn::Type::Path(p) => match transparent_wrapper_inner(p, transparent_wrappers) { + Some(inner) => peel_to_inner_path(inner, transparent_wrappers), None => Some(p), }, _ => None, } } -/// If `tp`'s last segment is a Deref-transparent stdlib wrapper -/// (`Box`/`Arc`/`Rc`/`Cow`), return its first generic type arg so the +/// If `tp`'s last segment is a Deref-transparent wrapper — either a +/// stdlib one (`Box`/`Arc`/`Rc`/`Cow`) or a user-configured entry in +/// `[architecture.call_parity]::transparent_wrappers` (e.g. axum +/// `State`, actix `Data`) — return its first generic type arg so the /// caller can peel further. Returns `None` for non-wrapper paths or /// wrappers without a positional type arg. Operation. -fn transparent_wrapper_inner(tp: &syn::TypePath) -> Option<&syn::Type> { +fn transparent_wrapper_inner<'a>( + tp: &'a syn::TypePath, + transparent_wrappers: &HashSet, +) -> Option<&'a syn::Type> { const STDLIB_TRANSPARENT: &[&str] = &["Box", "Arc", "Rc", "Cow"]; let last = tp.path.segments.last()?; let name = last.ident.to_string(); - if !STDLIB_TRANSPARENT.contains(&name.as_str()) { + let is_transparent = + STDLIB_TRANSPARENT.contains(&name.as_str()) || transparent_wrappers.contains(&name); + if !is_transparent { return None; } let syn::PathArguments::AngleBracketed(ab) = &last.arguments else { diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs index b03a0e9..873e726 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs @@ -67,7 +67,13 @@ fn test_collect_pub_fns_in_layer_free_fn() { let files = vec![("src/cli/handlers.rs", &file)]; let by_layer = { let aliases = aliases_from_files(&files); - collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) }; let cli = names_for_layer(&by_layer, "cli"); assert!(cli.contains("cmd_stats"), "cli = {cli:?}"); @@ -84,7 +90,13 @@ fn test_collect_pub_fns_skips_private_fns() { let files = vec![("src/cli/handlers.rs", &file)]; let by_layer = { let aliases = aliases_from_files(&files); - collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) }; let cli = names_for_layer(&by_layer, "cli"); assert!(cli.contains("cmd_stats")); @@ -104,7 +116,13 @@ fn test_pub_crate_is_treated_as_public_for_intra_crate_layers() { let files = vec![("src/cli/handlers.rs", &file)]; let by_layer = { let aliases = aliases_from_files(&files); - collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) }; let cli = names_for_layer(&by_layer, "cli"); assert!(cli.contains("cmd_stats"), "pub(crate) must be collected"); @@ -121,7 +139,13 @@ fn test_pub_super_and_pub_in_path_treated_as_public() { let files = vec![("src/cli/handlers.rs", &file)]; let by_layer = { let aliases = aliases_from_files(&files); - collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) }; let cli = names_for_layer(&by_layer, "cli"); assert!(cli.contains("cmd_a")); @@ -142,7 +166,13 @@ fn test_collect_pub_fns_collects_pub_impl_methods_for_pub_type() { let files = vec![("src/application/session.rs", &file)]; let by_layer = { let aliases = aliases_from_files(&files); - collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) }; let app = names_for_layer(&by_layer, "application"); assert!(app.contains("search"), "pub impl method must be collected"); @@ -174,7 +204,13 @@ fn test_collect_pub_fns_recognises_impl_across_files() { ]; let by_layer = { let aliases = aliases_from_files(&files); - collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) }; let app = names_for_layer(&by_layer, "application"); assert!( @@ -199,7 +235,13 @@ fn test_collect_pub_fns_skips_impl_methods_on_private_type() { let files = vec![("src/application/session.rs", &file)]; let by_layer = { let aliases = aliases_from_files(&files); - collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) }; let app = names_for_layer(&by_layer, "application"); assert!( @@ -220,7 +262,13 @@ fn test_collect_pub_fns_groups_by_layer() { ]; let by_layer = { let aliases = aliases_from_files(&files); - collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) }; assert_eq!( names_for_layer(&by_layer, "cli"), @@ -243,7 +291,13 @@ fn test_collect_pub_fns_skips_cfg_test_files() { let mut cfg_test = HashSet::new(); cfg_test.insert("src/cli/handlers.rs".to_string()); let aliases = aliases_from_files(&files); - let by_layer = collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &cfg_test); + let by_layer = collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &cfg_test, + &HashSet::new(), + ); assert!( names_for_layer(&by_layer, "cli").is_empty(), "cfg-test file must be skipped wholesale" @@ -262,7 +316,13 @@ fn test_collect_pub_fns_skips_test_attr_fns() { let files = vec![("src/cli/handlers.rs", &file)]; let by_layer = { let aliases = aliases_from_files(&files); - collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) }; let cli = names_for_layer(&by_layer, "cli"); assert!(cli.contains("cmd_stats")); @@ -277,7 +337,13 @@ fn test_collect_pub_fns_skips_unmatched_files() { let files = vec![("src/utils/misc.rs", &file)]; let by_layer = { let aliases = aliases_from_files(&files); - collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) }; for layer in ["application", "cli", "mcp"] { assert!( @@ -304,7 +370,13 @@ fn test_collect_pub_fns_skips_pub_fn_inside_private_inline_mod() { let files = vec![("src/cli/handlers.rs", &file)]; let by_layer = { let aliases = aliases_from_files(&files); - collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) }; let cli = names_for_layer(&by_layer, "cli"); assert!( @@ -330,7 +402,13 @@ fn test_collect_pub_fns_treats_pub_self_as_private() { let files = vec![("src/cli/handlers.rs", &file)]; let by_layer = { let aliases = aliases_from_files(&files); - collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) }; let cli = names_for_layer(&by_layer, "cli"); assert!( @@ -362,7 +440,13 @@ fn test_collect_pub_fns_skips_impl_method_on_type_in_private_inline_mod() { let files = vec![("src/cli/handlers.rs", &file)]; let by_layer = { let aliases = aliases_from_files(&files); - collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) }; let cli = names_for_layer(&by_layer, "cli"); assert!( @@ -392,7 +476,13 @@ fn test_collect_pub_fns_records_impl_in_private_mod_for_public_type() { let files = vec![("src/application/session.rs", &file)]; let by_layer = { let aliases = aliases_from_files(&files); - collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) }; let app = names_for_layer(&by_layer, "application"); assert!( @@ -424,7 +514,13 @@ fn test_collect_pub_fns_records_impl_via_nested_pub_use_export_path() { let files = vec![("src/cli/handlers.rs", &file)]; let by_layer = { let aliases = aliases_from_files(&files); - collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) }; let cli = names_for_layer(&by_layer, "cli"); assert!( @@ -433,6 +529,79 @@ fn test_collect_pub_fns_records_impl_via_nested_pub_use_export_path() { ); } +#[test] +fn test_collect_pub_fns_records_impl_via_chained_type_alias() { + // `type Inner = private::Hidden; pub type Public = Inner;` — + // the alias chain must be followed to the source type, otherwise + // visible_canonicals only contains `Inner` and the impl on + // `Hidden` stays out of scope. + let file = parse( + r#" + mod private { + pub struct Hidden; + impl Hidden { + pub fn op(&self) {} + } + } + type Inner = private::Hidden; + pub type Public = Inner; + "#, + ); + let files = vec![("src/cli/handlers.rs", &file)]; + let by_layer = { + let aliases = aliases_from_files(&files); + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) + }; + let cli = names_for_layer(&by_layer, "cli"); + assert!( + cli.contains("op"), + "alias chain target's impl method must be recorded, got {cli:?}" + ); +} + +#[test] +fn test_collect_pub_fns_records_impl_via_pub_type_alias_through_user_wrapper() { + // `pub type Public = State;` with a + // user-configured transparent wrapper `State`. The visibility + // pass must consult the same wrapper set the receiver resolver + // uses, otherwise Check B drops the public target. + let file = parse( + r#" + mod private { + pub struct Hidden; + impl Hidden { + pub fn op(&self) {} + } + } + pub type Public = State; + "#, + ); + let files = vec![("src/cli/handlers.rs", &file)]; + let mut wrappers = HashSet::new(); + wrappers.insert("State".to_string()); + let by_layer = { + let aliases = aliases_from_files(&files); + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &wrappers, + ) + }; + let cli = names_for_layer(&by_layer, "cli"); + assert!( + cli.contains("op"), + "user-wrapper alias target's impl method must be recorded, got {cli:?}" + ); +} + #[test] fn test_collect_pub_fns_records_impl_via_pub_type_alias_through_wrapper() { // `pub type Public = Box;` — the alias target @@ -454,7 +623,13 @@ fn test_collect_pub_fns_records_impl_via_pub_type_alias_through_wrapper() { let files = vec![("src/cli/handlers.rs", &file)]; let by_layer = { let aliases = aliases_from_files(&files); - collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) }; let cli = names_for_layer(&by_layer, "cli"); assert!( @@ -484,7 +659,13 @@ fn test_collect_pub_fns_records_impl_via_pub_type_alias() { let files = vec![("src/cli/handlers.rs", &file)]; let by_layer = { let aliases = aliases_from_files(&files); - collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) }; let cli = names_for_layer(&by_layer, "cli"); assert!( @@ -515,7 +696,13 @@ fn test_collect_pub_fns_records_renamed_reexport_impl_methods() { let files = vec![("src/cli/handlers.rs", &file)]; let by_layer = { let aliases = aliases_from_files(&files); - collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) }; let cli = names_for_layer(&by_layer, "cli"); assert!( @@ -548,7 +735,13 @@ fn test_collect_pub_fns_records_pub_use_reexport_with_qualified_impl() { let files = vec![("src/cli/handlers.rs", &file)]; let by_layer = { let aliases = aliases_from_files(&files); - collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) }; let cli = names_for_layer(&by_layer, "cli"); assert!( @@ -585,7 +778,13 @@ fn test_collect_pub_fns_skips_impl_methods_under_short_name_collision() { let files = vec![("src/cli/handlers.rs", &file)]; let by_layer = { let aliases = aliases_from_files(&files); - collect_pub_fns_by_layer(&files, &aliases, &adapter_layers(), &HashSet::new()) + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) }; let cli = names_for_layer(&by_layer, "cli"); assert!( diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/support.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/support.rs index 343f097..c60adc4 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/support.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/support.rs @@ -70,7 +70,13 @@ pub(super) fn run_check( cfg_test: &HashSet, ) -> Vec { let borrowed = borrowed_files(ws); - let pub_fns = collect_pub_fns_by_layer(&borrowed, &ws.aliases_per_file, layers, cfg_test); + let pub_fns = collect_pub_fns_by_layer( + &borrowed, + &ws.aliases_per_file, + layers, + cfg_test, + &cp.transparent_wrappers, + ); let graph = build_call_graph( &borrowed, &ws.aliases_per_file, From 537b9139f57c439ddfa1ec3337eef5247bc89b53 Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sun, 26 Apr 2026 01:23:56 +0200 Subject: [PATCH 24/30] fix: copilot comments --- CHANGELOG.md | 9 +++++ README.md | 1 + .../call_parity_rule/tests/regressions.rs | 21 ++++++++++++ .../call_parity_rule/type_infer/resolve.rs | 34 ++++++++++++++++--- .../type_infer/workspace_index/functions.rs | 4 +-- .../type_infer/workspace_index/methods.rs | 4 +-- .../call_parity_rule/workspace_graph.rs | 6 ++-- 7 files changed, 67 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b904fd7..1e36b7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -221,6 +221,15 @@ fallback markers rather than fabricate edges: private::Hidden { … }` directly so impl-canonical and caller-canonical agree, or `qual:allow(architecture)` on the affected impl. +- `type Id = T; pub type Public = Id;` — the + visibility pass doesn't substitute use-site generic args into + alias bodies (the workspace alias-index runs after pub-fn + enumeration). `Id` enters `visible_canonicals`, but + `private::Hidden` doesn't, so Check B can drop public methods on + `Hidden`. Receiver-side resolution does substitute, so callers + still reach `Hidden::op`. Workaround: skip the generic-alias + indirection (`pub type Public = private::Hidden;`), or + `qual:allow(architecture)` on the affected impl. - Arbitrary proc-macros that alter the call graph without being in `transparent_macros` config. User-annotate via `// qual:allow(architecture)` on the enclosing fn. diff --git a/README.md b/README.md index ec7f9dd..fbf17d9 100644 --- a/README.md +++ b/README.md @@ -929,6 +929,7 @@ Known limits (documented, with clear workarounds): - **Re-exported type aliases inside private modules.** `mod private { pub type Public = Hidden; … } pub use private::Public;` doesn't follow into the alias's target — private modules aren't walked by the visibility pass, so the alias's source type stays out of `visible_canonicals`. Workaround: lift the type alias to the parent module (`pub use private::Hidden; pub type Public = Hidden;`) so both the alias declaration and its target are processed. - **Type-vs-value namespace ambiguity in `pub use`.** A `pub use internal::helper as Hidden;` re-export adds `Hidden` as a workspace-visible *type* canonical without checking whether the leaf is actually a type. If the same scope has a private `struct Hidden`, its impl methods get registered as adapter surface even though the `pub use` only exported a function. Workaround: rename to avoid the value/type collision, or `qual:allow(architecture)` on the affected impl. - **`impl Alias { … }` with caller-side alias expansion.** `pub type Public = private::Hidden; impl Public { pub fn op(&self) {} }` indexes the method under `crate::…::Public::op` (impl self-type goes through the path canonicaliser), while a caller `fn h(x: Public) { x.op() }` resolves `x` via type-alias expansion to `crate::…::private::Hidden` and produces a `Hidden::op` edge. Visibility recognises `Public`, but the call-graph edges and the indexed method canonical disagree, so Check B reports `Public::op` as unreached. Workaround: declare the `impl` against the source type (`impl private::Hidden { … }`) so impl-canonical and caller-canonical agree, or `qual:allow(architecture)` on the affected impl. +- **Generic type-alias substitution in the visibility chain.** `type Id = T; pub type Public = Id;` doesn't substitute the use-site arg `private::Hidden` into `Id`'s body in the visibility pass — only the immediate alias `Id` enters `visible_canonicals`. Receiver-side resolution does substitute (the workspace alias-index runs after pub-fn enumeration), so callers reach `Hidden::op` correctly, but Check B can drop the public target. Workaround: skip the generic-alias indirection (`pub type Public = private::Hidden;`), or `qual:allow(architecture)` on the affected impl. - **Arbitrary proc-macros** not listed in `transparent_macros` — `// qual:allow(architecture)` on the enclosing fn is the escape. diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs index cd5d03f..84570e5 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs @@ -541,6 +541,27 @@ fn cast_as_self_resolves() { ); } +#[test] +fn aliased_stdlib_wrapper_peels_to_inner() { + // `use std::sync::Arc as Shared;` then `fn h(s: Shared)` + // — the receiver resolver must follow the alias to recognise + // `Shared` as `Arc`, peel it, and reach `Session::diff`. + let fx = parse( + r#" + use std::sync::Arc as Shared; + use crate::app::session::Session; + pub fn handle(s: Shared) { + s.diff(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "handle"); + assert!( + calls.contains("crate::app::session::Session::diff"), + "aliased Arc wrapper must peel to Session, got {calls:?}" + ); +} + // ═══════════════════════════════════════════════════════════════════ // Positive: free-fn return-type chain // ═══════════════════════════════════════════════════════════════════ diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs index 9a96c52..620920e 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs @@ -193,9 +193,19 @@ fn is_marker_trait(path: &syn::Path) -> bool { MARKER_TRAITS.contains(&name.as_str()) } +/// Names of the recognised stdlib wrappers, used both for direct-name +/// dispatch in `resolve_path` and for the alias-aware lookup that +/// promotes `use std::sync::Arc as Shared;`-imported names to their +/// canonical wrapper. +const WRAPPER_NAMES: &[&str] = &[ + "Result", "Option", "Future", "Vec", "HashMap", "BTreeMap", "Arc", "Box", "Rc", "Cow", +]; + /// Dispatch on the last path-segment's ident to recognise stdlib -/// wrappers. Falls through to `resolve_generic_path` for everything -/// else. Integration: closure-hidden own calls keep IOSP clean. +/// wrappers. Resolves `use std::sync::Arc as Shared;`-style import +/// aliases first, so `Shared` peels just like `Arc`. Falls +/// through to `resolve_generic_path` for everything else. +/// Integration: closure-hidden own calls keep IOSP clean. fn resolve_path(path: &syn::Path, ctx: &ResolveContext<'_>, depth: u8) -> CanonicalType { let Some(last) = path.segments.last() else { return CanonicalType::Opaque; @@ -206,9 +216,23 @@ fn resolve_path(path: &syn::Path, ctx: &ResolveContext<'_>, depth: u8) -> Canoni }; let peel = || peel_single_generic(args, ctx, depth); let fallback = || resolve_generic_path(path, ctx, depth); - let name = last.ident.to_string(); let wrap_future = || wrap_future_output(args, ctx, depth); - match name.as_str() { + let raw_name = last.ident.to_string(); + // Promote `use std::sync::Arc as Shared`-style import aliases to + // their canonical wrapper name so `Shared` matches the same + // arms as `Arc`. Direct-name hits and unrelated paths skip + // the alias lookup. + let alias_target = (!WRAPPER_NAMES.contains(&raw_name.as_str()) + && !is_user_transparent(&raw_name, ctx)) + .then(|| ctx.file.alias_map.get(&raw_name).and_then(|p| p.last())) + .flatten(); + let name = match alias_target { + Some(seg) if WRAPPER_NAMES.contains(&seg.as_str()) || is_user_transparent(seg, ctx) => { + seg.as_str() + } + _ => raw_name.as_str(), + }; + match name { "Result" => wrap(0, CanonicalType::Result), "Option" => wrap(0, CanonicalType::Option), // Future uses `Output = T` associated-type syntax, not a @@ -224,7 +248,7 @@ fn resolve_path(path: &syn::Path, ctx: &ResolveContext<'_>, depth: u8) -> Canoni // type. Users can opt back in via `transparent_wrappers` for // domain-specific deref-like wrappers. "Arc" | "Box" | "Rc" | "Cow" => peel(), - _ if is_user_transparent(&name, ctx) => peel(), + _ if is_user_transparent(name, ctx) => peel(), _ => fallback(), } } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs index d9d372d..722fc6e 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/functions.rs @@ -10,7 +10,7 @@ use super::super::canonical::CanonicalType; use super::super::resolve::resolve_type; use super::{resolve_ctx_from_build, BuildContext, WorkspaceTypeIndex}; use crate::adapters::analyzers::architecture::forbidden_rule::file_to_module_segments; -use crate::adapters::shared::cfg_test::has_cfg_test; +use crate::adapters::shared::cfg_test::{has_cfg_test, has_test_attr}; use syn::visit::Visit; /// Walk `ast` and populate `index.fn_returns`. Integration. @@ -35,7 +35,7 @@ struct FnCollector<'i, 'c> { impl<'ast, 'i, 'c> Visit<'ast> for FnCollector<'i, 'c> { fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) { - if has_cfg_test(&node.attrs) { + if has_cfg_test(&node.attrs) || has_test_attr(&node.attrs) { return; } record_fn(self.index, self.ctx, &self.mod_stack, node); diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs index 17169d9..8413222 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/workspace_index/methods.rs @@ -18,7 +18,7 @@ use super::super::self_subst::substitute_bare_self; use super::{canonical_type_key, resolve_ctx_from_build, BuildContext, WorkspaceTypeIndex}; use crate::adapters::analyzers::architecture::call_parity_rule::bindings::CanonScope; use crate::adapters::analyzers::architecture::call_parity_rule::workspace_graph::resolve_impl_self_type; -use crate::adapters::shared::cfg_test::has_cfg_test; +use crate::adapters::shared::cfg_test::{has_cfg_test, has_test_attr}; use syn::visit::Visit; /// Walk `ast` and populate `index.method_returns`. Integration: delegates @@ -68,7 +68,7 @@ impl<'ast, 'i, 'c> Visit<'ast> for MethodCollector<'i, 'c> { } fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) { - if has_cfg_test(&node.attrs) { + if has_cfg_test(&node.attrs) || has_test_attr(&node.attrs) { return; } record_method( diff --git a/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs b/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs index 947646f..91c259c 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/workspace_graph.rs @@ -25,7 +25,7 @@ use super::signature_params::extract_signature_params; use super::type_infer::{build_workspace_type_index, WorkspaceIndexInputs, WorkspaceTypeIndex}; use crate::adapters::analyzers::architecture::forbidden_rule::file_to_module_segments; use crate::adapters::analyzers::architecture::layer_rule::LayerDefinitions; -use crate::adapters::shared::cfg_test::has_cfg_test; +use crate::adapters::shared::cfg_test::{has_cfg_test, has_test_attr}; use crate::adapters::shared::use_tree::gather_alias_map_scoped; use crate::adapters::shared::use_tree::ScopedAliasMap; use std::collections::{HashMap, HashSet, VecDeque}; @@ -360,7 +360,7 @@ impl<'a> FileFnCollector<'a> { impl<'a, 'ast> Visit<'ast> for FileFnCollector<'a> { fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) { - if has_cfg_test(&node.attrs) { + if has_cfg_test(&node.attrs) || has_test_attr(&node.attrs) { return; } let name = node.sig.ident.to_string(); @@ -389,7 +389,7 @@ impl<'a, 'ast> Visit<'ast> for FileFnCollector<'a> { } fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) { - if has_cfg_test(&node.attrs) { + if has_cfg_test(&node.attrs) || has_test_attr(&node.attrs) { return; } let name = node.sig.ident.to_string(); From c41ac51231e66a23ffde04d0a4873a228da7b2fc Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sun, 26 Apr 2026 01:39:08 +0200 Subject: [PATCH 25/30] fix: copilot comments --- .../call_parity_rule/pub_fns_alias_chain.rs | 2 +- .../call_parity_rule/pub_fns_visibility.rs | 54 ++++++++++++---- .../call_parity_rule/tests/pub_fns.rs | 36 +++++++++++ .../call_parity_rule/tests/regressions.rs | 61 ++++++++++++++++++- .../call_parity_rule/type_infer/resolve.rs | 13 ++-- 5 files changed, 147 insertions(+), 19 deletions(-) diff --git a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_alias_chain.rs b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_alias_chain.rs index 289d992..b9419d1 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_alias_chain.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_alias_chain.rs @@ -102,7 +102,7 @@ pub(super) fn resolve_alias_target_canonical( mod_stack: &[String], transparent_wrappers: &HashSet, ) -> Option { - let p = peel_to_inner_path(ty, transparent_wrappers)?; + let p = peel_to_inner_path(ty, transparent_wrappers, file_scope, mod_stack)?; let segs: Vec = p .path .segments diff --git a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs index e54c531..0af6279 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs @@ -192,19 +192,28 @@ fn register_alias_target( /// Recursively peel transparent wrappers + references to reach the /// inner `TypePath`. Returns `None` for types we can't reduce /// (`RwLock`, `Mutex`, `dyn Trait`, tuples, …) — those don't expose -/// inner methods through Deref. +/// inner methods through Deref. `file_scope` + `mod_stack` are +/// threaded so renamed-import wrappers (`use std::sync::Arc as +/// Shared;`) get recognised through the scope-aware alias resolver. // qual:recursive pub(super) fn peel_to_inner_path<'a>( ty: &'a syn::Type, transparent_wrappers: &HashSet, + file_scope: &FileScope<'_>, + mod_stack: &[String], ) -> Option<&'a syn::TypePath> { + let recurse = |inner: &'a syn::Type| { + peel_to_inner_path(inner, transparent_wrappers, file_scope, mod_stack) + }; match ty { - syn::Type::Reference(r) => peel_to_inner_path(&r.elem, transparent_wrappers), - syn::Type::Paren(p) => peel_to_inner_path(&p.elem, transparent_wrappers), - syn::Type::Path(p) => match transparent_wrapper_inner(p, transparent_wrappers) { - Some(inner) => peel_to_inner_path(inner, transparent_wrappers), - None => Some(p), - }, + syn::Type::Reference(r) => recurse(&r.elem), + syn::Type::Paren(p) => recurse(&p.elem), + syn::Type::Path(p) => { + match transparent_wrapper_inner(p, transparent_wrappers, file_scope, mod_stack) { + Some(inner) => recurse(inner), + None => Some(p), + } + } _ => None, } } @@ -213,18 +222,37 @@ pub(super) fn peel_to_inner_path<'a>( /// stdlib one (`Box`/`Arc`/`Rc`/`Cow`) or a user-configured entry in /// `[architecture.call_parity]::transparent_wrappers` (e.g. axum /// `State`, actix `Data`) — return its first generic type arg so the -/// caller can peel further. Returns `None` for non-wrapper paths or -/// wrappers without a positional type arg. Operation. +/// caller can peel further. Renamed imports +/// (`use std::sync::Arc as Shared;`) are followed through the +/// scope-aware canonicaliser so `Shared` peels just like +/// `Arc`. Returns `None` for non-wrapper paths or wrappers +/// without a positional type arg. Operation. fn transparent_wrapper_inner<'a>( tp: &'a syn::TypePath, transparent_wrappers: &HashSet, + file_scope: &FileScope<'_>, + mod_stack: &[String], ) -> Option<&'a syn::Type> { const STDLIB_TRANSPARENT: &[&str] = &["Box", "Arc", "Rc", "Cow"]; + let is_transparent_name = |name: &str| -> bool { + STDLIB_TRANSPARENT.contains(&name) || transparent_wrappers.contains(name) + }; let last = tp.path.segments.last()?; - let name = last.ident.to_string(); - let is_transparent = - STDLIB_TRANSPARENT.contains(&name.as_str()) || transparent_wrappers.contains(&name); - if !is_transparent { + let raw_name = last.ident.to_string(); + let direct = is_transparent_name(&raw_name); + let aliased = !direct + && canonicalise_type_segments_in_scope( + std::slice::from_ref(&raw_name), + &CanonScope { + file: file_scope, + mod_stack, + }, + ) + .as_ref() + .and_then(|p| p.last()) + .map(|seg| is_transparent_name(seg)) + .unwrap_or(false); + if !direct && !aliased { return None; } let syn::PathArguments::AngleBracketed(ab) = &last.arguments else { diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs index 873e726..adc0a11 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs @@ -565,6 +565,42 @@ fn test_collect_pub_fns_records_impl_via_chained_type_alias() { ); } +#[test] +fn test_collect_pub_fns_records_impl_via_renamed_stdlib_wrapper() { + // `use std::sync::Arc as Shared; pub type Public = Shared;` + // — the visibility pass must follow the import alias when peeling + // wrappers, otherwise `Shared` is treated as a non-wrapper and + // `private::Hidden` never enters `visible_canonicals`. + let file = parse( + r#" + use std::sync::Arc as Shared; + mod private { + pub struct Hidden; + impl Hidden { + pub fn op(&self) {} + } + } + pub type Public = Shared; + "#, + ); + let files = vec![("src/cli/handlers.rs", &file)]; + let by_layer = { + let aliases = aliases_from_files(&files); + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) + }; + let cli = names_for_layer(&by_layer, "cli"); + assert!( + cli.contains("op"), + "renamed stdlib wrapper alias must peel in visibility pass, got {cli:?}" + ); +} + #[test] fn test_collect_pub_fns_records_impl_via_pub_type_alias_through_user_wrapper() { // `pub type Public = State;` with a diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs index 84570e5..886bd6d 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs @@ -20,7 +20,9 @@ use crate::adapters::analyzers::architecture::call_parity_rule::type_infer::{ CanonicalType, WorkspaceTypeIndex, }; use crate::adapters::analyzers::architecture::call_parity_rule::workspace_graph::collect_local_symbols; -use crate::adapters::shared::use_tree::{gather_alias_map, ScopedAliasMap}; +use crate::adapters::shared::use_tree::{ + gather_alias_map, gather_alias_map_scoped, ScopedAliasMap, +}; use std::collections::{HashMap, HashSet}; const SESSION_PATH: &str = "crate::app::session::Session"; @@ -541,6 +543,63 @@ fn cast_as_self_resolves() { ); } +#[test] +fn aliased_stdlib_wrapper_inside_inline_mod_peels_to_inner() { + // Same renamed-Arc test, but the `use` statement lives inside an + // inline mod. Top-level `alias_map` doesn't see it; the scoped + // overlay does. Receiver resolution must consult the scoped + // overlay for wrapper-name promotion. + let fx = parse( + r#" + mod inner { + use std::sync::Arc as Shared; + use crate::app::session::Session; + pub fn handle(s: Shared) { + s.diff(); + } + } + "#, + ); + let f = find_fn_in_mod(&fx.file, "inner", "handle"); + let ctx = FnContext { + file: &FileScope { + path: "src/cli/handlers.rs", + alias_map: &fx.alias_map, + aliases_per_scope: &gather_alias_map_scoped(&fx.file), + local_symbols: &fx.local_symbols, + local_decl_scopes: &HashMap::new(), + crate_root_modules: &fx.crate_roots, + }, + mod_stack: &["inner".to_string()], + body: &f.block, + signature_params: sig_params(&f.sig), + self_type: None, + workspace_index: Some(&rlm_index()), + workspace_files: None, + }; + let calls = collect_canonical_calls(&ctx); + assert!( + calls.contains("crate::app::session::Session::diff"), + "scoped Arc-alias inside inline mod must peel to Session, got {calls:?}" + ); +} + +fn find_fn_in_mod<'a>(file: &'a syn::File, mod_name: &str, fn_name: &str) -> &'a syn::ItemFn { + file.items + .iter() + .find_map(|item| match item { + syn::Item::Mod(m) if m.ident == mod_name => m.content.as_ref(), + _ => None, + }) + .and_then(|(_, items)| { + items.iter().find_map(|i| match i { + syn::Item::Fn(f) if f.sig.ident == fn_name => Some(f), + _ => None, + }) + }) + .unwrap_or_else(|| panic!("fn {mod_name}::{fn_name} not found")) +} + #[test] fn aliased_stdlib_wrapper_peels_to_inner() { // `use std::sync::Arc as Shared;` then `fn h(s: Shared)` diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs index 620920e..542a68b 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs @@ -220,12 +220,17 @@ fn resolve_path(path: &syn::Path, ctx: &ResolveContext<'_>, depth: u8) -> Canoni let raw_name = last.ident.to_string(); // Promote `use std::sync::Arc as Shared`-style import aliases to // their canonical wrapper name so `Shared` matches the same - // arms as `Arc`. Direct-name hits and unrelated paths skip - // the alias lookup. - let alias_target = (!WRAPPER_NAMES.contains(&raw_name.as_str()) + // arms as `Arc`. Uses the scope-aware canonicaliser so + // imports inside inline mods (`mod inner { use … as Shared; }`) + // resolve too. Direct-name hits and unrelated paths skip the + // alias lookup. + let alias_resolved = (!WRAPPER_NAMES.contains(&raw_name.as_str()) && !is_user_transparent(&raw_name, ctx)) - .then(|| ctx.file.alias_map.get(&raw_name).and_then(|p| p.last())) + .then(|| { + canonicalise_type_segments_in_scope(std::slice::from_ref(&raw_name), &canon_scope(ctx)) + }) .flatten(); + let alias_target = alias_resolved.as_ref().and_then(|p| p.last()); let name = match alias_target { Some(seg) if WRAPPER_NAMES.contains(&seg.as_str()) || is_user_transparent(seg, ctx) => { seg.as_str() From c1bd0d38367720d1ba554af57ae7e98d42ba66a5 Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sun, 26 Apr 2026 01:58:16 +0200 Subject: [PATCH 26/30] fix: copilot comments --- .../call_parity_rule/pub_fns_visibility.rs | 27 +++++++--- .../call_parity_rule/tests/pub_fns.rs | 38 ++++++++++++++ .../call_parity_rule/tests/regressions.rs | 49 +++++++++++++++++++ .../call_parity_rule/tests/rlm_snapshot.rs | 4 +- .../call_parity_rule/type_infer/resolve.rs | 47 +++++++++++++----- .../type_infer/tests/patterns_destructure.rs | 3 +- 6 files changed, 144 insertions(+), 24 deletions(-) diff --git a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs index 0af6279..01d3b7e 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs @@ -12,6 +12,7 @@ use super::local_symbols::{collect_local_symbols_scoped, FileScope, LocalSymbols use super::pub_fns_alias_chain::{ chase_alias_chain, collect_alias_chain, resolve_alias_target_canonical, }; +use super::type_infer::resolve::is_stdlib_prefixed; use crate::adapters::analyzers::architecture::forbidden_rule::file_to_module_segments; use crate::adapters::shared::cfg_test::has_cfg_test; use crate::adapters::shared::use_tree::gather_alias_map_scoped; @@ -225,8 +226,16 @@ pub(super) fn peel_to_inner_path<'a>( /// caller can peel further. Renamed imports /// (`use std::sync::Arc as Shared;`) are followed through the /// scope-aware canonicaliser so `Shared` peels just like -/// `Arc`. Returns `None` for non-wrapper paths or wrappers -/// without a positional type arg. Operation. +/// `Arc`. Two safeguards on the aliased path: +/// - Single-segment paths only (`wrap::Shared` keeps its +/// `wrap::` prefix, so it isn't a bare-alias use-site). +/// - The alias canonical must start with `std`/`core`/`alloc` for +/// stdlib auto-peeling, so `use crate::wrap::Arc as Shared` +/// doesn't trick the resolver. User wrappers stay last-segment +/// based. +/// +/// Returns `None` for non-wrapper paths or wrappers without a +/// positional type arg. Operation. fn transparent_wrapper_inner<'a>( tp: &'a syn::TypePath, transparent_wrappers: &HashSet, @@ -234,13 +243,13 @@ fn transparent_wrapper_inner<'a>( mod_stack: &[String], ) -> Option<&'a syn::Type> { const STDLIB_TRANSPARENT: &[&str] = &["Box", "Arc", "Rc", "Cow"]; - let is_transparent_name = |name: &str| -> bool { - STDLIB_TRANSPARENT.contains(&name) || transparent_wrappers.contains(name) - }; + let is_user = |name: &str| transparent_wrappers.contains(name); + let is_stdlib_direct = |name: &str| STDLIB_TRANSPARENT.contains(&name); let last = tp.path.segments.last()?; let raw_name = last.ident.to_string(); - let direct = is_transparent_name(&raw_name); + let direct = is_stdlib_direct(&raw_name) || is_user(&raw_name); let aliased = !direct + && tp.path.segments.len() == 1 && canonicalise_type_segments_in_scope( std::slice::from_ref(&raw_name), &CanonScope { @@ -249,8 +258,10 @@ fn transparent_wrapper_inner<'a>( }, ) .as_ref() - .and_then(|p| p.last()) - .map(|seg| is_transparent_name(seg)) + .map(|p| { + let last_seg = p.last().map(String::as_str).unwrap_or(""); + (is_stdlib_prefixed(p) && is_stdlib_direct(last_seg)) || is_user(last_seg) + }) .unwrap_or(false); if !direct && !aliased { return None; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs index adc0a11..b3716e9 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs @@ -565,6 +565,44 @@ fn test_collect_pub_fns_records_impl_via_chained_type_alias() { ); } +#[test] +fn test_collect_pub_fns_does_not_promote_local_wrapper_alias() { + // `use crate::wrap::Arc as Shared;` aliases a *local* wrapper + // type — its canonical (`crate::wrap::Arc`) doesn't start with + // std/core/alloc, so the visibility pass must NOT auto-peel it + // when it appears in `pub type Public = Shared<…>`. Only stdlib + // wrappers are auto-peeled; user-configured wrappers stay + // last-segment based. + let file = parse( + r#" + use crate::wrap::Arc as Shared; + mod private { + pub struct Hidden; + impl Hidden { + pub fn op(&self) {} + } + } + pub type Public = Shared; + "#, + ); + let files = vec![("src/cli/handlers.rs", &file)]; + let by_layer = { + let aliases = aliases_from_files(&files); + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) + }; + let cli = names_for_layer(&by_layer, "cli"); + assert!( + !cli.contains("op"), + "local wrapper alias must not auto-peel as stdlib Arc, got {cli:?}" + ); +} + #[test] fn test_collect_pub_fns_records_impl_via_renamed_stdlib_wrapper() { // `use std::sync::Arc as Shared; pub type Public = Shared;` diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs index 886bd6d..ced3c8b 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs @@ -543,6 +543,55 @@ fn cast_as_self_resolves() { ); } +#[test] +fn qualified_path_does_not_alias_promote_through_leaf() { + // `use std::sync::Arc as Shared;` is in scope, but the use site + // is `wrap::Shared` — a *qualified* path. The leaf + // `Shared` matches the alias name, but the prefix `wrap::` makes + // the type unrelated. Receiver inference must NOT peel + // `wrap::Shared` to `Session::diff` just because the + // bare-`Shared` alias resolves to `Arc`. (Session is in scope + // here so alias-promotion would otherwise produce a real edge.) + let fx = parse( + r#" + use std::sync::Arc as Shared; + use crate::app::session::Session; + pub fn handle(s: wrap::Shared) { + s.diff(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "handle"); + assert!( + !calls.contains("crate::app::session::Session::diff"), + "qualified path must not be alias-promoted, got {calls:?}" + ); +} + +#[test] +fn aliased_local_wrapper_does_not_auto_peel() { + // `use crate::wrap::Arc as Shared;` aliases a *local* wrapper + // type to `Shared`. The local `crate::wrap::Arc` may not be + // Deref-transparent like stdlib Arc, so receiver inference must + // NOT auto-peel `Shared` just because the alias's leaf + // segment is `Arc`. Only when the alias canonical lives in + // `std`/`core`/`alloc` do we trust the auto-peel. + let fx = parse( + r#" + use crate::wrap::Arc as Shared; + use crate::app::session::Session; + pub fn handle(s: Shared) { + s.diff(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "handle"); + assert!( + !calls.contains("crate::app::session::Session::diff"), + "aliased local wrapper must not auto-peel as stdlib Arc, got {calls:?}" + ); +} + #[test] fn aliased_stdlib_wrapper_inside_inline_mod_peels_to_inner() { // Same renamed-Arc test, but the `use` statement lives inside an diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/rlm_snapshot.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/rlm_snapshot.rs index 8863961..80cf135 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/rlm_snapshot.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/rlm_snapshot.rs @@ -4,8 +4,8 @@ //! Sets up a mini multi-file workspace that mirrors rlm's session / //! handler pattern (the one the original bug report called out), runs //! the full Check A + Check B pipeline, and asserts the exact set of -//! surviving findings. The fixture is deliberately small (5 files, -//! ~40 lines of rlm-shaped source) but covers every `call_parity_rule` +//! surviving findings. The fixture is deliberately small (3 files, +//! ~50 lines of rlm-shaped source) but covers every `call_parity_rule` //! code path the rlm bug exercised: //! //! - CLI handlers that do `let session = RlmSession::open_cwd().map_err(f)?; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs index 542a68b..cba5574 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs @@ -197,10 +197,24 @@ fn is_marker_trait(path: &syn::Path) -> bool { /// dispatch in `resolve_path` and for the alias-aware lookup that /// promotes `use std::sync::Arc as Shared;`-imported names to their /// canonical wrapper. -const WRAPPER_NAMES: &[&str] = &[ +pub(super) const WRAPPER_NAMES: &[&str] = &[ "Result", "Option", "Future", "Vec", "HashMap", "BTreeMap", "Arc", "Box", "Rc", "Cow", ]; +/// True when the canonical path starts with `std`, `core`, or +/// `alloc` — the prefixes that distinguish a real stdlib import +/// (`use std::sync::Arc as Shared;`) from a local one with a +/// matching leaf (`use crate::wrap::Arc as Shared;`). Shared by the +/// receiver-type resolver (this module) and the visibility pass +/// (`pub_fns_visibility`) so both apply the same alias-promotion +/// rules. Operation. +pub(crate) fn is_stdlib_prefixed(canonical: &[String]) -> bool { + matches!( + canonical.first().map(String::as_str), + Some("std" | "core" | "alloc") + ) +} + /// Dispatch on the last path-segment's ident to recognise stdlib /// wrappers. Resolves `use std::sync::Arc as Shared;`-style import /// aliases first, so `Shared` peels just like `Arc`. Falls @@ -220,23 +234,32 @@ fn resolve_path(path: &syn::Path, ctx: &ResolveContext<'_>, depth: u8) -> Canoni let raw_name = last.ident.to_string(); // Promote `use std::sync::Arc as Shared`-style import aliases to // their canonical wrapper name so `Shared` matches the same - // arms as `Arc`. Uses the scope-aware canonicaliser so - // imports inside inline mods (`mod inner { use … as Shared; }`) - // resolve too. Direct-name hits and unrelated paths skip the - // alias lookup. - let alias_resolved = (!WRAPPER_NAMES.contains(&raw_name.as_str()) + // arms as `Arc`. Two safeguards: + // - Single-segment paths only — qualified `wrap::Shared` + // refers to a `Shared` declared inside `wrap`, not the + // bare-`Shared` alias from `use … as Shared`. + // - For stdlib wrappers the alias canonical must start with + // `std` / `core` / `alloc`, so `use crate::wrap::Arc as Shared` + // doesn't get auto-peeled. User-configured wrappers stay + // last-segment based — that's the opt-in. + let alias_resolved = (path.segments.len() == 1 + && !WRAPPER_NAMES.contains(&raw_name.as_str()) && !is_user_transparent(&raw_name, ctx)) .then(|| { canonicalise_type_segments_in_scope(std::slice::from_ref(&raw_name), &canon_scope(ctx)) }) .flatten(); - let alias_target = alias_resolved.as_ref().and_then(|p| p.last()); - let name = match alias_target { - Some(seg) if WRAPPER_NAMES.contains(&seg.as_str()) || is_user_transparent(seg, ctx) => { - seg.as_str() + let alias_target = alias_resolved.as_ref().and_then(|p| { + let last_seg = p.last()?; + let stdlib_prefixed = is_stdlib_prefixed(p); + let stdlib_match = stdlib_prefixed && WRAPPER_NAMES.contains(&last_seg.as_str()); + if stdlib_match || is_user_transparent(last_seg, ctx) { + Some(last_seg.as_str()) + } else { + None } - _ => raw_name.as_str(), - }; + }); + let name = alias_target.unwrap_or(raw_name.as_str()); match name { "Result" => wrap(0, CanonicalType::Result), "Option" => wrap(0, CanonicalType::Option), diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs index ce92558..550e396 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/tests/patterns_destructure.rs @@ -166,13 +166,12 @@ fn test_struct_pattern_with_aliased_field() { #[test] fn test_struct_pattern_missing_field_binds_opaque() { - let mut f = TypeInferFixture::new(); + let f = TypeInferFixture::new(); let matched = CanonicalType::path(["crate", "app", "Ctx"]); // No entry for "unknown" — binding is still made but with Opaque. let b = bindings(&f, "Ctx { unknown }", matched); assert_eq!(b.len(), 1); assert_eq!(b[0].1, CanonicalType::Opaque); - f.self_type = None; // silence unused mut warning } #[test] From ce1da45355fba7edd7e229aee2cdf124dc42f795 Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sun, 26 Apr 2026 02:20:50 +0200 Subject: [PATCH 27/30] fix: copilot comments --- .../call_parity_rule/pub_fns_visibility.rs | 92 +++++++++++-------- .../call_parity_rule/tests/pub_fns.rs | 36 ++++++++ .../call_parity_rule/tests/regressions.rs | 48 ++++++++++ .../call_parity_rule/type_infer/mod.rs | 1 + .../call_parity_rule/type_infer/resolve.rs | 42 +++------ .../type_infer/resolve_wrapper.rs | 53 +++++++++++ 6 files changed, 206 insertions(+), 66 deletions(-) create mode 100644 src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve_wrapper.rs diff --git a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs index 01d3b7e..48cc2ec 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs @@ -219,53 +219,30 @@ pub(super) fn peel_to_inner_path<'a>( } } -/// If `tp`'s last segment is a Deref-transparent wrapper — either a +/// If `tp` resolves to a Deref-transparent wrapper — either a /// stdlib one (`Box`/`Arc`/`Rc`/`Cow`) or a user-configured entry in -/// `[architecture.call_parity]::transparent_wrappers` (e.g. axum -/// `State`, actix `Data`) — return its first generic type arg so the -/// caller can peel further. Renamed imports -/// (`use std::sync::Arc as Shared;`) are followed through the -/// scope-aware canonicaliser so `Shared` peels just like -/// `Arc`. Two safeguards on the aliased path: -/// - Single-segment paths only (`wrap::Shared` keeps its -/// `wrap::` prefix, so it isn't a bare-alias use-site). -/// - The alias canonical must start with `std`/`core`/`alloc` for -/// stdlib auto-peeling, so `use crate::wrap::Arc as Shared` -/// doesn't trick the resolver. User wrappers stay last-segment -/// based. +/// `[architecture.call_parity]::transparent_wrappers` — return its +/// first generic type arg so the caller can peel further. The +/// resolution mirrors `resolve::resolve_path`'s wrapper detection: +/// - Single-segment bare wrapper names match directly. +/// - Explicit stdlib qualification (`std::boxed::Box`) matches. +/// - Aliased / qualified paths run through the scope-aware +/// canonicaliser; auto-peel only if the canonical is +/// stdlib-prefixed or the leaf is in the user-transparent set. /// -/// Returns `None` for non-wrapper paths or wrappers without a -/// positional type arg. Operation. +/// Multi-segment paths to local types named `Arc`/`Box`/etc. don't +/// peel — only stdlib-rooted forms do. Returns `None` for non-wrapper +/// paths or wrappers without a positional type arg. Operation. fn transparent_wrapper_inner<'a>( tp: &'a syn::TypePath, transparent_wrappers: &HashSet, file_scope: &FileScope<'_>, mod_stack: &[String], ) -> Option<&'a syn::Type> { - const STDLIB_TRANSPARENT: &[&str] = &["Box", "Arc", "Rc", "Cow"]; - let is_user = |name: &str| transparent_wrappers.contains(name); - let is_stdlib_direct = |name: &str| STDLIB_TRANSPARENT.contains(&name); - let last = tp.path.segments.last()?; - let raw_name = last.ident.to_string(); - let direct = is_stdlib_direct(&raw_name) || is_user(&raw_name); - let aliased = !direct - && tp.path.segments.len() == 1 - && canonicalise_type_segments_in_scope( - std::slice::from_ref(&raw_name), - &CanonScope { - file: file_scope, - mod_stack, - }, - ) - .as_ref() - .map(|p| { - let last_seg = p.last().map(String::as_str).unwrap_or(""); - (is_stdlib_prefixed(p) && is_stdlib_direct(last_seg)) || is_user(last_seg) - }) - .unwrap_or(false); - if !direct && !aliased { + if !is_transparent_wrapper(&tp.path, transparent_wrappers, file_scope, mod_stack) { return None; } + let last = tp.path.segments.last()?; let syn::PathArguments::AngleBracketed(ab) = &last.arguments else { return None; }; @@ -275,6 +252,47 @@ fn transparent_wrapper_inner<'a>( }) } +/// Decide whether `path` should be treated as a transparent wrapper +/// for visibility-pass peeling. Mirrors `resolve_path`'s wrapper +/// detection so the visibility set agrees with the receiver-type +/// resolver. Operation. +fn is_transparent_wrapper( + path: &syn::Path, + transparent_wrappers: &HashSet, + file_scope: &FileScope<'_>, + mod_stack: &[String], +) -> bool { + const STDLIB_TRANSPARENT: &[&str] = &["Box", "Arc", "Rc", "Cow"]; + let is_user = |name: &str| transparent_wrappers.contains(name); + let is_stdlib_direct = |name: &str| STDLIB_TRANSPARENT.contains(&name); + let Some(last) = path.segments.last() else { + return false; + }; + let raw_name = last.ident.to_string(); + let single = path.segments.len() == 1; + if single && (is_stdlib_direct(&raw_name) || is_user(&raw_name)) { + return true; + } + let first_seg = path.segments.first().map(|s| s.ident.to_string()); + let explicit_stdlib = matches!(first_seg.as_deref(), Some("std" | "core" | "alloc")); + if explicit_stdlib && is_stdlib_direct(&raw_name) { + return true; + } + let segs: Vec = path.segments.iter().map(|s| s.ident.to_string()).collect(); + let scope = CanonScope { + file: file_scope, + mod_stack, + }; + let Some(canonical) = canonicalise_type_segments_in_scope(&segs, &scope) else { + return false; + }; + let Some(last_seg) = canonical.last() else { + return false; + }; + let stdlib_match = is_stdlib_prefixed(&canonical) && is_stdlib_direct(last_seg); + stdlib_match || is_user(last_seg) +} + /// Build `crate::::::` joined as a /// single string — the canonical key both `visible_canonicals` and /// `resolve_impl_self_type` agree on. Operation: pure string assembly. diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs index b3716e9..f049e6e 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs @@ -565,6 +565,42 @@ fn test_collect_pub_fns_records_impl_via_chained_type_alias() { ); } +#[test] +fn test_collect_pub_fns_does_not_promote_qualified_local_arc() { + // `pub type Public = wrap::Arc;` — `wrap::Arc` + // is a *local* wrapper, not stdlib. Direct dispatch on the leaf + // `Arc` must NOT peel; otherwise Check B would require coverage + // for methods on `private::Hidden` that aren't actually exposed. + let file = parse( + r#" + mod wrap { pub struct Arc(T); } + mod private { + pub struct Hidden; + impl Hidden { + pub fn op(&self) {} + } + } + pub type Public = wrap::Arc; + "#, + ); + let files = vec![("src/cli/handlers.rs", &file)]; + let by_layer = { + let aliases = aliases_from_files(&files); + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) + }; + let cli = names_for_layer(&by_layer, "cli"); + assert!( + !cli.contains("op"), + "qualified local Arc must not auto-peel as stdlib Arc, got {cli:?}" + ); +} + #[test] fn test_collect_pub_fns_does_not_promote_local_wrapper_alias() { // `use crate::wrap::Arc as Shared;` aliases a *local* wrapper diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs index ced3c8b..b14ad7c 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs @@ -568,6 +568,54 @@ fn qualified_path_does_not_alias_promote_through_leaf() { ); } +#[test] +fn qualified_local_arc_does_not_auto_peel() { + // `wrap::Arc` where `wrap::Arc` is a *local* type that + // happens to be named `Arc`. Direct wrapper dispatch must NOT + // peel just because the leaf is `Arc`. Only stdlib-rooted + // qualifications (`std::sync::Arc`) auto-peel. + let fx = parse( + r#" + use crate::app::session::Session; + mod wrap { pub struct Arc(T); } + pub fn handle(s: wrap::Arc) { + s.diff(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "handle"); + assert!( + !calls.contains("crate::app::session::Session::diff"), + "qualified local Arc must not auto-peel as stdlib Arc, got {calls:?}" + ); +} + +#[test] +fn renamed_external_user_wrapper_peels_via_user_config() { + // `use axum::extract::State as ExtractState;` with + // `transparent_wrappers = ["State"]`. The alias resolves to + // `axum::extract::State` (external path), the last segment is + // "State", which IS in the user-transparent set → peel. Already + // works through the existing alias-resolution path; this test + // pins that. + let fx = parse( + r#" + use axum::extract::State as ExtractState; + use crate::app::session::Session; + pub fn handle(s: ExtractState) { + s.diff(); + } + "#, + ); + let mut index = rlm_index(); + index.transparent_wrappers.insert("State".to_string()); + let calls = run(&fx, &index, "handle"); + assert!( + calls.contains("crate::app::session::Session::diff"), + "renamed external user wrapper must peel, got {calls:?}" + ); +} + #[test] fn aliased_local_wrapper_does_not_auto_peel() { // `use crate::wrap::Arc as Shared;` aliases a *local* wrapper diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/mod.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/mod.rs index 73e83d4..23622ce 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/mod.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/mod.rs @@ -16,6 +16,7 @@ pub mod infer; pub mod patterns; pub mod resolve; mod resolve_alias; +mod resolve_wrapper; pub(crate) mod self_subst; pub mod workspace_index; diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs index cba5574..7900e58 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs @@ -22,6 +22,7 @@ use super::super::bindings::{canonicalise_type_segments_in_scope, CanonScope}; use super::super::local_symbols::FileScope; use super::canonical::CanonicalType; use super::resolve_alias::{expand_alias, lookup_alias_param}; +use super::resolve_wrapper::identify_wrapper_name; use std::collections::{HashMap, HashSet}; /// Resolution inputs. Per-file lookup tables live in `file`; the @@ -232,34 +233,17 @@ fn resolve_path(path: &syn::Path, ctx: &ResolveContext<'_>, depth: u8) -> Canoni let fallback = || resolve_generic_path(path, ctx, depth); let wrap_future = || wrap_future_output(args, ctx, depth); let raw_name = last.ident.to_string(); - // Promote `use std::sync::Arc as Shared`-style import aliases to - // their canonical wrapper name so `Shared` matches the same - // arms as `Arc`. Two safeguards: - // - Single-segment paths only — qualified `wrap::Shared` - // refers to a `Shared` declared inside `wrap`, not the - // bare-`Shared` alias from `use … as Shared`. - // - For stdlib wrappers the alias canonical must start with - // `std` / `core` / `alloc`, so `use crate::wrap::Arc as Shared` - // doesn't get auto-peeled. User-configured wrappers stay - // last-segment based — that's the opt-in. - let alias_resolved = (path.segments.len() == 1 - && !WRAPPER_NAMES.contains(&raw_name.as_str()) - && !is_user_transparent(&raw_name, ctx)) - .then(|| { - canonicalise_type_segments_in_scope(std::slice::from_ref(&raw_name), &canon_scope(ctx)) - }) - .flatten(); - let alias_target = alias_resolved.as_ref().and_then(|p| { - let last_seg = p.last()?; - let stdlib_prefixed = is_stdlib_prefixed(p); - let stdlib_match = stdlib_prefixed && WRAPPER_NAMES.contains(&last_seg.as_str()); - if stdlib_match || is_user_transparent(last_seg, ctx) { - Some(last_seg.as_str()) - } else { - None - } - }); - let name = alias_target.unwrap_or(raw_name.as_str()); + let resolved_name = identify_wrapper_name(path, &raw_name, ctx); + // For a single-segment path with an unrecognised name, fall back + // to the raw name (the match arms below test for stdlib idents). + // For a multi-segment path with no canonical wrapper match, + // skip the wrapper-arm match entirely — `wrap::Arc` must not + // be peeled as stdlib `Arc`. + let name = match (resolved_name.as_deref(), path.segments.len() == 1) { + (Some(s), _) => s, + (None, true) => raw_name.as_str(), + (None, false) => return fallback(), + }; match name { "Result" => wrap(0, CanonicalType::Result), "Option" => wrap(0, CanonicalType::Option), @@ -312,7 +296,7 @@ fn future_output_type(args: &syn::PathArguments) -> Option<&syn::Type> { /// Check if `name` is a user-configured transparent wrapper. /// Operation: set lookup with optional presence. -fn is_user_transparent(name: &str, ctx: &ResolveContext<'_>) -> bool { +pub(super) fn is_user_transparent(name: &str, ctx: &ResolveContext<'_>) -> bool { ctx.transparent_wrappers .is_some_and(|set| set.contains(name)) } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve_wrapper.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve_wrapper.rs new file mode 100644 index 0000000..962e5b0 --- /dev/null +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve_wrapper.rs @@ -0,0 +1,53 @@ +//! Wrapper-name decision for the type resolver. +//! +//! Splits cleanly from `resolve.rs` because the decision logic +//! (single-segment shortcut, explicit-stdlib shortcut, scoped +//! canonicalisation with stdlib-prefix verification) doesn't share +//! call edges with the rest of the resolver — it's a self-contained +//! "is this path a wrapper" probe. + +use super::super::bindings::{canonicalise_type_segments_in_scope, CanonScope}; +use super::resolve::{is_stdlib_prefixed, is_user_transparent, ResolveContext, WRAPPER_NAMES}; + +/// Decide the wrapper-arm name for `path`. Returns `Some(name)` when +/// the path should be dispatched as a wrapper, `None` otherwise. +/// Three resolution paths, each guarded: +/// - Single-segment bare wrapper (`Arc` after `use … Arc`): +/// fast path, no canonicalisation needed. +/// - Explicit stdlib qualification (`std::sync::Arc`, +/// `core::option::Option`): leaf is the wrapper name and the +/// prefix proves stdlib origin. +/// - Aliased / canonicalised paths (`Shared`, `wrap::Shared`): +/// run the scope-aware canonicaliser; auto-peel only if the +/// canonical is stdlib-prefixed and ends in a wrapper name, or +/// the leaf is in the user-transparent set. +/// +/// Operation. +pub(super) fn identify_wrapper_name( + path: &syn::Path, + raw_name: &str, + ctx: &ResolveContext<'_>, +) -> Option { + let single = path.segments.len() == 1; + if single && (WRAPPER_NAMES.contains(&raw_name) || is_user_transparent(raw_name, ctx)) { + return Some(raw_name.to_string()); + } + let first_seg = path.segments.first().map(|s| s.ident.to_string()); + let explicit_stdlib = matches!(first_seg.as_deref(), Some("std" | "core" | "alloc")); + if explicit_stdlib && WRAPPER_NAMES.contains(&raw_name) { + return Some(raw_name.to_string()); + } + let scope = CanonScope { + file: ctx.file, + mod_stack: ctx.mod_stack, + }; + let segs: Vec = path.segments.iter().map(|s| s.ident.to_string()).collect(); + let canonical = canonicalise_type_segments_in_scope(&segs, &scope)?; + let last_seg = canonical.last()?; + let stdlib_match = is_stdlib_prefixed(&canonical) && WRAPPER_NAMES.contains(&last_seg.as_str()); + if stdlib_match || is_user_transparent(last_seg, ctx) { + Some(last_seg.clone()) + } else { + None + } +} From 1d77e4e8dd19f6c48e8ba6027087a6ede84b0054 Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sun, 26 Apr 2026 02:32:17 +0200 Subject: [PATCH 28/30] fix: copilot comments --- .../call_parity_rule/pub_fns_visibility.rs | 39 +++++----- .../call_parity_rule/tests/pub_fns.rs | 73 +++++++++++++++++++ .../call_parity_rule/tests/regressions.rs | 46 ++++++++++++ .../call_parity_rule/type_infer/resolve.rs | 22 +++--- .../type_infer/resolve_wrapper.rs | 65 +++++++++++------ 5 files changed, 190 insertions(+), 55 deletions(-) diff --git a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs index 48cc2ec..825f544 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns_visibility.rs @@ -254,8 +254,9 @@ fn transparent_wrapper_inner<'a>( /// Decide whether `path` should be treated as a transparent wrapper /// for visibility-pass peeling. Mirrors `resolve_path`'s wrapper -/// detection so the visibility set agrees with the receiver-type -/// resolver. Operation. +/// detection (canonicalise-first, fallbacks for unresolvable paths) +/// so the visibility set agrees with the receiver-type resolver. +/// Operation. fn is_transparent_wrapper( path: &syn::Path, transparent_wrappers: &HashSet, @@ -269,28 +270,28 @@ fn is_transparent_wrapper( return false; }; let raw_name = last.ident.to_string(); + let scope = CanonScope { + file: file_scope, + mod_stack, + }; + let segs: Vec = path.segments.iter().map(|s| s.ident.to_string()).collect(); + if let Some(canonical) = canonicalise_type_segments_in_scope(&segs, &scope) { + let Some(last_seg) = canonical.last() else { + return false; + }; + let stdlib_match = is_stdlib_prefixed(&canonical) && is_stdlib_direct(last_seg); + return stdlib_match || is_user(last_seg); + } + if is_user(&raw_name) { + return true; + } let single = path.segments.len() == 1; - if single && (is_stdlib_direct(&raw_name) || is_user(&raw_name)) { + if single && is_stdlib_direct(&raw_name) { return true; } let first_seg = path.segments.first().map(|s| s.ident.to_string()); let explicit_stdlib = matches!(first_seg.as_deref(), Some("std" | "core" | "alloc")); - if explicit_stdlib && is_stdlib_direct(&raw_name) { - return true; - } - let segs: Vec = path.segments.iter().map(|s| s.ident.to_string()).collect(); - let scope = CanonScope { - file: file_scope, - mod_stack, - }; - let Some(canonical) = canonicalise_type_segments_in_scope(&segs, &scope) else { - return false; - }; - let Some(last_seg) = canonical.last() else { - return false; - }; - let stdlib_match = is_stdlib_prefixed(&canonical) && is_stdlib_direct(last_seg); - stdlib_match || is_user(last_seg) + explicit_stdlib && is_stdlib_direct(&raw_name) } /// Build `crate::::::` joined as a diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs index f049e6e..ab7bab9 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/pub_fns.rs @@ -565,6 +565,79 @@ fn test_collect_pub_fns_records_impl_via_chained_type_alias() { ); } +#[test] +fn test_collect_pub_fns_does_not_promote_bare_local_arc() { + // `use crate::wrap::Arc; pub type Public = Arc;` + // — bare `Arc` is shadowed by the local `use`. Visibility must + // canonicalise first and refuse to auto-peel local Arcs. + let file = parse( + r#" + mod wrap { pub struct Arc(T); } + use crate::wrap::Arc; + mod private { + pub struct Hidden; + impl Hidden { + pub fn op(&self) {} + } + } + pub type Public = Arc; + "#, + ); + let files = vec![("src/cli/handlers.rs", &file)]; + let by_layer = { + let aliases = aliases_from_files(&files); + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &HashSet::new(), + ) + }; + let cli = names_for_layer(&by_layer, "cli"); + assert!( + !cli.contains("op"), + "bare Arc shadowed by local must not auto-peel, got {cli:?}" + ); +} + +#[test] +fn test_collect_pub_fns_peels_qualified_user_wrapper() { + // `pub type Public = axum::extract::State;` with + // `transparent_wrappers = ["State"]`. External `axum::*` paths + // can't be canonicalised, so the visibility pass must fall back + // to last-segment matching for user-transparent wrappers. + let file = parse( + r#" + mod private { + pub struct Hidden; + impl Hidden { + pub fn op(&self) {} + } + } + pub type Public = axum::extract::State; + "#, + ); + let files = vec![("src/cli/handlers.rs", &file)]; + let mut wrappers = HashSet::new(); + wrappers.insert("State".to_string()); + let by_layer = { + let aliases = aliases_from_files(&files); + collect_pub_fns_by_layer( + &files, + &aliases, + &adapter_layers(), + &HashSet::new(), + &wrappers, + ) + }; + let cli = names_for_layer(&by_layer, "cli"); + assert!( + cli.contains("op"), + "fully-qualified user wrapper must peel via leaf, got {cli:?}" + ); +} + #[test] fn test_collect_pub_fns_does_not_promote_qualified_local_arc() { // `pub type Public = wrap::Arc;` — `wrap::Arc` diff --git a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs index b14ad7c..6ea4a00 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/tests/regressions.rs @@ -590,6 +590,52 @@ fn qualified_local_arc_does_not_auto_peel() { ); } +#[test] +fn bare_local_arc_does_not_auto_peel() { + // `use crate::wrap::Arc;` then `s: Arc` — `Arc` is + // single-segment and matches the stdlib wrapper list, but the + // active `use` resolves it to a *local* type. The bare-name + // fast path must canonicalise first and skip auto-peeling for + // non-stdlib targets. + let fx = parse( + r#" + use crate::wrap::Arc; + use crate::app::session::Session; + pub fn handle(s: Arc) { + s.diff(); + } + "#, + ); + let calls = run(&fx, &rlm_index(), "handle"); + assert!( + !calls.contains("crate::app::session::Session::diff"), + "bare Arc shadowed by local must not auto-peel, got {calls:?}" + ); +} + +#[test] +fn fully_qualified_user_wrapper_peels_via_leaf_match() { + // `axum::extract::State` with + // `transparent_wrappers = ["State"]`. The canonicaliser can't + // resolve external `axum::*` paths, so the user-transparent + // matching must fall back to the leaf segment. + let fx = parse( + r#" + use crate::app::session::Session; + pub fn handle(s: axum::extract::State) { + s.diff(); + } + "#, + ); + let mut index = rlm_index(); + index.transparent_wrappers.insert("State".to_string()); + let calls = run(&fx, &index, "handle"); + assert!( + calls.contains("crate::app::session::Session::diff"), + "fully-qualified user wrapper must peel via leaf-name, got {calls:?}" + ); +} + #[test] fn renamed_external_user_wrapper_peels_via_user_config() { // `use axum::extract::State as ExtractState;` with diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs index 7900e58..f4cfef8 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve.rs @@ -233,18 +233,16 @@ fn resolve_path(path: &syn::Path, ctx: &ResolveContext<'_>, depth: u8) -> Canoni let fallback = || resolve_generic_path(path, ctx, depth); let wrap_future = || wrap_future_output(args, ctx, depth); let raw_name = last.ident.to_string(); - let resolved_name = identify_wrapper_name(path, &raw_name, ctx); - // For a single-segment path with an unrecognised name, fall back - // to the raw name (the match arms below test for stdlib idents). - // For a multi-segment path with no canonical wrapper match, - // skip the wrapper-arm match entirely — `wrap::Arc` must not - // be peeled as stdlib `Arc`. - let name = match (resolved_name.as_deref(), path.segments.len() == 1) { - (Some(s), _) => s, - (None, true) => raw_name.as_str(), - (None, false) => return fallback(), + // `identify_wrapper_name` is authoritative: it considers the + // canonical resolution (catching shadow cases like + // `use crate::wrap::Arc;`), explicit-stdlib qualification, and + // user-transparent leaf matches. When it returns `None`, the + // path isn't a wrapper — drop straight into the regular + // canonicalisation pipeline. + let Some(name) = identify_wrapper_name(path, &raw_name, ctx) else { + return fallback(); }; - match name { + match name.as_str() { "Result" => wrap(0, CanonicalType::Result), "Option" => wrap(0, CanonicalType::Option), // Future uses `Output = T` associated-type syntax, not a @@ -260,7 +258,7 @@ fn resolve_path(path: &syn::Path, ctx: &ResolveContext<'_>, depth: u8) -> Canoni // type. Users can opt back in via `transparent_wrappers` for // domain-specific deref-like wrappers. "Arc" | "Box" | "Rc" | "Cow" => peel(), - _ if is_user_transparent(name, ctx) => peel(), + _ if is_user_transparent(&name, ctx) => peel(), _ => fallback(), } } diff --git a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve_wrapper.rs b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve_wrapper.rs index 962e5b0..794af82 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve_wrapper.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/type_infer/resolve_wrapper.rs @@ -11,16 +11,27 @@ use super::resolve::{is_stdlib_prefixed, is_user_transparent, ResolveContext, WR /// Decide the wrapper-arm name for `path`. Returns `Some(name)` when /// the path should be dispatched as a wrapper, `None` otherwise. -/// Three resolution paths, each guarded: -/// - Single-segment bare wrapper (`Arc` after `use … Arc`): -/// fast path, no canonicalisation needed. -/// - Explicit stdlib qualification (`std::sync::Arc`, -/// `core::option::Option`): leaf is the wrapper name and the -/// prefix proves stdlib origin. -/// - Aliased / canonicalised paths (`Shared`, `wrap::Shared`): -/// run the scope-aware canonicaliser; auto-peel only if the -/// canonical is stdlib-prefixed and ends in a wrapper name, or -/// the leaf is in the user-transparent set. +/// +/// Resolution flow (canonicalise-first, fallbacks for unresolvable +/// paths): +/// 1. Full canonicalisation. When the path resolves through the +/// alias / local-symbol / crate-root pipeline, the canonical +/// authoritatively decides: stdlib-prefixed + wrapper leaf, or +/// user-transparent leaf → wrapper. Anything else → not a +/// wrapper. This catches the shadow case +/// (`use crate::wrap::Arc;` then `Arc`) — the canonical +/// points to the local type, not stdlib. +/// 2. User-transparent leaf-name match. The user opts into "any +/// path ending in `State` is transparent", so external-crate +/// forms like `axum::extract::State` (which the +/// canonicaliser can't reach) still peel. +/// 3. Bare wrapper convention. `Result`, `Option`, +/// `Arc` without an active `use` (or with the standard +/// stdlib `use`) work as expected — the canonicaliser fails +/// cleanly in those cases. +/// 4. Explicit stdlib qualification (`std::sync::Arc`, +/// `core::option::Option`) for callers that fully qualify +/// without aliasing. /// /// Operation. pub(super) fn identify_wrapper_name( @@ -28,8 +39,26 @@ pub(super) fn identify_wrapper_name( raw_name: &str, ctx: &ResolveContext<'_>, ) -> Option { + let scope = CanonScope { + file: ctx.file, + mod_stack: ctx.mod_stack, + }; + let segs: Vec = path.segments.iter().map(|s| s.ident.to_string()).collect(); + if let Some(canonical) = canonicalise_type_segments_in_scope(&segs, &scope) { + let last_seg = canonical.last()?; + let stdlib_match = + is_stdlib_prefixed(&canonical) && WRAPPER_NAMES.contains(&last_seg.as_str()); + return if stdlib_match || is_user_transparent(last_seg, ctx) { + Some(last_seg.clone()) + } else { + None + }; + } + if is_user_transparent(raw_name, ctx) { + return Some(raw_name.to_string()); + } let single = path.segments.len() == 1; - if single && (WRAPPER_NAMES.contains(&raw_name) || is_user_transparent(raw_name, ctx)) { + if single && WRAPPER_NAMES.contains(&raw_name) { return Some(raw_name.to_string()); } let first_seg = path.segments.first().map(|s| s.ident.to_string()); @@ -37,17 +66,5 @@ pub(super) fn identify_wrapper_name( if explicit_stdlib && WRAPPER_NAMES.contains(&raw_name) { return Some(raw_name.to_string()); } - let scope = CanonScope { - file: ctx.file, - mod_stack: ctx.mod_stack, - }; - let segs: Vec = path.segments.iter().map(|s| s.ident.to_string()).collect(); - let canonical = canonicalise_type_segments_in_scope(&segs, &scope)?; - let last_seg = canonical.last()?; - let stdlib_match = is_stdlib_prefixed(&canonical) && WRAPPER_NAMES.contains(&last_seg.as_str()); - if stdlib_match || is_user_transparent(last_seg, ctx) { - Some(last_seg.clone()) - } else { - None - } + None } From 2352307c7690e9d83097cb61f529d7159f149a3d Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sun, 26 Apr 2026 02:41:16 +0200 Subject: [PATCH 29/30] fix: copilot comments --- src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs index f62adb0..9928bc3 100644 --- a/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs +++ b/src/adapters/analyzers/architecture/call_parity_rule/pub_fns.rs @@ -25,7 +25,6 @@ use super::workspace_graph::{collect_crate_root_modules, resolve_impl_self_type} use crate::adapters::analyzers::architecture::layer_rule::LayerDefinitions; use crate::adapters::shared::cfg_test::{has_cfg_test, has_test_attr}; use crate::adapters::shared::use_tree::gather_alias_map_scoped; -use crate::adapters::shared::use_tree::ScopedAliasMap; use std::collections::{HashMap, HashSet}; use syn::visit::Visit; From ff6ab331deb538294d6faf923ff54d4e4c3a0fc3 Mon Sep 17 00:00:00 2001 From: SaschaBa <18143567+SaschaOnTour@users.noreply.github.com> Date: Sun, 26 Apr 2026 10:48:16 +0200 Subject: [PATCH 30/30] fix: make readme more readable --- README.md | 1415 +++--------------------------- book/adapter-parity.md | 157 ++++ book/ai-coding-workflow.md | 115 +++ book/architecture-rules.md | 212 +++++ book/ci-integration.md | 156 ++++ book/code-reuse.md | 173 ++++ book/coupling-quality.md | 102 +++ book/function-quality.md | 163 ++++ book/getting-started.md | 98 +++ book/legacy-adoption.md | 153 ++++ book/module-quality.md | 142 +++ book/reference-cli.md | 108 +++ book/reference-configuration.md | 297 +++++++ book/reference-output-formats.md | 156 ++++ book/reference-rules.md | 126 +++ book/reference-suppression.md | 165 ++++ book/test-quality.md | 154 ++++ 17 files changed, 2615 insertions(+), 1277 deletions(-) create mode 100644 book/adapter-parity.md create mode 100644 book/ai-coding-workflow.md create mode 100644 book/architecture-rules.md create mode 100644 book/ci-integration.md create mode 100644 book/code-reuse.md create mode 100644 book/coupling-quality.md create mode 100644 book/function-quality.md create mode 100644 book/getting-started.md create mode 100644 book/legacy-adoption.md create mode 100644 book/module-quality.md create mode 100644 book/reference-cli.md create mode 100644 book/reference-configuration.md create mode 100644 book/reference-output-formats.md create mode 100644 book/reference-rules.md create mode 100644 book/reference-suppression.md create mode 100644 book/test-quality.md diff --git a/README.md b/README.md index fbf17d9..540bd07 100644 --- a/README.md +++ b/README.md @@ -4,1331 +4,209 @@ [![crates.io](https://img.shields.io/crates/v/rustqual.svg)](https://crates.io/crates/rustqual) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) -Comprehensive Rust code quality analyzer — six dimensions: Complexity, Coupling, DRY, IOSP, SRP, Test Quality — plus 7 structural binary checks integrated into SRP and Coupling. Particularly useful as a structural quality guardrail for AI-generated code, catching the god-functions, mixed concerns, duplicated patterns, and weak tests that AI coding agents commonly produce. +**A structural quality guardrail for Rust — with AI coding agents specifically in mind.** rustqual scores your code across seven dimensions (IOSP, Complexity, DRY, SRP, Coupling, Test Quality, Architecture) and combines them into one quality number. Equally useful for senior teams enforcing architecture in CI. -## Quality Dimensions +What sets it apart from clippy and other Rust linters: rustqual reasons across files and modules, not just within functions. Its architecture rules and call-parity check verify properties that span an entire codebase, which is where most real drift happens. -rustqual analyzes your Rust code across **seven quality dimensions**, each contributing to an overall quality score: +It catches what AI agents consistently produce and what tired humans consistently miss: god-functions that mix orchestration with logic, copy-paste-with-variation, tests without assertions, architectural drift across adapter layers (CLI, MCP, REST, …), and dead code that piles up after a refactor pivot. -| Dimension | Weight | What it checks | -|--------------|--------|----------------| -| IOSP | 22% | Function separation (Integration vs Operation) | -| Complexity | 18% | Cognitive/cyclomatic complexity, magic numbers, nesting depth, function length, unsafe blocks, error handling | -| DRY | 13% | Duplicate functions, fragments, dead code, boilerplate | -| SRP | 18% | Struct cohesion (LCOM4), module length, function clusters, structural checks (BTC, SLM, NMS) | -| Coupling | 9% | Module instability, circular dependencies, SDP, structural checks (OI, SIT, DEH, IET) | -| Test Quality | 10% | Assertion density, no-SUT tests, untested functions, coverage gaps, untested logic | -| Architecture | 10% | Layer ordering, forbidden-edge rules, symbol patterns (path/method/function/macro/derive/item-kind), trait-signature contracts | - -## What is IOSP? - -The **Integration Operation Segregation Principle** (from Ralf Westphal's *Flow Design*) states that every function should be **either**: - -- **Integration** — orchestrates other functions, contains no logic of its own -- **Operation** — contains logic (control flow, computation), but does not call other "own" functions - -A function that does **both** is a **violation**. A function too small to matter (empty body, single expression without logic or own calls) is classified as **Trivial**. - -``` -┌─────────────┐ ┌─────────────┐ ┌────────────────────┐ -│ Integration │ │ Operation │ │ ✗ Violation │ -│ │ │ │ │ │ -│ calls A() │ │ if x > 0 │ │ if x > 0 │ -│ calls B() │ │ y = x*2 │ │ result = calc() │ ← mixes both -│ calls C() │ │ return y │ │ return result + 1 │ -└─────────────┘ └─────────────┘ └────────────────────┘ -``` - -## Installation - -```bash -# From crates.io -cargo install rustqual - -# From source -cargo install --path . - -# Then use either: -rustqual # direct invocation (defaults to .) -cargo qual # as cargo subcommand -``` - -## Quick Start +## What it looks like ```bash -# Analyze current directory (default — matches architecture-rule globs) -rustqual - -# Analyze a specific file or directory -rustqual src/lib.rs +$ cargo install rustqual +$ rustqual . -# Show all functions, not just findings -rustqual --verbose - -# Do not exit with code 1 on findings (for local exploration) -rustqual --no-fail - -# Generate a default config file -rustqual --init - -# Watch mode: re-analyze on file changes -rustqual --watch src/ -``` - -> **Using AI coding agents?** See [Using with AI Coding Agents](#using-with-ai-coding-agents) for integration patterns with Claude Code, Cursor, Copilot, and other tools. - -## Output Formats - -### Text (default) - -```bash -rustqual src/ --verbose -``` - -``` ── src/order.rs ✓ INTEGRATION process_order (line 12) ✓ OPERATION calculate_discount (line 28) - Complexity: logic=2, calls=0, nesting=1, cognitive=2, cyclomatic=3 ✗ VIOLATION process_payment (line 48) [MEDIUM] - Logic: if (line 50), comparison (line 50), if (line 56) - Calls: determine_payment_method (line 55), charge_credit_card (line 59) - Complexity: logic=3, calls=2, nesting=1, cognitive=5, cyclomatic=4 - · TRIVIAL get_name (line 72) - ~ SUPPRESSED legacy_handler (line 85) ═══ Summary ═══ Functions: 24 Quality Score: 82.3% - IOSP: 85.7% (4I, 8O, 10T, 2 violations) - Complexity: 90.0% (3 complexity, 1 magic numbers) - DRY: 95.0% (1 duplicates, 2 dead code) + IOSP: 85.7% + Complexity: 90.0% + DRY: 95.0% SRP: 100.0% Test Quality: 100.0% Coupling: 100.0% - - ~ Suppressed: 1 + Architecture: 100.0% 4 quality findings. Run with --verbose for details. ``` -### JSON - -```bash -rustqual --json -# or -rustqual --format json -``` - -Produces machine-readable output with `summary`, `functions`, `coupling`, `duplicates`, `dead_code`, `fragments`, `boilerplate`, and `srp` sections: - -```json -{ - "summary": { - "total": 24, - "integrations": 4, - "operations": 8, - "violations": 2, - "trivial": 10, - "suppressed": 1, - "iosp_score": 0.857, - "quality_score": 0.823, - "coupling_warnings": 0, - "coupling_cycles": 0, - "duplicate_groups": 0, - "dead_code_warnings": 0, - "fragment_groups": 0, - "boilerplate_warnings": 0, - "srp_struct_warnings": 0, - "srp_module_warnings": 0, - "suppression_ratio_exceeded": false - }, - "functions": [...] -} -``` - -### GitHub Actions Annotations - -```bash -rustqual --format github -``` - -Produces `::warning`, `::error`, and `::notice` annotations that GitHub Actions renders inline on PRs: - -``` -::warning file=src/order.rs,line=48::IOSP violation in process_payment: logic=[if (line 50)], calls=[determine_payment_method (line 55)] -::error::Quality analysis: 2 violation(s), 82.3% quality score -``` - -### DOT (Graphviz) - -```bash -rustqual --format dot > call-graph.dot -dot -Tsvg call-graph.dot -o call-graph.svg -``` - -Generates a call-graph visualization with color-coded nodes: -- Green: Integration -- Blue: Operation -- Red: Violation -- Gray: Trivial - -### SARIF - -```bash -rustqual --format sarif > report.sarif -``` - -Produces [SARIF v2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) output for integration with GitHub Code Scanning, VS Code SARIF Viewer, and other static analysis platforms. Includes rules for all dimensions (IOSP, complexity, coupling, DRY, SRP, test quality). - -### HTML - -```bash -rustqual --format html > report.html -``` - -Generates a self-contained HTML report with: -- Dashboard showing overall quality score and 6 dimension scores -- Collapsible detail sections for IOSP, Complexity, DRY, SRP, Test Quality, and Coupling findings -- Color-coded severity indicators and inline CSS (no external dependencies) - -## CLI Reference - -``` -rustqual [OPTIONS] [PATH] -``` - -| Argument / Flag | Description | -|---------------------------------|----------------------------------------------------------------------| -| `PATH` | File or directory to analyze. Defaults to `.` | -| `-v, --verbose` | Show all functions, not just findings | -| `--json` | Output as JSON (shorthand for `--format json`) | -| `--format ` | Output format: `text`, `json`, `github`, `dot`, `sarif`, `html` | -| `-c, --config ` | Path to config file. Defaults to auto-discovered `rustqual.toml` | -| `--strict-closures` | Treat closures as logic (stricter analysis) | -| `--strict-iterators` | Treat iterator chains (`.map`, `.filter`, ...) as logic | -| `--allow-recursion` | Don't count recursive calls as violations | -| `--strict-error-propagation` | Count `?` operator as logic (implicit control flow) | -| `--no-fail` | Do not exit with code 1 on quality findings (local exploration) | -| `--fail-on-warnings` | Treat warnings (e.g. suppression ratio exceeded) as errors (exit 1) | -| `--init` | Generate a tailored `rustqual.toml` based on current codebase metrics | -| `--completions ` | Generate shell completions (bash, zsh, fish, elvish, powershell) | -| `--save-baseline ` | Save current results as a JSON baseline | -| `--compare ` | Compare current results against a saved baseline | -| `--fail-on-regression` | Exit with code 1 if quality score regressed vs baseline | -| `--watch` | Watch for file changes and re-analyze continuously | -| `--suggestions` | Show refactoring suggestions for IOSP violations | -| `--sort-by-effort` | Sort violations by refactoring effort score (descending) | -| `--findings` | Show only findings with `file:line` locations (one per line) | -| `--min-quality-score ` | Exit with code 1 if quality score is below threshold (0–100) | -| `--diff [REF]` | Only analyze files changed vs a git ref (default: HEAD) | -| `--coverage ` | Path to LCOV coverage file for test quality analysis (TQ-005) | -| `--explain ` | Architecture dimension: print layer assignment, classified imports, and active rules for one file | - -### Exit Codes - -| Code | Meaning | -|------|---------------------------------------------| -| `0` | Success (no findings, or `--no-fail` set) | -| `1` | Quality findings found (default), regression detected (`--fail-on-regression`), quality gate breached (`--min-quality-score`), or warnings present with `--fail-on-warnings` | -| `2` | Configuration error (invalid or unreadable config file) | - -## Configuration - -The analyzer auto-discovers `rustqual.toml` by searching from the analysis path upward through parent directories. You can also specify a config explicitly with `--config`. Generate a commented default config with `--init`. - -If a `rustqual.toml` exists but cannot be parsed (syntax errors, unknown fields), the analyzer exits with code 2 and an error message instead of silently falling back to defaults. - -### Full `rustqual.toml` Reference - -```toml -# ──────────────────────────────────────────────────────────────── -# Ignore Functions -# ──────────────────────────────────────────────────────────────── -# Functions matching these patterns are completely excluded from analysis. -# Supports full glob syntax: *, ?, [abc], [!abc] -ignore_functions = [ - "main", # entry point, always mixes logic + calls - "run", # composition-root dispatcher - "visit_*", # syn::Visit trait implementations (external dispatch) -] - -# ──────────────────────────────────────────────────────────────── -# Exclude Files -# ──────────────────────────────────────────────────────────────── -# Glob patterns for files to exclude from analysis entirely. -exclude_files = ["examples/**"] # e.g. fixture crates for rule demos - -# ──────────────────────────────────────────────────────────────── -# Strictness -# ──────────────────────────────────────────────────────────────── -strict_closures = false # If true, closures count as logic -strict_iterator_chains = false # If true, iterator chains count as own calls -allow_recursion = false # If true, recursive calls don't violate IOSP -strict_error_propagation = false # If true, ? operator counts as logic - -# ──────────────────────────────────────────────────────────────── -# Suppression Ratio -# ──────────────────────────────────────────────────────────────── -# Maximum fraction of functions that may be suppressed (0.0–1.0). -# Exceeding this ratio produces a warning. -max_suppression_ratio = 0.05 - -# If true, exit with code 1 when warnings are present (e.g. suppression ratio exceeded). -# Default: false. Use --fail-on-warnings CLI flag to enable. -fail_on_warnings = false - -# ──────────────────────────────────────────────────────────────── -# Complexity Analysis -# ──────────────────────────────────────────────────────────────── -[complexity] -enabled = true -max_cognitive = 15 # Cognitive complexity threshold -max_cyclomatic = 10 # Cyclomatic complexity threshold -max_nesting_depth = 4 # Maximum nesting depth before warning -max_function_lines = 60 # Maximum function body lines before warning -detect_magic_numbers = true # Flag numeric literals not in allowed list -allowed_magic_numbers = ["0", "1", "-1", "2", "0.0", "1.0"] -detect_unsafe = true # Flag functions containing unsafe blocks -detect_error_handling = true # Flag unwrap/expect/panic/todo usage -allow_expect = false # If true, .expect() calls don't trigger warnings - -# ──────────────────────────────────────────────────────────────── -# Coupling Analysis -# ──────────────────────────────────────────────────────────────── -[coupling] -enabled = true -max_instability = 0.8 # Instability threshold (Ce / (Ca + Ce)) -max_fan_in = 15 # Maximum afferent coupling -max_fan_out = 12 # Maximum efferent coupling -check_sdp = true # Check Stable Dependencies Principle - -# ──────────────────────────────────────────────────────────────── -# DRY / Duplicate Detection -# ──────────────────────────────────────────────────────────────── -[duplicates] -enabled = true -min_tokens = 50 # Minimum token count for duplicate detection -min_lines = 5 # Minimum line count -min_statements = 3 # Minimum statements for fragment detection -similarity_threshold = 0.85 # Jaccard similarity for near-duplicates -ignore_tests = true # Skip test functions -detect_dead_code = true # Enable dead code detection -detect_wildcard_imports = true # Flag use foo::* imports -detect_repeated_matches = true # Flag repeated match blocks (DRY-005) - -# ──────────────────────────────────────────────────────────────── -# Boilerplate Detection -# ──────────────────────────────────────────────────────────────── -[boilerplate] -enabled = true -suggest_crates = true # Suggest derive macros / crates -patterns = [ # Which patterns to check (BP-001 through BP-010) - "BP-001", "BP-002", "BP-003", "BP-004", "BP-005", - "BP-006", "BP-007", "BP-008", "BP-009", "BP-010", -] - -# ──────────────────────────────────────────────────────────────── -# SRP Analysis -# ──────────────────────────────────────────────────────────────── -[srp] -enabled = true -smell_threshold = 0.6 # Composite score threshold for warnings -max_fields = 12 # Maximum struct fields -max_methods = 15 # Maximum impl methods -max_fan_out = 10 # Maximum external call targets -max_parameters = 5 # Maximum function parameters (AST-based) -lcom4_threshold = 3 # LCOM4 component threshold -weights = [0.4, 0.25, 0.15, 0.2] # [lcom4, fields, methods, fan_out] -file_length_baseline = 300 # Production lines before penalty starts -file_length_ceiling = 800 # Production lines at maximum penalty -max_independent_clusters = 2 # Highest allowed (warn on 3+ clusters) -min_cluster_statements = 5 # Min statements for a function to count in clusters - -# ──────────────────────────────────────────────────────────────── -# Structural Binary Checks -# ──────────────────────────────────────────────────────────────── -[structural] -enabled = true -check_btc = true # Broken Trait Contract (SRP) -check_slm = true # Self-less Methods (SRP) -check_nms = true # Needless &mut self (SRP) -check_oi = true # Orphaned Impl (Coupling) -check_sit = true # Single-Impl Trait (Coupling) -check_deh = true # Downcast Escape Hatch (Coupling) -check_iet = true # Inconsistent Error Types (Coupling) - -# ──────────────────────────────────────────────────────────────── -# Test Quality Analysis -# ──────────────────────────────────────────────────────────────── -[test_quality] -enabled = true -coverage_file = "" # Path to LCOV file (or use --coverage CLI flag) -# Extra macro names (beyond assert*/debug_assert*) to recognize as assertions in TQ-001 -# extra_assertion_macros = ["verify", "check", "expect_that"] - -# ──────────────────────────────────────────────────────────────── -# Quality Weights -# ──────────────────────────────────────────────────────────────── -[weights] -iosp = 0.22 -complexity = 0.18 -dry = 0.13 -srp = 0.18 -coupling = 0.09 -test_quality = 0.10 -architecture = 0.10 -# Weights must sum to 1.0 - -# ──────────────────────────────────────────────────────────────── -# Architecture Dimension (see "Architecture Dimension" section for details) -# ──────────────────────────────────────────────────────────────── -[architecture] -enabled = true - -[architecture.layers] -order = ["domain", "port", "infrastructure", "analysis", "application"] -unmatched_behavior = "strict_error" # or "composition_root" - -[architecture.layers.domain] -paths = ["src/domain/**"] - -[architecture.layers.port] -paths = ["src/ports/**"] - -[architecture.layers.infrastructure] -paths = [ - "src/adapters/config/**", - "src/adapters/source/**", - "src/adapters/suppression/**", -] - -[architecture.layers.analysis] -paths = [ - "src/adapters/analyzers/**", - "src/adapters/shared/**", - "src/adapters/report/**", -] - -[architecture.layers.application] -paths = ["src/app/**"] - -[architecture.reexport_points] -paths = [ - "src/lib.rs", - "src/main.rs", - "src/adapters/mod.rs", - "src/bin/**", - "src/cli/**", - "tests/**", -] - -# Optional: map external crate names to your own layers (for workspaces) -[architecture.external_crates] -# "my_domain_crate" = "domain" -# "my_infra_crate" = "infrastructure" - -# Forbidden edges (cross-branch imports the layer rule permits but you don't want) -[[architecture.forbidden]] -from = "src/adapters/analyzers/**" -to = "src/adapters/report/**" -reason = "Analyzers produce findings; reporters consume them separately" - -# Symbol patterns (see "Architecture Dimension" below for all 7 matcher types) -[[architecture.pattern]] -name = "no_panic_helpers_in_production" -forbid_method_call = ["unwrap", "expect"] -forbidden_in = ["src/**"] -except = ["**/tests/**"] -reason = "Production propagates errors through Result" - -[[architecture.pattern]] -name = "no_syn_in_domain" -forbid_path_prefix = ["syn::", "proc_macro2::", "quote::"] -forbidden_in = ["src/domain/**"] -reason = "Domain types know no AST representation" - -# Trait-signature rule (port contract) -[[architecture.trait_contract]] -name = "port_traits" -scope = "src/ports/**" -receiver_may_be = ["shared_ref"] -forbidden_return_type_contains = ["anyhow::", "Box Result> { - // ... -} - -// qual:api -pub fn decode(data: &[u8], config: &Config) -> Result> { - // ... -} -``` - -Unlike `// qual:allow`, API markers do **not** count against the suppression ratio. Use `// qual:api` for functions that are part of your library's public interface — they have no callers within the project because they're meant to be called by external consumers. - -### Test-Helper Annotation - -Mark integration-test helpers with `// qual:test_helper` to exclude them from dead code (DRY-002 `testonly`) and untested function (TQ-003) detection, **while keeping every other check active**: - -```rust -// qual:test_helper -pub fn assert_in_range(actual: f64, expected: f64, tolerance: f64) { - assert!((actual - expected).abs() < tolerance); -} -``` - -This is the narrow fix for the „helper called from `tests/*.rs` but not from production" case that used to force a choice between `ignore_functions` (which silently disables **every** check for that function) and a `qual:allow(dry)` + `qual:allow(test_quality)` stack (which costs against the suppression ratio). Semantic distinction from `qual:api`: - -| Marker | Intent | What it suppresses | -|---|---|---| -| `// qual:api` | „this is the public library API" | DRY-002 (`testonly` dead code) + TQ-003 (untested) | -| `// qual:test_helper` | „this exists so test binaries can call into it" | DRY-002 `testonly` dead code + TQ-003 (untested) | - -Neither marker counts against `max_suppression_ratio`. Complexity, SRP, duplicate detection, and coupling checks keep applying — if a test helper grows to 200 lines with nested match arms, `LONG_FN` and `COGNITIVE` will still flag it. - -### Inverse Annotation - -Mark inverse method pairs with `// qual:inverse(fn_name)` to suppress near-duplicate DRY findings between them: - -```rust -// qual:inverse(parse) -pub fn as_str(&self) -> &str { - match self { - Self::Function => "fn", - Self::Method => "method", - // ... - } -} - -// qual:inverse(as_str) -pub fn parse(s: &str) -> Self { - match s { - "fn" => Self::Function, - "method" => Self::Method, - // ... - } -} -``` - -Common use cases: `serialize`/`deserialize`, `encode`/`decode`, `to_bytes`/`from_bytes`. Like `// qual:api`, inverse markers do **not** count against the suppression ratio — they document intentional structural similarity. - -### Automatic Leaf Detection - -Functions with no own calls (Operations and Trivials) are automatically recognized as **leaf functions**. Calls to leaves do not count as "own calls" for the caller: - -```rust -fn get_config() -> Config { // Operation (C=0) → leaf - if let Ok(c) = load_file() { c } else { Config::default() } -} - -fn cmd_quality(clear: bool) -> Result<()> { - let config = get_config(); // calling a leaf → not an own call - if clear { /* logic */ } // logic only → Operation, not Violation - Ok(()) -} -``` - -Without leaf detection, `cmd_quality` would be a Violation (logic + own call). With it, the call to `get_config` is recognized as terminal — no orchestration involved. - -More broadly, calls to **any non-Violation function** are treated as safe — this includes Operations (pure logic), Trivials (empty/simple), and Integrations (pure delegation). Only calls to other Violations (functions that themselves mix logic and non-safe calls) remain true Violations. This cascades iteratively until stable. - -> **Design note — pragmatic IOSP relaxation:** In strict IOSP, *any* call to an own function from a function with logic constitutes a Violation. rustqual relaxes this: only calls to Violations count as concern-mixing. Calls to well-structured functions (Operations, Integrations, Trivials) are treated as safe building blocks. This eliminates false positives for common patterns while preserving true Violations where tangled code calls other tangled code (e.g., mutually recursive Violations). - -### Recursive Annotation - -Mark intentionally recursive functions with `// qual:recursive` to prevent the self-call from being counted as an own call: - -```rust -// qual:recursive -fn traverse(node: &Node) -> Vec { - let mut result = vec![node.name.clone()]; - for child in &node.children { - result.extend(traverse(child)); // self-call not counted - } - result -} -``` - -Without the annotation, `traverse` would be a Violation (loop logic + self-call). With it, the self-call is removed before classification. Like `// qual:api` and `// qual:inverse`, recursive markers do **not** count against the suppression ratio. - -### Lenient vs. Strict Mode - -By default the analyzer runs in **lenient mode**. This makes it practical for idiomatic Rust code: - -| Construct | Lenient (default) | `--strict-closures` | `--strict-iterators` | -|---------------------------------|-------------------------|--------------------------|-------------------------| -| `items.iter().map(\|x\| x + 1)` | ignored entirely | closure logic counted | `.map()` as own call | -| `\|\| { if cond { a } }` | closure logic ignored | `if` counted as logic | — | -| `self.do_work()` in closure | call ignored | call counted as own | — | -| `x?` | not logic | — | — | -| `async { if x { } }` | ignored (like closures) | — | — | - -Use `--strict-error-propagation` to count `?` as logic. - -## Features - -### Quality Score - -The overall quality score is a weighted average of seven dimension scores (weights are configurable via `[weights]` in `rustqual.toml`): - -| Dimension | Default Weight | Metric | -|--------------|----------------|--------| -| IOSP | 22% | Compliance ratio (non-trivial functions) | -| Complexity | 18% | 1 - (complexity + magic numbers + nesting + length + unsafe + error handling) / total | -| DRY | 13% | 1 - (duplicates + fragments + dead code + boilerplate + wildcards + repeated matches) / total | -| SRP | 18% | 1 - (struct warnings + module warnings + param warnings + structural BTC/SLM/NMS) / total | -| Coupling | 9% | 1 - (coupling warnings + 2×cycles + SDP violations + structural OI/SIT/DEH/IET) / total | -| Test Quality | 10% | 1 - (assertion-free + no-SUT + untested + uncovered + untested-logic) / total | -| Architecture | 10% | 1 - (layer violations + forbidden edges + pattern hits + trait-contract breaches) / total | - -Quality score ranges from 0% (all findings) to 100% (no findings). Weights must sum to 1.0. - -### Quality Gates - -By default, the analyzer exits with code 1 on any findings — no extra flags needed for CI. Use `--no-fail` for local exploration. - -```bash -# Fail if quality score is below 90% -rustqual src/ --min-quality-score 90 - -# Local exploration (never fail) -rustqual src/ --no-fail -``` - -### Violation Severity - -Violations are categorized by severity based on the number of findings: - -| Severity | Condition | -|----------|--------------------| -| Low | ≤2 total findings | -| Medium | 3–5 total findings | -| High | >5 total findings | - -Severity is shown as `[LOW]`, `[MEDIUM]`, `[HIGH]` in text output and as a `severity` field in JSON/SARIF. - -### Complexity Metrics - -Each analyzed function gets complexity metrics (shown with `--verbose`): - -- **cognitive_complexity**: Cognitive complexity score (increments for nesting depth) -- **cyclomatic_complexity**: Cyclomatic complexity score (decision points + 1) -- **magic_numbers**: Numeric literals not in the configured allowed list -- **logic_count**: Number of logic occurrences (if, match, operators, etc.) -- **call_count**: Number of own-function calls -- **max_nesting**: Maximum nesting depth of control flow -- **function_lines**: Number of lines in the function body -- **unsafe_blocks**: Count of `unsafe` blocks -- **unwrap/expect/panic/todo**: Error handling pattern counts - -### Coupling Analysis - -Detects module-level coupling issues: - -- **Afferent coupling (Ca)**: Modules depending on this one (fan-in) -- **Efferent coupling (Ce)**: Modules this one depends on (fan-out) -- **Instability**: Ce / (Ca + Ce), ranging from 0.0 (stable) to 1.0 (unstable) -- **Circular dependencies**: Detected via Kosaraju's iterative SCC algorithm - -Leaf modules (Ca=0) are excluded from instability warnings since I=1.0 is natural for them. - -- **Stable Dependencies Principle (SDP)**: Flags when a stable module (low instability) depends on a more unstable module. This violates the principle that dependencies should flow toward stability. - -### DRY Analysis - -Detects five categories of repetition: +Exit code `1` on findings — drop it into CI without ceremony. -- **Duplicate functions**: Exact and near-duplicate functions (via AST normalization + Jaccard similarity) -- **Duplicate fragments**: Repeated statement sequences across functions (sliding window + merge) -- **Dead code**: Functions never called from production code, or only called from tests. Detects both direct calls and function references passed as arguments (e.g., `.for_each(some_fn)`). -- **Boilerplate patterns**: 10 common Rust boilerplate patterns (BP-001 through BP-010) including trivial `From`/`Display` impls, manual getters/setters, builder patterns, manual `Default`, repetitive match arms, error enum boilerplate, and clone-heavy conversions -- **Wildcard imports**: Flags `use foo::*` glob imports (excludes `prelude::*` paths and `use super::*` in test modules) -- **Repeated match patterns** (DRY-005): Detects identical `match` blocks (≥3 arms) duplicated across ≥3 instances in ≥2 functions, via AST normalization and structural hashing +## Why it exists -### SRP Analysis +If you've used Claude Code, Cursor, GitHub Copilot, or Codex on Rust projects, you've seen the same patterns: -Detects Single Responsibility Principle violations at three levels: +- Functions that mix orchestration ("call helper, if/else, call another helper") with logic — hard to test, hard to refactor. +- Copy-paste with minor variation when asked to "do the same for X" instead of extracting an abstraction. +- Tests that exercise code without checking it (`#[test] fn it_works() { run_thing(); }`) — coverage looks good, real coverage is zero. +- Architectural drift: new functionality lands in one adapter (CLI, MCP, REST) and silently misses the others. -- **Struct-level**: LCOM4 cohesion analysis using Union-Find on method→field access graph. Composite score combines normalized LCOM4, field count, method count, and fan-out with configurable weights. -- **Module-level (length)**: Production line counting (before `#[cfg(test)]`) with linear penalty between configurable baseline and ceiling. -- **Module-level (cohesion)**: Detects files with too many independent function clusters. Uses Union-Find on private substantive functions, leveraging IOSP own-call data. Functions that call each other or share a common caller are united into the same cluster. A file with more than `max_independent_clusters` (default 2, so 3+ clusters trigger) independent groups indicates multiple responsibilities that should be split into separate modules. +rustqual catches all of these mechanically. Wired into the agent's feedback loop (CI, hooks, instruction file), the agent self-corrects. Reviewer time goes to the actual logic, not to spotting fake tests and inlined god-functions. -### Structural Binary Checks +The same checks help senior teams enforce architecture decisions in CI — the layer rules and forbidden-edge rules don't care whether the code came from a human or an LLM. They keep the codebase coherent over time. -Seven binary (pass/fail) checks for common Rust structural issues, integrated into existing dimensions: +rustqual addresses each of these patterns through a separate quality dimension. Each is independently tunable; together they produce one aggregated quality score. -| Rule | Name | Dimension | What it checks | -|------|------|-----------|----------------| -| BTC | Broken Trait Contract | SRP | Impl blocks missing required trait methods | -| SLM | Self-less Methods | SRP | Methods in impl blocks that don't use `self` (could be free functions) | -| NMS | Needless `&mut self` | SRP | Methods taking `&mut self` that only read from self | -| OI | Orphaned Impl | Coupling | Impl blocks in files that don't define the implemented type | -| SIT | Single-Impl Trait | Coupling | Traits with exactly one implementation (unnecessary abstraction) | -| DEH | Downcast Escape Hatch | Coupling | `.downcast_ref()` / `.downcast_mut()` / `.downcast()` usage (broken abstraction) | -| IET | Inconsistent Error Types | Coupling | Modules returning 3+ different error types (missing unified error type) | +## Seven quality dimensions -Each rule can be individually toggled via `[structural]` config. Suppress with `// qual:allow(srp)` or `// qual:allow(coupling)` depending on the dimension. - -### Architecture Dimension (v1.0) - -Four rule types check the structural shape of the codebase against an -explicit layered architecture. Enabled via `[architecture] enabled = true`. - -**Layer Rule** — files are assigned to layers via path globs; inner layers -(lower rank) may not import from outer layers (higher rank). With -`unmatched_behavior = "strict_error"`, every production file must match -a layer glob; unmatched files become violations. With `"composition_root"`, -unmatched files bypass the rule entirely (useful for Cargo-workspace roots). - -A minimal hexagonal layering: - -```toml -[architecture.layers] -order = ["domain", "port", "application", "adapter"] -unmatched_behavior = "strict_error" - -[architecture.layers.domain] -paths = ["src/domain/**"] - -[architecture.layers.port] -paths = ["src/ports/**"] - -[architecture.layers.application] -paths = ["src/app/**"] - -[architecture.layers.adapter] -paths = ["src/adapters/**"] - -[architecture.reexport_points] -paths = ["src/lib.rs", "src/main.rs"] -``` - -A file in `src/domain/**` importing from `src/adapters/**` is flagged. -Rustqual itself uses a five-rank variant that separates -infrastructure-style adapters (`config`, `source`, `suppression`) from -analysis-logic adapters (`analyzers`, `shared`, `report`) — see the -committed `rustqual.toml` for the full structure. - -**Forbidden Rule** — paired `from` / `to` path globs forbid cross-branch -imports: - -```toml -[[architecture.forbidden]] -from = "src/adapters/analyzers/iosp/**" -to = "src/adapters/analyzers/**" -except = ["src/adapters/analyzers/iosp/**"] -reason = "peer analyzers are isolated" -``` +| Dimension | What it checks | +|---|---| +| **IOSP** | Function separation: every function is either Integration (orchestrates) or Operation (logic), never both. From Ralf Westphal's Flow Design. | +| **Complexity** | Cognitive/cyclomatic complexity, magic numbers, nesting depth, function length, `unsafe`, error-handling style. | +| **DRY** | Duplicate functions, fragments, dead code, boilerplate (10 BP-* rules), repeated match patterns. | +| **SRP** | Struct cohesion (LCOM4), module length, function clusters, structural method-checks (BTC, SLM, NMS). | +| **Coupling** | Module instability, circular deps, Stable Dependencies Principle, structural checks (OI, SIT, DEH, IET). | +| **Test Quality** | Assertion density, no-SUT tests, untested functions, optional LCOV-based coverage gaps. | +| **Architecture** | Layer rules, forbidden edges, symbol patterns, trait contracts, **call parity** across adapters. | -**Symbol Patterns** — ban specific language shapes via seven matchers: +Each dimension contributes to the aggregated quality score with a configurable weight (defaults to a balanced split summing to 1.0). Each dimension can also be tuned or disabled in `rustqual.toml` — full reference: [book/reference-configuration.md](./book/reference-configuration.md). -| Matcher | Hits | -|---------|------| -| `forbid_path_prefix` | any path reference starting with a banned prefix | -| `forbid_glob_import` | `use foo::*;` | -| `forbid_method_call` | `x.unwrap()` / UFCS `Option::unwrap(x)` | -| `forbid_function_call` | `Box::new(…)` via fully-qualified path | -| `forbid_macro_call` | `panic!()`, `println!()`, etc. | -| `forbid_item_kind` | `async_fn`, `unsafe_fn`, `unsafe_impl`, `static_mut`, `extern_c_block`, `inline_cfg_test_module`, `top_level_cfg_test_item` | -| `forbid_derive` | `#[derive(Serialize)]` | +## What's unusual: call parity -Scope is XOR: either `allowed_in` (whitelist) or `forbidden_in` (blocklist), -with `except` as fine-grained overrides. Example: - -```toml -[[architecture.pattern]] -name = "no_panic_in_production" -forbid_macro_call = ["panic", "todo", "unreachable"] -forbidden_in = ["src/**"] -except = ["**/tests/**"] -reason = "production code returns typed errors" -``` - -**Trait-Signature Rule** — structural checks on trait definitions in scope: - -```toml -[[architecture.trait_contract]] -name = "port_traits" -scope = "src/ports/**" -receiver_may_be = ["shared_ref"] -methods_must_be_async = true -forbidden_return_type_contains = ["anyhow::", "Box`, `Box`, `Rc`, `Cow<'_, T>`, - `&T`, `&mut T` — the Deref-transparent smart pointers — strip to the - inner type. `RwLock` / `Mutex` / `RefCell` / `Cell` do - **not** strip by default (their `read` / `lock` / `borrow` / `get` - methods don't exist on the inner type — stripping would synthesize - bogus edges). Opt in per-wrapper via `transparent_wrappers` if your - codebase uses a genuinely Deref-transparent domain wrapper. `Vec` - / `HashMap<_, V>` preserve the element/value type. -- **`Self::xxx`** in impl-method contexts substitutes to the enclosing - type. -- **`if let Some(s) = opt`** binds `s: T` when `opt: Option`, same - for `Ok(x)` / `Err(e)` patterns. -- **Trait dispatch** (`dyn Trait` / `&dyn Trait` / `Box` - receivers): fans out to every workspace impl of the trait. Method - must be declared on the trait — unrelated methods stay unresolved - rather than fabricating edges. Marker traits (`Send`, `Sync`, …) - skipped when picking the dispatch-relevant bound. -- **Turbofish return types**: `get::()` for generic fns — the - turbofish arg is used as the return type when the workspace index has - no concrete return for `get`. Only single-ident paths trigger. -- **Type aliases**: `type Repo = Arc>;` is recorded and - expanded during receiver resolution, so `fn h(r: Repo) { r.insert(..) }` - reaches `Store::insert` through the peeled smart-pointer chain. - Aliases wrapping non-Deref types (`type Db = Arc>`) still - expand, but the `RwLock` stops peeling — methods on the inner `Store` - aren't reached unless `RwLock` is listed in `transparent_wrappers`. - -For framework codebases you can extend the wrapper and macro lists: - -```toml -[architecture.call_parity] -# Framework extractor wrappers peeled like Arc / Box: -transparent_wrappers = ["State", "Extension", "Json", "Data"] -# Attribute macros that don't affect the call graph. The set is -# recorded for future macro-expansion integrations and currently has -# no observable effect on the call-graph / type-inference pipeline. -transparent_macros = ["my_custom_attr"] ``` -Two escape mechanisms: -- `exclude_targets` — glob list in config for whole groups of - legitimately asymmetric target fns. -- `// qual:allow(architecture)` — per-fn escape for individual - exceptions. Counts against `max_suppression_ratio`. - -See `examples/architecture/call_parity/` for a runnable 3-adapter -fixture. - -Known limits (documented, with clear workarounds): -- **Closure-body arg types** `Session::open().map(|r| r.m())` — the - closure arg's type isn't inferred. Inner method call stays - `:m`. Workaround: pull the method call out of the closure. -- **Unannotated generics** `let x = get(); x.m()` where `get() -> T` - — use turbofish `get::()` or `let x: T = get();`. -- **`impl Trait` inherent methods** — `fn make() -> impl Handler; make().trait_method()` resolves to every workspace impl of `Handler::trait_method` via over-approximation, but an inherent method not declared on `Handler` can't be reached (the concrete type is hidden by design). -- **Multi-bound `impl Trait` / `dyn Trait` returns** — `fn make() -> impl Future + Handler` keeps only the first non-marker bound, so `.await` propagation *or* trait-dispatch fires, never both. Marker traits (`Send`/`Sync`/`Unpin`/`Copy`/`Clone`/`Sized`/`Debug`/`Display`) are filtered first, so `impl Future + Send` is unaffected. Workaround: split the return into two methods, or `qual:allow(architecture)` on the call-site. -- **Caller-side `pub use` path-following.** `pub mod outer { mod private { pub struct Hidden; impl Hidden { pub fn op() } } pub use self::private::Hidden; }` with a caller `fn h(x: outer::Hidden) { x.op() }` resolves the parameter to `crate::…::outer::Hidden` while the impl is keyed under `crate::…::outer::private::Hidden`. Visibility is recognised on both paths, but the call-graph edge goes to `:op` because the resolver doesn't follow workspace-wide `pub use` re-exports inside nested modules. Workaround: write `impl outer::Hidden { … }` at the file-level qualified path so impl-canonical and caller-canonical agree, or `qual:allow(architecture)` at the call-site. -- **Re-exported type aliases inside private modules.** `mod private { pub type Public = Hidden; … } pub use private::Public;` doesn't follow into the alias's target — private modules aren't walked by the visibility pass, so the alias's source type stays out of `visible_canonicals`. Workaround: lift the type alias to the parent module (`pub use private::Hidden; pub type Public = Hidden;`) so both the alias declaration and its target are processed. -- **Type-vs-value namespace ambiguity in `pub use`.** A `pub use internal::helper as Hidden;` re-export adds `Hidden` as a workspace-visible *type* canonical without checking whether the leaf is actually a type. If the same scope has a private `struct Hidden`, its impl methods get registered as adapter surface even though the `pub use` only exported a function. Workaround: rename to avoid the value/type collision, or `qual:allow(architecture)` on the affected impl. -- **`impl Alias { … }` with caller-side alias expansion.** `pub type Public = private::Hidden; impl Public { pub fn op(&self) {} }` indexes the method under `crate::…::Public::op` (impl self-type goes through the path canonicaliser), while a caller `fn h(x: Public) { x.op() }` resolves `x` via type-alias expansion to `crate::…::private::Hidden` and produces a `Hidden::op` edge. Visibility recognises `Public`, but the call-graph edges and the indexed method canonical disagree, so Check B reports `Public::op` as unreached. Workaround: declare the `impl` against the source type (`impl private::Hidden { … }`) so impl-canonical and caller-canonical agree, or `qual:allow(architecture)` on the affected impl. -- **Generic type-alias substitution in the visibility chain.** `type Id = T; pub type Public = Id;` doesn't substitute the use-site arg `private::Hidden` into `Id`'s body in the visibility pass — only the immediate alias `Id` enters `visible_canonicals`. Receiver-side resolution does substitute (the workspace alias-index runs after pub-fn enumeration), so callers reach `Hidden::op` correctly, but Check B can drop the public target. Workaround: skip the generic-alias indirection (`pub type Public = private::Hidden;`), or `qual:allow(architecture)` on the affected impl. -- **Arbitrary proc-macros** not listed in `transparent_macros` — - `// qual:allow(architecture)` on the enclosing fn is the escape. - -Design reference: `docs/rustqual-design-receiver-type-inference.md`. - -**`--explain `** diagnostic mode prints the file's layer assignment, -classified imports, and rule hits — useful for understanding why a rule -fires or when tuning config: +Two checks under one rule: -``` -$ cargo run -- --explain src/domain/foo.rs -═══ Architecture Explain: src/domain/foo.rs ═══ -Layer: domain (rank 0) +- **Check A** — every adapter must delegate. A CLI command that doesn't reach into the application layer is logic in the wrong place. +- **Check B** — every application capability must reach every adapter. Add `app::ingest::run`, forget to wire it into CLI, and Check B reports it by name in CI before review. -Imports (1): - line 1: crate::adapters::Foo — crate::adapters → layer adapter +The hard part is making the call graph honest across method chains, field access, trait dispatch, type aliases, framework extractors, and `Self` substitution. rustqual ships a shallow type-inference engine that resolves these cases without fabricating edges. Full write-up: [book/adapter-parity.md](./book/adapter-parity.md). -Layer violations: - line 1: domain ↛ adapter via crate::adapters::Foo -``` +## Use cases -See `examples/architecture/` for a runnable mini-fixture per matcher/rule. -Suppress with `// qual:allow(architecture)` on the file. +- **AI-assisted Rust development** — agent instruction file, pre-commit hook, CI quality gate, baseline tracking. → [book/ai-coding-workflow.md](./book/ai-coding-workflow.md) +- **CI/CD integration** — GitHub Actions, SARIF, baseline comparison, coverage. → [book/ci-integration.md](./book/ci-integration.md) +- **Adopting on a large existing codebase** — four staged adoption patterns from "lightest touch" to full enforcement. → [book/legacy-adoption.md](./book/legacy-adoption.md) +- **Function-level quality** (IOSP, complexity, structural method checks). → [book/function-quality.md](./book/function-quality.md) +- **Module-level quality** (SRP, LCOM4, file length). → [book/module-quality.md](./book/module-quality.md) +- **Coupling quality** (instability, SDP, OI/SIT/DEH/IET). → [book/coupling-quality.md](./book/coupling-quality.md) +- **Architecture rules** (layers, forbidden edges, symbol patterns, trait contracts). → [book/architecture-rules.md](./book/architecture-rules.md) +- **Adapter parity** — call parity, the architecture rule that's unique to rustqual. → [book/adapter-parity.md](./book/adapter-parity.md) +- **Code reuse** (DRY, dead code, boilerplate). → [book/code-reuse.md](./book/code-reuse.md) +- **Test quality** (assertions, untested functions, coverage). → [book/test-quality.md](./book/test-quality.md) -### Baseline Comparison - -Track quality over time: - -```bash -# Save current state as baseline -rustqual src/ --save-baseline baseline.json - -# ... make changes ... - -# Compare against baseline (shows new/fixed findings, score delta) -rustqual src/ --compare baseline.json +## What is IOSP? -# Fail CI only on regression -rustqual src/ --compare baseline.json --fail-on-regression -``` +The **Integration Operation Segregation Principle** ([Ralf Westphal's Flow Design](https://flow-design.info/)) says every function should be: -The baseline format (v2) includes quality score, all dimension counts, and total findings. V1 baselines (IOSP-only) are still supported for backward compatibility. +- **Integration** — orchestrates other functions. No own logic. +- **Operation** — contains logic. No calls to your own project's functions. -### Refactoring Suggestions +A function that does both is a **Violation** — that's the smell to fix. -```bash -rustqual src/ --suggestions ``` - -Provides pattern-based refactoring hints for violations, such as extracting conditions, splitting dispatch logic, or converting loops to iterator chains. - -### Watch Mode - -```bash -rustqual src/ --watch +┌─────────────┐ ┌─────────────┐ ┌────────────────────┐ +│ Integration │ │ Operation │ │ ✗ Violation │ +│ │ │ │ │ │ +│ calls A() │ │ if x > 0 │ │ if x > 0 │ +│ calls B() │ │ y = x*2 │ │ r = calc() │ ← mixes both +│ calls C() │ │ return y │ │ return r + 1 │ +└─────────────┘ └─────────────┘ └────────────────────┘ ``` -Monitors the filesystem for `.rs` file changes and re-runs analysis automatically. Useful during refactoring sessions. +Out of the box rustqual is forgiving where it matters — closures, iterator chains, match-as-dispatch, and trivial self-getters are all leniency cases. Tighten with `--strict-closures` / `--strict-iterators` if you want them counted as logic. Full breakdown: [book/function-quality.md](./book/function-quality.md). -### Shell Completions +## Install & first run ```bash -# Generate completions for your shell -rustqual --completions bash > ~/.bash_completion.d/rustqual -rustqual --completions zsh > ~/.zfunc/_rustqual -rustqual --completions fish > ~/.config/fish/completions/rustqual.fish +cargo install rustqual +cd your-rust-project +rustqual ``` -## Using with AI Coding Agents - -### Why AI-Generated Code Needs Structural Analysis - -AI coding agents (Claude Code, Cursor, Copilot, etc.) are excellent at producing working code quickly, but they consistently exhibit structural problems that rustqual is designed to catch: - -- **IOSP violations**: AI agents routinely generate functions that mix orchestration with logic — calling helper functions inside `if` blocks, combining validation with dispatch. These "god-functions" are hard to test and hard to maintain. -- **Complexity creep**: Generated functions tend to be long, deeply nested, and full of inline logic rather than composed from small, focused operations. -- **Duplication**: When asked to implement similar features, AI agents often copy-paste patterns rather than extracting shared abstractions, leading to DRY violations. -- **Weak tests**: AI-generated tests frequently lack meaningful assertions, contain overly long test functions, or rely heavily on mocks without verifying real behavior. The Test Quality dimension catches assertion-free tests, low assertion density, and coverage gaps. +Walkthrough with `--init`, `--no-fail`, `--findings`, the common flags, and the first-run output: [book/getting-started.md](./book/getting-started.md). Full flag reference: [book/reference-cli.md](./book/reference-cli.md). -IOSP is particularly valuable for AI-generated code because it enforces a strict decomposition: every function is either an Integration (orchestrates, no logic) or an Operation (logic, no own calls). This constraint forces the kind of small, testable, single-purpose functions that AI agents tend not to produce on their own. +## CI integration -### CLAUDE.md / Cursor Rules Integration - -Project-level instruction files (`.claude/CLAUDE.md`, `.cursorrules`, etc.) can teach AI agents to follow IOSP principles. Add rules like these to your project: - -```markdown -## Code Quality Rules +Minimal GitHub Actions step: -- Run `rustqual src/` after making changes. All findings must be resolved. -- Follow IOSP: every function is either an Integration (calls other functions, - no logic) or an Operation (contains logic, no own-function calls). Never mix both. -- Keep functions under 60 lines and cognitive complexity under 15. -- Do not duplicate logic — extract shared patterns into reusable Operations. -- Do not introduce functions with more than 5 parameters. -- Every test function must contain at least one assertion (assert!, assert_eq!, etc.). -- Generate LCOV coverage data and pass it via `--coverage` to verify coverage gaps. +```yaml +- run: cargo install rustqual +- run: rustqual --format github --min-quality-score 90 ``` -This works with any AI tool that reads project-level instruction files. The key insight is that the agent gets actionable feedback: rustqual tells it exactly which function violated which principle, so it can self-correct. - -### CI Quality Gate for AI-Generated Code - -Add rustqual to your CI pipeline so that AI-generated PRs are automatically checked: +With coverage and PR annotations: ```yaml -name: Quality Check -on: [pull_request] - -jobs: - quality: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - run: cargo install rustqual cargo-llvm-cov - - name: Generate coverage data - run: cargo llvm-cov --lcov --output-path lcov.info - - name: Check quality (changed files only) - run: rustqual --diff HEAD~1 --coverage lcov.info --fail-on-warnings --format github +- run: cargo install rustqual cargo-llvm-cov +- run: cargo llvm-cov --lcov --output-path lcov.info +- run: rustqual --diff origin/main --coverage lcov.info --format github ``` -Key flags for AI workflows: -- `--diff HEAD~1` — only analyze files changed in the PR, not the entire codebase -- `--coverage lcov.info` — include test quality coverage analysis (TQ-005) -- `--fail-on-warnings` — treat suppression ratio violations as errors -- `--min-quality-score 90` — reject PRs that drop quality below a threshold -- `--format github` — produces inline annotations on the PR diff - -See [CI Integration](#ci-integration) for more workflow examples including baseline comparison. - -### Pre-commit Hook - -Catch violations before they enter version control — especially useful when AI agents generate code locally: +For codebases that aren't yet at 100% but want to prevent regression: ```bash -#!/bin/bash -# .git/hooks/pre-commit -if ! rustqual src/ 2>/dev/null; then - echo "rustqual: quality findings detected. Please refactor before committing." - exit 1 -fi -``` - -This gives the AI agent (or developer) immediate feedback before the code is committed. See [Pre-commit Hook](#pre-commit-hook) for the basic setup. - -### Recommended Workflow - -The full quality loop for AI-assisted development: - -1. **Agent instructions** — CLAUDE.md / Cursor rules teach the agent IOSP principles and rustqual usage -2. **Pre-commit hook** — catches violations locally before they enter version control -3. **Coverage verification** — generate LCOV data with `cargo llvm-cov` and pass via `--coverage` to detect weak or missing tests -4. **CI quality gate** — prevents merges below quality threshold using `--min-quality-score` or `--fail-on-regression` -5. **Baseline tracking** — `--save-baseline` and `--compare` track quality score over time, ensuring AI-generated code does not erode structural quality - -## Architecture - -The analyzer uses a **two-pass pipeline**: - -``` - ┌──────────────────────────────────┐ - │ Pass 1: Collect │ - .rs files ──read──► │ Read + Parse all files (rayon) │ - │ Build ProjectScope (all names) │ - │ Scan for // qual:allow markers │ - └────────────────┬─────────────────┘ - │ - ┌────────────────▼─────────────────┐ - │ Pass 2: Analyze │ - │ For each function: │ - │ BodyVisitor walks AST │ - │ → logic + call occurrences │ - │ → complexity metrics │ - │ → classify: I / O / V / T │ - │ Coupling analysis (use-graph) │ - │ DRY detection (normalize+hash) │ - │ SRP analysis (LCOM4+composite) │ - │ Compute quality score │ - └────────────────┬─────────────────┘ - │ - ┌────────────────▼─────────────────┐ - │ Output │ - │ Text / JSON / GitHub / DOT / │ - │ SARIF / HTML / Suggestions / │ - │ Baseline comparison │ - └──────────────────────────────────┘ -``` - -### Source Files - -~200 production files in `src/`, ~19 400 lines. Layered per Clean Architecture: - -``` -src/ -├── lib.rs Composition root (run() entry, ~140 lines) -├── main.rs Thin binary wrapper (rustqual) -├── bin/cargo-qual/main.rs Thin binary wrapper (cargo qual) -│ -├── domain/ Pure value types (no syn, no I/O) -│ ├── dimension.rs -│ ├── finding.rs Port-emitted Finding struct -│ ├── score.rs PERCENTAGE_MULTIPLIER -│ ├── severity.rs -│ ├── source_unit.rs -│ └── suppression.rs -│ -├── ports/ Trait contracts -│ ├── dimension_analyzer.rs DimensionAnalyzer + AnalysisContext + ParsedFile -│ ├── reporter.rs -│ ├── source_loader.rs -│ └── suppression_parser.rs -│ -├── adapters/ -│ ├── config/ TOML loading, tailored --init, weight validation -│ ├── source/ Filesystem walk, parse, --watch -│ ├── suppression/ qual:allow marker parsing -│ ├── shared/ Cross-analyzer utilities -│ │ ├── cfg_test.rs has_cfg_test, has_test_attr -│ │ ├── cfg_test_files.rs collect_cfg_test_file_paths -│ │ ├── normalize.rs AST normalization for DRY -│ │ └── use_tree.rs Canonical use-tree walker -│ ├── analyzers/ Seven dimension analyzers -│ │ ├── iosp/ Analyzer, BodyVisitor, classify, scope -│ │ ├── complexity/ -│ │ ├── dry/ Incl. boilerplate/ (BP-001–BP-010) -│ │ ├── srp/ -│ │ ├── coupling/ -│ │ ├── tq/ -│ │ ├── structural/ BTC, SLM, NMS, OI, SIT, DEH, IET -│ │ └── architecture/ Layer + Forbidden + Symbol + Trait-contract rules -│ └── report/ Text, JSON, SARIF, HTML, DOT, GitHub, -│ AI, AI-JSON, baseline, suggestions -│ -├── app/ Application use cases -│ ├── analyze_codebase.rs Port-based use case -│ ├── pipeline.rs Full-pipeline orchestrator -│ ├── secondary.rs Per-dimension secondary passes -│ ├── metrics.rs Coupling/DRY/SRP helpers -│ ├── tq_metrics.rs -│ ├── structural_metrics.rs -│ ├── architecture.rs Architecture dim wiring via port -│ ├── warnings.rs Complexity + leaf reclass + suppression ratio -│ ├── dry_suppressions.rs -│ ├── exit_gates.rs Default-fail, min-quality, fail-on-warnings -│ └── setup.rs Config loading + CLI overrides -│ -└── cli/ - ├── mod.rs Cli struct (clap), OutputFormat - ├── handlers.rs --init, --completions, --save-baseline, --compare - └── explain.rs --explain architecture diagnostic - -tests/ Workspace integration tests -├── integration.rs End-to-end CLI invocations -└── showcase_iosp.rs Before/after IOSP refactor demonstration -``` - -Companion test trees live next to the production code they cover -(`src//tests/.rs`). Workspace-root `tests/**` are Cargo's -integration-test binaries; each is its own crate. - -### How Classification Works - -1. **Trivial check**: Empty bodies are immediately `Trivial`. Single-statement bodies are analyzed — only classified as Trivial if they contain neither logic nor own calls. -2. **AST walking**: `BodyVisitor` implements `syn::visit::Visit` to walk the function body, recording: - - **Logic**: `if`, `match`, `for`, `while`, `loop`, binary operators (`+`, `&&`, `>`, etc.), optionally `?` operator - - **Own calls**: function/method calls that match names defined in the project (via `ProjectScope`) - - **Nesting depth**: tracks control-flow nesting for complexity metrics -3. **Classification**: - - Logic only → **Operation** - - Own calls only → **Integration** - - Both → **Violation** (with severity based on finding count) - - Neither → **Trivial** -4. **Recursion exception**: If `allow_recursion` is enabled and the only own call is to the function itself, it's classified as Operation instead of Violation. - -### ProjectScope: Solving the Method Call Problem - -Without type information, the analyzer cannot distinguish `self.push(x)` (Vec method, external) from `self.analyze(x)` (own method). The `ProjectScope` solves this with a two-pass approach: - -1. **First pass**: Scan all `.rs` files and collect every declared function, method, struct, enum, and trait name. -2. **Second pass**: During analysis, a call is only counted as "own" if the name exists in the project scope. - -This means `v.push(1)` is never counted as own (since `push` is not defined in your project), while `self.analyze_file(f)` is (because `analyze_file` is defined in your project). - -**Universal methods** (~26 entries like `new`, `default`, `fmt`, `clone`, `eq`, ...) are always treated as external, even if your project implements them via trait impls. This prevents false positives from standard trait implementations. - -### IOSP Score - -``` -IOSP Score = (Integrations + Operations) / (Integrations + Operations + Violations) × 100% +rustqual --save-baseline baseline.json +git add baseline.json && git commit -m "Add quality baseline" ``` -Trivial and suppressed functions are excluded because they are too small or explicitly allowed. - -## CI Integration - -### GitHub Actions - ```yaml -name: Quality Check -on: [push, pull_request] - -jobs: - quality: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - name: Install rustqual - run: cargo install --path . - - name: Check code quality - run: rustqual src/ --min-quality-score 90 --format github +- run: rustqual --compare baseline.json --fail-on-regression ``` -### GitHub Actions with Baseline - -```yaml -- name: Check quality regression - run: | - rustqual src/ --compare baseline.json --fail-on-regression --format github -``` +Full patterns: [book/ci-integration.md](./book/ci-integration.md). -### Generic CI (JSON) +## AI coding agent integration -```yaml -- name: Quality Check - run: | - cargo run --release -- src/ --json > quality-report.json - cat quality-report.json -``` +Drop this into `CLAUDE.md`, `.cursorrules`, `.github/copilot-instructions.md`, or whichever instruction file your tool reads: -### Pre-commit Hook +```markdown +## Code Quality Rules -```bash -#!/bin/bash -# .git/hooks/pre-commit -if ! cargo run --quiet -- src/ 2>/dev/null; then - echo "Quality findings detected. Please refactor before committing." - exit 1 -fi +- Run `rustqual` after making changes. All findings must be resolved before marking a task complete. +- Follow IOSP: every function is either an Integration or an Operation, never both. +- Keep functions under 60 lines and cognitive complexity under 15. +- Don't duplicate logic — extract shared patterns into reusable Operations. +- Don't introduce functions with more than 5 parameters. +- Every test function must contain at least one assertion. +- For public-API functions intentionally untested in this crate, mark with `// qual:api`. ``` -## How to Fix Violations +The agent gets actionable feedback: rustqual tells it which function violated which principle, so it can self-correct without you having to point each issue out. Full patterns: [book/ai-coding-workflow.md](./book/ai-coding-workflow.md). -When a function is flagged as a violation, refactor by splitting it into pure integrations and operations: +## Suppression annotations -**Before (violation):** -```rust -fn process(data: &Data) -> Result { - if data.value > threshold() { // logic + call mixed - transform(data) - } else { - default_output() - } -} -``` +For genuine exceptions: -**After (IOSP compliant):** ```rust -// Integration: orchestrates, no logic -fn process(data: &Data) -> Result { - let threshold = threshold(); - let exceeds = check_threshold(data.value, threshold); - select_output(exceeds, data) -} - -// Operation: logic only, no own calls -fn check_threshold(value: f64, threshold: f64) -> bool { - value > threshold -} - -// Integration: delegates to transform or default -fn select_output(exceeds: bool, data: &Data) -> Result { - if exceeds { transform(data) } else { default_output() } - // Note: this is still a violation! Further refactoring needed: - // Move the if-logic into an operation, call it from here. -} +// qual:allow(iosp) — match dispatcher; arms intentionally inlined +fn dispatch(cmd: Command) -> Result<()> { /* … */ } + +// qual:api — public re-export, callers live outside this crate +pub fn parse(input: &str) -> Result { /* … */ } + +// qual:test_helper — used only from integration tests +pub fn build_test_session() -> Session { /* … */ } ``` -**Common refactoring patterns:** +`max_suppression_ratio` (default 5%) caps how much code can be under `qual:allow`. Stale suppressions (no matching finding in their window) are flagged as `ORPHAN-001`. Full reference: [book/reference-suppression.md](./book/reference-suppression.md). -| Pattern | Approach | -|---------|----------| -| `if` + call in branch | Extract the condition into an Operation, use `.then()` or pass result to Integration | -| `for` loop with calls | Use iterator chains (`.iter().map(\|x\| process(x)).collect()`) — closures are lenient | -| Match + calls | Extract match logic into an Operation that returns an enum/value, dispatch in Integration | +## Output formats -Use `--suggestions` to get automated refactoring hints. +`--format ` — `text` (default), `json`, `github`, `sarif`, `dot`, `html`, `ai`, `ai-json`. Same analysis, different serialisation. Full reference: [book/reference-output-formats.md](./book/reference-output-formats.md). -## Self-Compliance +## Self-compliance -rustqual **analyzes itself** with zero findings across all seven dimensions: +rustqual analyses itself — the full source tree (~2.5k functions across all seven dimensions) reports `Quality Score: 100.0%` with zero findings and zero warnings: ```bash $ cargo run -- . --fail-on-warnings --coverage coverage.lcov ═══ Summary ═══ - Functions: 1805 Quality Score: 100.0% + Quality Score: 100.0% - IOSP: 100.0% (996I, 270O, 521T) + IOSP: 100.0% Complexity: 100.0% DRY: 100.0% SRP: 100.0% @@ -1336,59 +214,42 @@ $ cargo run -- . --fail-on-warnings --coverage coverage.lcov Test Quality:100.0% Architecture:100.0% - ~ All allows: 27 (qual:allow + #[allow]) - All quality checks passed! ✓ ``` -This is verified by the integration test suite and CI. Note: use `.` as -the analysis root (not `src/`) so that architecture-rule globs like -`src/adapters/**` match the actual paths. +Verified by the integration test suite and CI on every push. -## Testing +## Build & test ```bash -cargo nextest run # 1114 tests (1107 unit + 4 integration + 3 showcase) -RUSTFLAGS="-Dwarnings" cargo clippy --all-targets # lint check (0 warnings) +cargo nextest run # full test suite +cargo run -- . --fail-on-warnings --coverage coverage.lcov # self-analysis +RUSTFLAGS="-Dwarnings" cargo clippy --all-targets # lints (0 warnings) ``` -The test suite covers: -- **adapters/analyzers/** — classification, closures, iterators, scope, recursion, `?` operator, async/await, severity, complexity, IOSP/DRY/SRP/coupling/TQ/structural/architecture rule behaviour -- **adapters/config/** — ignore patterns, glob compilation, TOML loading, validation, tailored `--init` generation, weight sum check -- **adapters/report/** — summary stats, JSON structure, suppression counting, baseline roundtrip, HTML, SARIF, GitHub annotations, AI/TOON output -- **adapters/shared/** — cfg-test detection, use-tree walking, AST normalization -- **adapters/source/** — filesystem walk, `--watch` loop -- **app/** — pipeline orchestration, exit gates, setup, secondary-pass coordination, warning accumulation -- **domain/** + **ports/** — value-type invariants and trait-contract shape -- **Integration tests** (`tests/integration.rs`): self-analysis, sample expectations, JSON validity, verbose output -- **Showcase tests** (`tests/showcase_iosp.rs`): before/after IOSP refactoring examples - -## Known Limitations - -1. **Syntactic analysis only**: Uses `syn` for AST parsing without type resolution. Cannot determine the receiver type of method calls — relies on `ProjectScope` heuristics and `external_prefixes` config as fallbacks. -2. **Macros**: Macro invocations are not expanded. `println!` etc. are handled as special cases via `external_prefixes`, but custom macros producing logic or calls may be misclassified. -3. **External file modules**: `mod foo;` declarations pointing to separate files are not followed. Only inline modules (`mod foo { ... }`) are analyzed recursively. -4. **Parallelization**: The analysis pass is sequential because `proc_macro2::Span` (with `span-locations` enabled for line numbers) is not `Sync`. File I/O is parallelized via `rayon`. - -## Dependencies - -| Crate | Purpose | -|----------------|-------------------------------------------------| -| `syn` | Rust AST parsing (with `full`, `visit` features)| -| `proc-macro2` | Span locations for line numbers | -| `quote` | Token stream formatting (generic type display) | -| `derive_more` | `Display` derive for analysis types | -| `clap` | CLI argument parsing | -| `clap_complete`| Shell completion generation | -| `walkdir` | Recursive directory traversal | -| `colored` | Terminal color output | -| `serde` | Config deserialization | -| `toml` | TOML config file parsing | -| `serde_json` | JSON output serialization | -| `globset` | Glob pattern matching for ignore/exclude | -| `rayon` | Parallel file I/O | -| `notify` | File system watching for `--watch` mode | +## In use at + +- [rlm](https://github.com/SaschaOnTour/rlm) — Rust local memory manager. The reference adopter codebase that prompted the call-parity rule. +- [turboquant](https://github.com/SaschaOnTour/turboquant) — Rust quantitative finance toolkit (in active development). + +## Known limitations + +1. **Syntactic analysis only.** Uses `syn` for AST parsing. The receiver-type-inference engine (v1.2+) resolves most method-call receivers; what it can't resolve stays unresolved rather than being fabricated. +2. **Macros.** Macro invocations are not expanded. `println!` etc. are special-cased; custom macros producing logic or calls may be misclassified. Configurable via `[architecture.call_parity].transparent_macros`. +3. **External file modules.** `mod foo;` declarations pointing to separate files are not followed. Only inline modules (`mod foo { ... }`) are analysed recursively. +4. **Sequential analysis pass.** `proc_macro2::Span` (with `span-locations` enabled for line numbers) is not `Sync`. File I/O is parallelised via `rayon`. ## License -MIT +MIT. See [LICENSE](./LICENSE). + +## Contributing + +Bug reports and feature requests: open an issue at [github.com/SaschaOnTour/rustqual/issues](https://github.com/SaschaOnTour/rustqual/issues). For PRs: + +1. `cargo nextest run` — all tests must stay green. +2. `cargo run -- . --fail-on-warnings --coverage coverage.lcov` — the source tree must keep its 100% self-compliance score. +3. `RUSTFLAGS="-Dwarnings" cargo clippy --all-targets` — clippy must stay clean. +4. Update `CHANGELOG.md` for any user-visible change; bump `Cargo.toml` version on release-worthy contributions. + +The codebase is its own best reference for IOSP self-compliance and the architecture rules. The CLAUDE.md file documents internal conventions and common pitfalls. diff --git a/book/adapter-parity.md b/book/adapter-parity.md new file mode 100644 index 0000000..e66092b --- /dev/null +++ b/book/adapter-parity.md @@ -0,0 +1,157 @@ +# Use case: adapter parity (call parity) + +If your project has multiple frontends — a CLI, an MCP server, a REST API, a web UI — they're supposed to expose the same underlying capabilities. In theory, every CLI command has a matching MCP handler. In practice, it drifts. Someone adds a new application function, MCP picks it up, CLI forgets, and three months later you discover `cmd_export` exists in one adapter but not the other. + +**Call Parity makes adapter symmetry a CI-checkable property, not a code-review hope.** It's rustqual's most opinionated architecture rule, and one I haven't found a direct equivalent for in any other Rust static analyzer. + +## What it checks + +Configure adapter layers and a shared target. Minimal example with two adapters: + +```toml +[architecture.layers] +order = ["domain", "application", "cli", "mcp"] + +[architecture.layers.application] +paths = ["src/application/**"] + +[architecture.layers.cli] +paths = ["src/cli/**"] + +[architecture.layers.mcp] +paths = ["src/mcp/**"] + +[architecture.call_parity] +adapters = ["cli", "mcp"] +target = "application" +``` + +`adapters` can list any number of peer layers — REST endpoints, web handlers, gRPC servers, message-queue consumers — they're treated identically. + +Two checks run under one rule: + +- **Check A — every adapter must delegate.** Each `pub fn` in an adapter layer must (transitively) reach into the `target` layer. A CLI command that doesn't actually call into the application layer is logic in the wrong place. Caught at build time. +- **Check B — every target capability must reach all adapters.** Each `pub fn` in the `target` layer must be (transitively) reached from *every* adapter layer. Add `app::ingest::run`, forget to wire it into CLI, and Check B reports exactly that — by name, in CI, before review. + +`call_depth` (default 3) controls how many hops the transitive walk traces. + +## Why this is unusual + +Static analyzers traditionally fall into two camps: + +- **Style and local linters** (Clippy, ESLint, RuboCop) enforce per-function rules. They don't know your architecture. +- **Architecture linters** (ArchUnit, dependency-cruiser) enforce **containment**: "domain doesn't import adapters", "infrastructure doesn't depend on application". They prove what *can't* be called. + +Neither proves what **must** be called — that several adapter modules *collectively cover every public capability* of a target module. That requires building a real call graph across files, resolving method receivers through type aliases, wrappers, re-exports, and `Self`, then reverse-walking from each adapter to see what target functions it actually reaches. + +I haven't found another tool — for any language — that does this out of the box. The closest neighbours are general-purpose graph queries on top of CodeQL or Joern, where you write the analysis from scratch every time. If you know of one, I'd genuinely like to hear about it. + +## The hard part: an honest call graph + +The rule itself is simple. The work is making the call graph honest. Real Rust code looks like: + +```rust +let session = Session::open_cwd().map_err(map_err)?; +session.diff(path).map_err(map_err)?; +``` + +A naive analyzer sees `.diff()` on something it can't name and gives up — that turns into a false-positive "your CLI doesn't reach the application." rustqual ships a shallow type-inference engine that resolves receivers end-to-end: + +- Method-chain constructors and stdlib combinator returns (`Result::map_err`, `Option::ok`, `Future::await`, `Result::inspect`, …) +- Field access chains (`ctx.session.diff()`) +- Trait dispatch on `dyn Trait` and `impl Trait` (over-approximated to every workspace impl) +- Type aliases — including chains, wrappers (`Box`), and re-exports +- Renamed imports (`use std::sync::Arc as Shared;`) — with shadow detection so a local `crate::wrap::Arc` doesn't masquerade as stdlib +- `Self` substitution across all resolver paths so impl-internal delegation works + +Anything that can't be resolved cleanly stays unresolved — no fabricated edges. **False positives kill architectural rules**; missing an edge is recoverable, inventing one isn't. + +## Framework extractors and macro transparency + +Web frameworks wrap state in extractor types (`State`, `Data`, `Json`). Without help, the call graph stops at the extractor. Add them as transparent wrappers: + +```toml +[architecture.call_parity] +adapters = ["cli", "mcp"] +target = "application" +transparent_wrappers = ["State", "Extension", "Json", "Data"] +transparent_macros = ["tracing::instrument", "async_trait::async_trait"] +``` + +Now `fn h(State(db): State) { db.query() }` resolves through the `State` peel and the `Db::query` edge is recorded. + +The default `transparent_macros` list already covers the common cases; entries here extend it. + +## What you'll see + +``` +✗ ARCH-CALL-PARITY src/cli/commands/sync.rs::cmd_sync (Check A) + pub fn does not (transitively, depth=3) reach the target layer + 'application' — adapter has no delegation path + +✗ ARCH-CALL-PARITY src/application/export.rs::run_export (Check B) + target fn is unreached by adapter 'cli' + (reachable from: mcp) +``` + +The first finding says "this CLI command does logic locally instead of delegating". The second says "you added a new application capability and forgot to expose it via CLI". + +## Excluding legitimate asymmetries + +Sometimes a target function genuinely shouldn't have an adapter for every frontend — debug endpoints, admin-only tooling, internal setup. Use `exclude_targets`: + +```toml +[architecture.call_parity] +adapters = ["cli", "mcp"] +target = "application" +exclude_targets = [ + "application::admin::*", # admin tools, not exposed via either adapter + "application::setup::run", # bootstrap, called once at startup +] +``` + +Globs match against the *module path* (with `crate::` stripped), not the layer name. `application::admin::*` matches every `pub fn` under `src/application/admin/**`. + +For ad-hoc per-function suppression: + +```rust +// qual:allow(architecture) — internal capability, intentionally MCP-only +pub fn admin_purge() { /* … */ } +``` + +## Why the false-positive rate matters + +False positives don't just waste reviewer time, they *teach the team to ignore the tool*. The whole call-parity approach only works if the false-positive rate stays close to zero — which is why the receiver-type-inference engine refuses to fabricate edges. An honest "I don't know" beats a confident wrong answer when the rule is going to fail builds. + +## For teams using AI coding assistants + +If you're building Rust with Copilot, Claude, Codex, or similar: this rule guards against one of the more common patterns of architectural drift in AI-assisted codebases. When an agent adds `pub fn export_csv()` to your application layer, it tends to wire it into one frontend and forget the others. Check B catches that on the next `cargo` run — before the PR — without you having to write a custom prompt or review checklist. + +Combined with rustqual's other architecture rules (layers, forbidden edges, trait contracts), this gives any LLM agent a *structural* feedback loop that's stricter and more reliable than narrative architectural documentation in a repo's README. + +## Try it + +```toml +# rustqual.toml +[architecture] +enabled = true +[architecture.layers] +order = ["domain", "application", "cli", "mcp"] +# ... layer paths ... + +[architecture.call_parity] +adapters = ["cli", "mcp"] +target = "application" +``` + +```sh +cargo install rustqual +rustqual . --fail-on-warnings +``` + +## Related + +- [architecture-rules.md](./architecture-rules.md) — the broader architecture dimension (layers, forbidden edges, patterns, trait contracts) +- [ai-coding-workflow.md](./ai-coding-workflow.md) — why call parity especially matters for AI-generated code +- [reference-rules.md](./reference-rules.md) — every rule code with details +- [reference-configuration.md](./reference-configuration.md) — every config option diff --git a/book/ai-coding-workflow.md b/book/ai-coding-workflow.md new file mode 100644 index 0000000..f327606 --- /dev/null +++ b/book/ai-coding-workflow.md @@ -0,0 +1,115 @@ +# Use case: AI-assisted Rust development + +This is what rustqual was originally built for. AI coding agents — Claude Code, Cursor, GitHub Copilot, Codex — are productive but consistently produce a recognisable set of structural smells. rustqual catches them mechanically so the agent can self-correct, without you having to spot every issue in code review. + +## What AI agents tend to get wrong + +- **God-functions** — functions that mix orchestration with logic ("call helper, then if/else, then call another helper, then …"). Hard to test, hard to read, hard to refactor. +- **Long functions with deep nesting** — agents err on the side of inlining everything they need. Cognitive complexity climbs fast. +- **Copy-paste with minor variation** — when asked to "do the same for X", agents often copy the implementation rather than extracting a shared abstraction. +- **Tests without assertions** — agents generate test bodies that *exercise* code without *checking* it. Coverage looks good, real coverage is zero. +- **Architectural drift** — adding code "wherever it fits" instead of respecting the project's layering. The domain layer slowly imports adapters, infrastructure leaks into application, etc. +- **Asymmetric adapters** — when a project has multiple frontends (CLI, REST, MCP), agents tend to wire new functionality into the one they're touching and forget the others. + +rustqual catches all of these. The trick is wiring it into the agent's feedback loop so it self-corrects. + +## Pattern 1: agent instruction file + +Drop this into `CLAUDE.md`, `.cursorrules`, `.github/copilot-instructions.md`, or whichever instruction file your tool reads: + +```markdown +## Code Quality Rules + +- Run `rustqual` after making changes. All findings must be resolved before marking a task complete. +- Follow IOSP: every function is either an Integration (calls other functions, no own logic) or an Operation (contains logic, no calls to project functions). Never mix both. +- Keep functions under 60 lines and cognitive complexity under 15. +- Don't duplicate logic — extract shared patterns into reusable Operations. +- Don't introduce functions with more than 5 parameters. +- Every test function must contain at least one assertion (`assert!`, `assert_eq!`, etc.). +- For public-API functions that are intentionally untested in this crate, mark with `// qual:api` instead of writing a stub test. +``` + +The agent gets actionable feedback: rustqual tells it which function violated which principle, so it can self-correct without you having to point each issue out. + +## Pattern 2: pre-commit hook + +Catch violations before they enter version control — useful when the agent runs locally: + +```bash +#!/bin/bash +# .git/hooks/pre-commit +if ! rustqual 2>/dev/null; then + echo "rustqual: quality findings detected. Refactor before committing." + exit 1 +fi +``` + +Make it executable: `chmod +x .git/hooks/pre-commit`. + +This gives the agent immediate feedback before anything reaches the remote. If you're using Claude Code with hooks (`PostToolUse`), you can wire `rustqual` into the same loop: every Edit triggers a re-check. + +## Pattern 3: CI quality gate + +```yaml +# .github/workflows/quality.yml +name: Quality Check +on: [pull_request] + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo install rustqual cargo-llvm-cov + - run: cargo llvm-cov --lcov --output-path lcov.info + - run: rustqual --diff HEAD~1 --coverage lcov.info --format github +``` + +`--format github` produces inline annotations on the PR diff — exactly where the issue is, what rule fired, why it matters. `--diff HEAD~1` restricts analysis to the changed files so PRs stay fast even on large codebases. + +## Pattern 4: baseline tracking for AI-velocity codebases + +If you have a codebase already at the limit of what you can refactor right now, but you want to make sure new AI-generated code doesn't make it worse: + +```bash +# Snapshot the current state +rustqual --save-baseline baseline.json + +# In CI: fail only on regression +rustqual --compare baseline.json --fail-on-regression +``` + +This lets you ratchet quality up over time without blocking PRs that don't make things worse. Combined with `--min-quality-score 90`, you get a hard floor plus a no-regression rule — exactly what you want when an agent is generating dozens of PRs a week. + +## Why IOSP specifically + +The Integration/Operation distinction is what separates rustqual from a generic linter for AI-coding contexts. AI agents naturally produce mixed-concern functions — they don't have an internal pressure to decompose. IOSP makes that pressure mechanical: the agent writes a god-function, rustqual marks it as a violation, the agent reads the finding, splits the function. Repeat until the loop converges on small, single-purpose functions. + +Without that constraint, agents settle into "works but unmaintainable" code that passes tests, passes clippy, and rots over six months. With it, the agent is structurally pushed toward decomposition every time. + +## Suppression for legitimate exceptions + +Not every violation is a bug. Use `// qual:allow` annotations sparingly: + +```rust +// qual:allow(iosp) — match dispatcher; splitting would just rename the match +fn dispatch(cmd: Command) -> Result<()> { + match cmd { + Command::Sync => sync_handler(), + Command::Diff => diff_handler(), + // … + } +} +``` + +The `max_suppression_ratio` config (default 5%) caps how much code can be suppressed. If the agent suppresses too much, that itself becomes a finding. + +Full annotation reference: [reference-suppression.md](./reference-suppression.md). + +## Related + +- [function-quality.md](./function-quality.md) — what IOSP/complexity actually check +- [test-quality.md](./test-quality.md) — assertion density, coverage, untested functions +- [legacy-adoption.md](./legacy-adoption.md) — applying this to a codebase that's already grown messy +- [ci-integration.md](./ci-integration.md) — full CI patterns diff --git a/book/architecture-rules.md b/book/architecture-rules.md new file mode 100644 index 0000000..4a9b305 --- /dev/null +++ b/book/architecture-rules.md @@ -0,0 +1,212 @@ +# Use case: architecture rules + +The Architecture dimension is rustqual's "I want this codebase to look like *that* in five years" enforcement layer. Where IOSP, complexity, and SRP catch *local* smells, architecture rules catch the *global* drift — the kind that turns a clean hexagonal design into a tangle of cross-imports over six months. + +Architecture rules are config-driven. You write them once in `rustqual.toml`, and they apply on every analysis run. If a PR violates a rule, CI fails. There's no "I'll fix it later" — the rule is the source of truth. + +## What you can enforce + +- **Layers** — domain → ports → infrastructure → application. Inner layers can't import outer ones. +- **Forbidden edges** — `analyzer X` cannot import `analyzer Y`, regardless of layer. Specific cross-cuts. +- **Symbol patterns** — where specific paths/macros/methods are allowed (e.g., `syn::` only in adapters, `unwrap()` only in tests, `println!` only in CLI). +- **Trait contracts** — every port trait must be object-safe, `Send + Sync`, and not leak adapter error types. + +These are independent rule families. You can use any combination. Most projects start with layers, add forbidden edges as they discover specific cross-cuts to forbid, and add symbol patterns when a particular antipattern keeps coming back. + +## Layers + +The defining structural rule. Inner layers know nothing of outer ones; outer layers can use inner ones freely. + +```toml +[architecture.layers] +order = ["domain", "ports", "infrastructure", "analysis", "application"] +unmatched_behavior = "strict_error" # files outside any layer fail the dim + +[architecture.layers.domain] +paths = ["src/domain/**"] + +[architecture.layers.ports] +paths = ["src/ports/**"] + +[architecture.layers.infrastructure] +paths = ["src/adapters/config/**", "src/adapters/source/**"] + +[architecture.layers.analysis] +paths = ["src/adapters/analyzers/**", "src/adapters/report/**"] + +[architecture.layers.application] +paths = ["src/app/**"] +``` + +A `domain` file importing from `application` fails. An `application` file importing from `domain` is fine. Same-layer imports are always fine. + +### `unmatched_behavior` + +Three options: + +- `"strict_error"` — every production file must match a layer (hard finding otherwise). Recommended; flags new files dropped in arbitrary locations. +- `"composition_root"` — unmatched files act as the composition root and may import any layer. +- `"warn"` — soft warning instead of error. + +### Re-export points + +Some files (`lib.rs`, `main.rs`, `bin/**`, `cli/**`, `tests/**`) live at the root and re-export from every layer. Mark them explicitly: + +```toml +[architecture.reexport_points] +paths = ["src/lib.rs", "src/main.rs", "src/bin/**", "src/cli/**", "tests/**"] +``` + +### Workspaces + +For multi-crate workspaces, map external crates to layers: + +```toml +[architecture.external_crates] +my_domain_types = "domain" +my_port_traits = "ports" +``` + +Now `infrastructure` can import `my_domain_types` (lower-rank, allowed) but not the other way around. + +## Forbidden edges + +Where layers are too coarse, forbidden edges name specific source/destination pairs: + +```toml +[[architecture.forbidden]] +from = "src/adapters/analyzers/iosp/**" +to = "src/adapters/analyzers/**" +except = ["src/adapters/analyzers/iosp/**"] +reason = "Dimension analyzers don't know each other" + +[[architecture.forbidden]] +from = "src/adapters/**" +to = "src/app/**" +reason = "Adapters know domain + ports, not application" +``` + +Each rule has `from`, `to`, an optional `except` list, and a human-readable `reason` that shows up in the finding. + +## Symbol patterns + +The most flexible family — you can forbid specific path prefixes, method calls, macro calls, or glob imports in specific directories: + +```toml +# AST types only in adapters +[[architecture.pattern]] +name = "no_syn_in_domain" +forbid_path_prefix = ["syn::", "proc_macro2::", "quote::"] +forbidden_in = ["src/domain/**"] +reason = "Domain has no AST representation" + +# unwrap()/expect() only in tests +[[architecture.pattern]] +name = "no_panic_helpers_in_production" +forbid_method_call = ["unwrap", "expect"] +forbidden_in = ["src/**"] +except = ["**/tests/**"] +reason = "Production propagates errors typed instead of panicking" + +# println!/print!/dbg! only in CLI/binaries +[[architecture.pattern]] +name = "no_stdout_in_library_code" +forbid_macro_call = ["println", "print", "dbg"] +forbidden_in = ["src/**"] +allowed_in = ["src/main.rs", "src/bin/**", "src/cli/**"] +reason = "stdout is the CLI's channel, not library code's" + +# No glob imports in domain +[[architecture.pattern]] +name = "no_glob_imports_in_domain" +forbid_glob_import = true +forbidden_in = ["src/domain/**"] +reason = "Glob imports hide layer tunneling" +``` + +Each rule carries `forbidden_in` (where it fires) and optional `allowed_in`/`except` (where it's exempted). The `reason` field is mandatory and shows up in the finding so reviewers know *why*. + +## Trait contracts + +The most prescriptive family — used to keep port traits clean: + +```toml +[[architecture.trait_contract]] +name = "port_traits" +scope = "src/ports/**" + +receiver_may_be = ["shared_ref"] # only &self, no &mut self / self +forbidden_return_type_contains = [ + "anyhow::", "Box` instead of a typed error. + +Plus the structural binary check `BTC` (broken trait contract) flags impls that are entirely stubs (`unimplemented!`, `todo!`, `Default::default()` only). + +## What you'll see + +``` +✗ ARCH-LAYER src/domain/order.rs imports src/adapters/source/io.rs + domain (rank 0) cannot import infrastructure (rank 2) + +✗ ARCH-FORBID src/adapters/analyzers/iosp/visitor.rs imports + src/adapters/analyzers/dry/mod.rs + reason: Dimension analyzers don't know each other + +✗ ARCH-PATTERN src/auth/session.rs uses unwrap() (line 88) + rule: no_panic_helpers_in_production + reason: Production propagates errors typed instead of panicking + +✗ ARCH-TRAIT src/ports/storage.rs trait Storage::write returns anyhow::Result + rule: port_traits + reason: forbidden_return_type_contains: anyhow:: +``` + +## Diagnostic mode + +`rustqual --explain src/some/file.rs` prints which layer the file matches, which symbol rules apply, and what would change if you moved it. Useful when you can't tell why a file is failing. + +## Configure + +```toml +[architecture] +enabled = true +# Then add layers, forbidden edges, patterns, trait_contract sections +``` + +`--init` doesn't generate architecture rules — they require an opinion about your design that the tool can't infer. Add them manually, ratchet up over time. + +## Suppression + +Architecture is suppression-resistant by design. The `// qual:allow(architecture)` annotation works at the import site or item, but it counts hard against `max_suppression_ratio`, and you should leave a `reason:` rationale in the comment block: + +```rust +// qual:allow(architecture) — port adapter must call into the registry directly +// here for serialization round-trip; pure domain accessor would lose ordering. +use crate::adapters::registry::lookup; +``` + +The right answer is usually to widen the rule (with a clear `except` clause) or move the file, not to suppress. + +## Why this is unusual + +Most static analyzers ship per-function rules and stop there. Architecture linters (ArchUnit, dependency-cruiser) prove what *can't* be called. rustqual's architecture dimension does both directions: + +- **Negative space** (forbidden edges, layer rules, symbol patterns) — what mustn't happen. +- **Positive space** (call parity — see [adapter-parity.md](./adapter-parity.md)) — what *must* happen across multiple adapters. + +The combination is what makes drift mechanically detectable rather than review-dependent. + +## Related + +- [adapter-parity.md](./adapter-parity.md) — call parity, the architecture rule that's unique to rustqual +- [coupling-quality.md](./coupling-quality.md) — metric-based coupling (instability, SDP) +- [reference-rules.md](./reference-rules.md) — every rule code with details +- [reference-configuration.md](./reference-configuration.md) — every config option diff --git a/book/ci-integration.md b/book/ci-integration.md new file mode 100644 index 0000000..41aa30f --- /dev/null +++ b/book/ci-integration.md @@ -0,0 +1,156 @@ +# Use case: CI/CD integration + +Run rustqual on every push or pull request. The defaults make this easy — the binary already exits with code `1` on any finding, so a single `run:` line in CI is enough for a hard quality gate. + +## GitHub Actions — minimal + +```yaml +name: Quality Check +on: [push, pull_request] + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo install rustqual + - run: rustqual +``` + +That's all. If any dimension flags a finding, the job fails. + +## GitHub Actions — with inline PR annotations + +```yaml +- run: rustqual --format github --min-quality-score 90 +``` + +`--format github` emits `::error::` and `::warning::` annotations that GitHub renders inline on the PR diff. `--min-quality-score 90` enforces a hard floor on the overall quality score. + +## GitHub Actions — changed files only + +For large codebases, restrict analysis to files changed in the PR: + +```yaml +- run: rustqual --diff origin/main --format github +``` + +`--diff ` runs the same analysis but only reports findings in files that differ from ``. Cuts CI time for large codebases without losing PR-level signal. + +## GitHub Actions — with coverage + +The Test Quality dimension can use LCOV coverage data to detect untested logic: + +```yaml +- run: cargo install rustqual cargo-llvm-cov +- run: cargo llvm-cov --lcov --output-path lcov.info +- run: rustqual --coverage lcov.info --format github +``` + +This enables TQ-005 (uncovered logic detection) on top of the static checks (assertion-free tests, no-SUT tests, untested public functions). + +## GitHub Actions — baseline comparison + +For codebases that aren't yet at 100% but want to prevent regression: + +```yaml +- run: rustqual --compare baseline.json --fail-on-regression --format github +``` + +The job fails only when the quality score drops or new findings appear. New code can't make things worse, but you don't have to fix everything before the next merge. + +Generate the baseline once and commit it: + +```bash +rustqual --save-baseline baseline.json +git add baseline.json && git commit -m "Add quality baseline" +``` + +Update it intentionally as part of refactor PRs. + +## SARIF for GitHub Code Scanning + +```yaml +- run: rustqual --format sarif > rustqual.sarif +- uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: rustqual.sarif +``` + +Findings appear in the **Security** tab as Code Scanning alerts, with rule IDs for every dimension (IOSP, complexity, coupling, DRY, SRP, test quality, architecture). + +## Other CI systems + +rustqual has no GitHub-specific dependencies — it's a regular Rust binary. For GitLab, CircleCI, Jenkins, etc., the only difference is which output format you want: + +- `--format json` — pipe to whatever tool you have +- `--format html` — self-contained HTML report you can publish as an artifact +- Default text output — printed to stdout + +Example GitLab snippet: + +```yaml +quality: + image: rust:latest + script: + - cargo install rustqual + - rustqual --format json > quality.json + artifacts: + paths: + - quality.json +``` + +## Pre-commit hook + +For local enforcement before code reaches CI: + +```bash +#!/bin/bash +# .git/hooks/pre-commit +if ! rustqual 2>/dev/null; then + echo "rustqual: quality findings detected. Refactor before committing." + exit 1 +fi +``` + +`chmod +x .git/hooks/pre-commit` to enable. + +## Quality gates in practice + +The flags compose. A typical "production-grade" CI step: + +```yaml +- run: | + rustqual \ + --diff origin/main \ + --coverage lcov.info \ + --min-quality-score 90 \ + --fail-on-warnings \ + --format github +``` + +This: +- analyses only files changed vs `main`, +- includes coverage-based test-quality checks, +- requires the overall quality score to be at least 90%, +- treats suppression-ratio overruns as hard errors, +- emits inline PR annotations. + +`--fail-on-warnings` is worth knowing: by default, exceeding `max_suppression_ratio` (5% of functions suppressed) emits a warning but doesn't fail. With this flag, it does. + +## Exit codes + +| Code | Meaning | +|---|---| +| `0` | Success — no findings, or `--no-fail` set | +| `1` | Quality findings; or regression vs baseline (`--fail-on-regression`); or score below threshold (`--min-quality-score`); or warnings present (`--fail-on-warnings`) | +| `2` | Configuration error — invalid or unreadable `rustqual.toml` | + +Full flag reference: [reference-cli.md](./reference-cli.md). + +## Related + +- [ai-coding-workflow.md](./ai-coding-workflow.md) — patterns specific to AI-generated code +- [legacy-adoption.md](./legacy-adoption.md) — onboarding rustqual on existing codebases +- [reference-output-formats.md](./reference-output-formats.md) — every format with examples diff --git a/book/code-reuse.md b/book/code-reuse.md new file mode 100644 index 0000000..736d836 --- /dev/null +++ b/book/code-reuse.md @@ -0,0 +1,173 @@ +# Use case: code reuse (DRY, dead code, boilerplate) + +DRY findings are usually the highest-signal category in the first run. They're concrete: this function looks like that function, this code is never called, this `From` impl can be `derive`d. Easy to act on, easy to verify, big quality wins for not much work. + +rustqual covers four families: + +- **Duplicate functions / fragments / match patterns** — same code in multiple places. +- **Dead code** — defined but never called. +- **Wildcard imports** — `use foo::*;` hiding what's actually pulled in. +- **Boilerplate** — patterns the compiler/macros could write for you. + +## What goes wrong + +- Two functions that are 90% identical with one parameter-change. Someone copied instead of extracting. +- A helper that nobody calls anymore (refactor leftover, or never wired up). +- `use foo::*;` where you only need `foo::Bar` — you've imported 30 names you don't use, and added 30 hidden coupling points. +- Hand-written `impl Display for FooError` that just maps each variant to a string — `#[derive(thiserror::Error)]` does it automatically. + +## What rustqual catches + +| Rule | Meaning | +|---|---| +| `DRY-001` | Two functions are duplicates (95%+ token similarity, one is suggested for removal) | +| `DRY-002` | Dead code — function defined but never called | +| `DRY-003` | Duplicate code fragment (≥6 lines repeated across functions) | +| `DRY-004` | Wildcard import (`use module::*;`) | +| `DRY-005` | Repeated match pattern across functions (≥3 arms identical, ≥3 instances) | +| `BP-001` | Trivial `From` impl (derivable) | +| `BP-002` | Trivial `Display` impl | +| `BP-003` | Trivial getter/setter (consider field visibility) | +| `BP-004` | Builder pattern — consider `derive_builder` or similar | +| `BP-005` | Manual `Default` impl (derivable) | +| `BP-006` | Repetitive match mapping | +| `BP-007` | Error-enum boilerplate (consider `thiserror`) | +| `BP-008` | Clone-heavy conversion | +| `BP-009` | Struct-update boilerplate | +| `BP-010` | Format-string repetition | + +## Duplicates + +`DRY-001` uses token-based similarity with a 95% threshold by default. It's deliberately strict — finding fewer, higher-confidence duplicates is more useful than spamming "these two functions both call `.unwrap()`". When it fires, it tells you: + +- The two functions involved. +- Which one to keep (typically the older / more public). +- The token-level diff between them. + +For inverse-method pairs (encode/decode, serialize/deserialize) where structural duplication is intentional: + +```rust +// qual:inverse(decode) +pub fn encode(input: &Value) -> String { /* … */ } + +// qual:inverse(encode) +pub fn decode(input: &str) -> Value { /* … */ } +``` + +This suppresses the DRY-001 finding without counting against the suppression ratio. + +## Dead code + +`DRY-002` builds a workspace-wide call graph. A function that nobody calls — and isn't a `pub` API entry point — is dead code. + +Two important escape hatches: + +```rust +// qual:api — public re-export, callers live outside this crate +pub fn parse_config(input: &str) -> Result { /* … */ } + +// qual:test_helper — only used from tests/, not from src/ +pub fn assert_in_range(actual: f64, expected: f64, tol: f64) { /* … */ } +``` + +`qual:api` and `qual:test_helper` exclude the function from `DRY-002` *and* from `TQ-003` (untested), without counting against `max_suppression_ratio`. Use them on functions that are exported to consumers outside the crate or used only by integration tests. + +By default, the dead-code analysis treats workspace-root `tests/**` files as call-sites — so a function used only from integration tests is not dead. + +## Code fragments and repeated matches + +`DRY-003` finds ≥6-line blocks repeated across functions — usually setup boilerplate that should be a helper, or assertion patterns that should be a test utility. + +`DRY-005` finds repeated `match` blocks: identical arms, ≥3 of them, in ≥3 different functions. Classic case is dispatching the same enum to slightly different methods in five places — extract a helper. + +## Wildcard imports + +`DRY-004` flags every `use foo::*;`. Wildcards hide: + +- Which symbols you actually depend on. +- Layer-tunneling (a wildcard re-export can pull in domain types into adapters without it being visible). +- Name collisions when the upstream module adds new symbols. + +Replace with explicit imports. If a `prelude::*` is unavoidable (some crates require it), suppress narrowly: + +```rust +// qual:allow(dry) — diesel requires this prelude for query DSL +use diesel::prelude::*; +``` + +## Boilerplate + +The `BP-*` rules detect patterns where the compiler or a derive macro could write the code for you. Each finding includes a suggested replacement. Examples: + +- `BP-001` `impl From for B { fn from(a: A) -> B { B { x: a.x, y: a.y } } }` → `#[derive(...)]` or struct shorthand. +- `BP-005` Manual `impl Default` that just calls every field's default → `#[derive(Default)]`. +- `BP-007` Hand-written error enum with `From` impls and `Display` mapping → `#[derive(thiserror::Error)]`. + +These rarely require thinking — just apply the suggestion and move on. Disable the boilerplate dimension if your project has a reason to avoid derive macros: + +```toml +[boilerplate] +enabled = false +``` + +## Configure thresholds + +```toml +[duplicates] +enabled = true +# similarity_threshold = 0.95 +# min_function_lines = 6 + +[boilerplate] +enabled = true +``` + +Most thresholds are tuned to be opinionated by default. Loosen them via `--init` if you want them calibrated to your current codebase metrics. + +## What you'll see + +``` +✗ DRY-001 src/api/users.rs::format_user (line 88) + duplicate of src/api/orders.rs::format_order (96% similarity) + +✗ DRY-002 src/utils/legacy.rs::old_helper (line 12) — dead code + +⚠ DRY-004 src/api/handlers.rs uses `use db::*;` (line 4) + +✗ BP-001 src/error.rs impl From for AppError — derivable +``` + +## Suppression + +For genuine cases where suppression is right: + +```rust +// qual:allow(dry) — keeping this duplicate temporarily; consolidating in PR-345 +fn old_path() { /* … */ } + +// qual:api — public-API entry, callers outside this crate +pub fn parse(input: &str) -> Result { /* … */ } + +// qual:test_helper — used only from integration tests +pub fn build_test_config() -> Config { /* … */ } + +// qual:inverse(decode) +fn encode(v: &Value) -> Vec { /* … */ } +``` + +`qual:api`, `qual:test_helper`, and `qual:inverse` don't count against `max_suppression_ratio`. `qual:allow(dry)` does. + +Full annotation reference: [reference-suppression.md](./reference-suppression.md). + +## Why this matters for AI-generated code + +AI agents are particularly prone to copy-paste-with-variation: when asked to "do the same for X", they tend to copy the existing implementation rather than extract a shared abstraction. `DRY-001` and `DRY-003` catch that mechanically. After a few cycles of `rustqual` flagging duplicates, the agent learns to extract; the codebase stays denormalised. + +`DRY-002` is the other half of that loop — agents sometimes generate helpers that go unused because they pivoted mid-task. Catching dead code at PR time prevents accumulation. + +## Related + +- [function-quality.md](./function-quality.md) — IOSP, complexity (where duplication often lives) +- [test-quality.md](./test-quality.md) — `TQ-003` (untested) shares the call graph with `DRY-002` +- [reference-rules.md](./reference-rules.md) — every rule code with details +- [reference-suppression.md](./reference-suppression.md) — `qual:api`, `qual:test_helper`, `qual:inverse` diff --git a/book/coupling-quality.md b/book/coupling-quality.md new file mode 100644 index 0000000..3553b0f --- /dev/null +++ b/book/coupling-quality.md @@ -0,0 +1,102 @@ +# Use case: coupling + +Coupling is what makes a refactor cost a week instead of an hour. Two modules that import each other can't be tested independently, can't be deployed independently, and tend to drift toward a single mega-module over time. rustqual measures coupling at the module level and flags the patterns that historically lead to architecture decay. + +## What goes wrong + +- **Circular dependencies** — `a` imports `b`, `b` imports `a`. Either directly or through a chain. Fundamentally breaks layering. +- **Unstable cores** — modules that are heavily depended on (high fan-in) but also depend on a lot of other modules (high fan-out). Every change ripples; nothing is safe to touch. +- **Stable Dependencies Principle violations** — a stable module (lots of incoming deps) depends on an unstable one (few incoming, many outgoing). Should be the other way around. +- **Orphaned impl blocks** — `impl Foo` lives in a different file than `struct Foo`. Useful occasionally; usually a "I'll move it later" smell. +- **Single-impl traits** — a non-public trait with exactly one impl. The trait isn't an abstraction, it's a stub waiting to be inlined. +- **Downcast escape hatches** — `Any::downcast`/`downcast_ref`. Almost always means a missing enum or trait method. +- **Inconsistent error types** — one module returns `Result<_, MyError>` from half its functions and `Result<_, anyhow::Error>` from the other half. + +## What rustqual catches + +| Rule | Meaning | +|---|---| +| `CP-001` | Circular module dependency | +| `CP-002` | Stable Dependencies Principle violation — a stable module depends on a less stable one | +| `OI` | Orphaned impl: `impl Foo` block in a different file from `struct Foo` | +| `SIT` | Single-impl trait: non-`pub` trait with exactly one implementation | +| `DEH` | Downcast escape hatch: use of `Any::downcast` | +| `IET` | Inconsistent error types within a module | + +`OI`, `SIT`, `DEH`, `IET` are part of the **structural binary checks** under the Coupling dimension. They each fire as a single binary signal per module, which is why they don't have numeric thresholds. + +### Instability metric + +For each module, rustqual computes `I = fan_out / (fan_in + fan_out)`: + +- `I = 0` — purely depended-upon, depends on nothing (stable: domain types). +- `I = 1` — depends on everything, nothing depends on it (unstable: top-level orchestration). + +The Stable Dependencies Principle says *dependencies should flow toward stability* — stable modules at the bottom, unstable ones at the top. `CP-002` fires when a stable module imports an unstable one, which inverts the dependency direction. + +## Configure thresholds + +```toml +[coupling] +enabled = true +max_instability = 0.8 # warn above this +max_fan_in = 15 +max_fan_out = 12 +check_sdp = true # disable SDP check if you don't want it + +[structural] +enabled = true +check_oi = true +check_sit = true +check_deh = true +check_iet = true +``` + +`--init` calibrates `max_fan_in` and `max_fan_out` to your current codebase metrics. + +## What you'll see + +``` +✗ CP-001 src/auth/session.rs ↔ src/auth/token.rs (cycle of length 2) +✗ CP-002 src/domain/user.rs depends on src/api/handlers.rs (I=0.91) +⚠ OI impl Order in src/orders/persistence.rs (struct in src/orders/types.rs) +⚠ SIT trait OrderValidator (1 impl: BasicOrderValidator) +⚠ DEH downcast in src/registry/lookup.rs (line 88) +⚠ IET src/payment/mod.rs uses MyError (4×) and anyhow::Error (3×) +``` + +`--format dot` produces a Graphviz module dependency graph — useful for visualising cycles or eyeballing dependency direction. + +## Refactor patterns + +**Cycle**: usually one of the two modules has a function that belongs in the other. Move the function and the cycle dissolves. If both modules genuinely need each other, extract the shared types into a third module they both depend on. + +**SDP violation**: invert the dependency — typically by introducing a trait in the stable module that the unstable one implements. The stable module then knows nothing about the unstable one. + +**Orphaned impl**: move the `impl` block next to the type definition, or — if it's a trait impl that *can't* live there (orphan rules) — accept it and add `// qual:allow(coupling)`. + +**Single-impl trait**: inline the trait. Most "interface for testability" cases can be replaced by direct dependency injection of the concrete type, or by a trait that has more than one real impl somewhere in the codebase. + +**Downcast**: replace with an enum. If you find yourself reaching for `Any::downcast`, the type system is telling you the cases were never enumerated. + +**Inconsistent errors**: pick one. Either every public function in the module returns `MyError` (with `From` impls for upstream errors), or every public function returns `anyhow::Error`. Don't mix. + +## Suppression + +Coupling warnings are *module-level*, not function-level. The `qual:allow(coupling)` annotation on a single function doesn't silence them — that's intentional. To suppress a coupling finding for a whole module: + +```rust +// At the top of the module file: +//! qual:allow(coupling) — orchestration layer, intentionally depends on every adapter. +``` + +Inner doc-comment form (`//!`) attaches to the module, not to a single item. + +For structural-binary checks (`OI`, `SIT`, `DEH`, `IET`) which target specific items, use `// qual:allow(coupling)` at the impl/trait/use site. + +## Related + +- [architecture-rules.md](./architecture-rules.md) — explicit layer rules instead of metrics +- [module-quality.md](./module-quality.md) — within-module structure (SRP) +- [reference-rules.md](./reference-rules.md) — every rule code with details +- [reference-configuration.md](./reference-configuration.md) — every config option diff --git a/book/function-quality.md b/book/function-quality.md new file mode 100644 index 0000000..ef8af76 --- /dev/null +++ b/book/function-quality.md @@ -0,0 +1,163 @@ +# Use case: function-level quality + +Function quality is where most code rot starts. A function that mixes orchestration with logic, or that grows past 60 lines with deep nesting, is the smell that everything else builds on. rustqual catches these mechanically — through IOSP, complexity metrics, and method-shape checks — so the feedback loop runs at every change instead of every six months. + +## IOSP — Integration vs Operation + +Every function should be either: + +- **Integration** — orchestrates other functions. No own logic (no `if`, `for`, `match`, arithmetic, etc.). Just calls and assignments. +- **Operation** — contains logic. No calls into your own project's functions (stdlib and external crates are fine). + +A function that does both is a **Violation** — that's the smell to fix. Splitting it gives you one Integration and one or more Operations, each with a single purpose. + +```rust +// Violation: mixes orchestration and logic +pub fn process_order(order: Order) -> Result { + let total = order.items.iter().map(|i| i.price).sum::(); // logic + let discount = if order.is_premium { 0.1 } else { 0.0 }; // logic + let final_total = total * (1.0 - discount); // logic + let receipt = generate_receipt(&order, final_total)?; // call + save_receipt(&receipt)?; // call + Ok(receipt) +} + +// After refactor: one Integration, two Operations +pub fn process_order(order: Order) -> Result { + let total = calculate_total(&order); + let receipt = generate_receipt(&order, total)?; + save_receipt(&receipt)?; + Ok(receipt) +} + +fn calculate_total(order: &Order) -> f64 { + let raw = order.items.iter().map(|i| i.price).sum::(); + let discount = if order.is_premium { 0.1 } else { 0.0 }; + raw * (1.0 - discount) +} +``` + +### Leniency rules + +Out of the box, IOSP is forgiving where it matters: + +- **Closures and async blocks** don't count as logic. `.map(|x| x.foo())` is fine inside an Integration. +- **For-loops over delegation** — a `for x in xs { handler(x) }` is treated as orchestration, not logic. +- **Match-as-dispatch** — a `match` whose every arm is a single delegation call is orchestration. Add a guard or any logic to one arm and it becomes a Violation. +- **Trivial self-getters** (`fn name(&self) -> &str { &self.name }`) are excluded from own-call counting. +- **`#[test]` functions** are exempt from IOSP — assertions are logic by nature. + +Tighten with `--strict-closures` / `--strict-iterators` if you want closures and iterator chains to count as logic. + +## Complexity + +IOSP shapes the *structure* of functions; complexity caps the *size* of each piece. The defaults are deliberate: + +| Rule | Default | What it catches | +|---|---|---| +| `CX-001` | cognitive ≤ 15 | Hard-to-read control flow (deep nesting + boolean combinators). Cognitive penalises nesting more than cyclomatic does. | +| `CX-002` | cyclomatic ≤ 10 | Decision-point density. | +| `CX-003` | magic number ≤ 0 | Bare numeric literals outside `const`/`static`. Forces named constants. | +| `CX-004` | length ≤ 60 lines | Function size. | +| `CX-005` | nesting ≤ 4 levels | Pyramid-of-doom guard. | +| `CX-006` | `detect_unsafe = true` | `unsafe { … }` blocks. Use `// qual:allow(unsafe)` for genuine FFI. | +| `A20` | `detect_error_handling = true` | `.unwrap()`, `.expect()`, `panic!`, `todo!` in production code. | + +Configure thresholds in `rustqual.toml`: + +```toml +[complexity] +enabled = true +max_cognitive = 15 +max_cyclomatic = 10 +max_function_lines = 60 +max_nesting_depth = 4 +detect_unsafe = true +detect_error_handling = true +allow_expect = false # set true to permit .expect() but still flag .unwrap() +``` + +`--init` calibrates these to your current metrics so the initial run produces actionable findings, not aspirational ones. + +### Test-aware + +`A20` and `CX-004` skip `#[test]` functions and files under workspace-root `tests/**`. Asserting on `.unwrap()` in a test is fine; it's panicking in production that matters. + +## Method-shape checks + +Beyond IOSP and complexity, rustqual flags structural smells at the method level. These are part of the **structural binary checks** under SRP/Coupling: + +| Rule | What it means | +|---|---| +| `SLM` | **Selfless method** — takes `self` but never references it. Should be a free function or associated function. | +| `NMS` | **Needless `&mut self`** — declares mutable receiver but never mutates. Tighten the signature to `&self`. | +| `BTC` | **Broken trait contract** — every method in an `impl Trait` is a stub (`unimplemented!`, `todo!`, `Default::default()`). The trait is unimplemented in spirit. | + +Configure under `[structural]`: + +```toml +[structural] +enabled = true +# check_btc = true +# check_slm = true +# check_nms = true +``` + +## Parameter sprawl + +`SRP-003` flags functions with too many parameters (default: > 5). The fix is usually a context struct: + +```rust +// Flagged +fn render(width: u32, height: u32, dpi: u32, theme: Theme, + locale: Locale, watermark: Option<&str>) { /* … */ } + +// Better +fn render(opts: &RenderOptions) { /* … */ } +``` + +## What you'll see + +``` +✗ VIOLATION process_order (line 48) [MEDIUM] + IOSP — function mixes orchestration with logic + CX-001 cognitive=18 > 15 + CX-004 length=72 > 60 + +⚠ SLM dispatch (line 124) — takes &self but never uses it +``` + +`--findings` gives one finding per line for piping; `--verbose` shows every function with its full metrics. + +## Suppression for legitimate exceptions + +For functions you genuinely cannot refactor right now (legacy entry points, generated code, FFI shims): + +```rust +// qual:allow(iosp) — match-dispatcher; arms intentionally inlined for codegen +fn dispatch(cmd: Command) -> Result<()> { /* … */ } + +// qual:allow(complexity) — large lookup table; splitting hurts readability +fn rule_table() -> &'static [Rule] { /* … */ } + +// qual:allow(unsafe) — FFI boundary, audited 2026-Q1 +unsafe fn raw_call() { /* … */ } +``` + +Suppressions count against `max_suppression_ratio` (default 5%) so they can't silently take over the codebase. `unsafe`-specific suppression has a separate path that doesn't count against that ratio. + +Full annotation reference: [reference-suppression.md](./reference-suppression.md). + +## Why IOSP for AI-assisted code + +AI agents tend to inline everything — they have no internal pressure to decompose. IOSP makes that pressure mechanical: the agent writes a god-function, rustqual marks it, the agent reads the finding and splits. That converges the loop on small, single-purpose functions instead of "works but unmaintainable" code that passes tests and clippy but rots over months. + +Background on the principle itself: [flow-design.info](https://flow-design.info/) — Ralf Westphal's original write-up on Integration Operation Segregation. + +## Related + +- [module-quality.md](./module-quality.md) — when too many functions cluster into one module +- [test-quality.md](./test-quality.md) — assertion density, untested functions +- [code-reuse.md](./code-reuse.md) — duplicates and dead code +- [reference-rules.md](./reference-rules.md) — every rule code with details +- [reference-configuration.md](./reference-configuration.md) — every config option diff --git a/book/getting-started.md b/book/getting-started.md new file mode 100644 index 0000000..3f47e7e --- /dev/null +++ b/book/getting-started.md @@ -0,0 +1,98 @@ +# Getting started + +## Install + +```bash +cargo install rustqual +``` + +Or from source: + +```bash +git clone https://github.com/SaschaOnTour/rustqual +cd rustqual +cargo install --path . +``` + +You can run rustqual two ways — they're equivalent: + +```bash +rustqual # direct invocation +cargo qual # as a cargo subcommand +``` + +## First run + +```bash +cd your-rust-project +rustqual +``` + +By default rustqual analyses `.`, prints a coloured summary, and exits with code `1` if it found anything. For local exploration that shouldn't fail: + +```bash +rustqual --no-fail +``` + +## What you'll see + +``` +── src/order.rs + ✓ INTEGRATION process_order (line 12) + ✓ OPERATION calculate_discount (line 28) + ✗ VIOLATION process_payment (line 48) [MEDIUM] + +═══ Summary ═══ + Functions: 24 Quality Score: 82.3% + + IOSP: 85.7% + Complexity: 90.0% + DRY: 95.0% + SRP: 100.0% + Test Quality: 100.0% + Coupling: 100.0% + Architecture: 100.0% + +4 quality findings. Run with --verbose for details. +``` + +Each function is classified as **Integration** (orchestrates other functions, no logic), **Operation** (logic, no own calls), **Violation** (mixes both — the smell to fix), or **Trivial** (too small to matter). + +`--verbose` shows every function plus its complexity metrics. `--findings` prints one location per line, useful for piping to `grep`. + +## Generate a config + +```bash +rustqual --init +``` + +This writes a `rustqual.toml` next to `Cargo.toml`, with thresholds calibrated to your current codebase metrics. You can edit any section to tighten or relax checks. Full reference: [reference-configuration.md](./reference-configuration.md). + +## Where to go next + +- **Building with AI assistants?** → [ai-coding-workflow.md](./ai-coding-workflow.md) +- **Adopting on a large existing codebase?** → [legacy-adoption.md](./legacy-adoption.md) +- **Setting up CI?** → [ci-integration.md](./ci-integration.md) +- **Specific quality concerns?** → see the use-case files in this directory: + - [function-quality.md](./function-quality.md) — IOSP, complexity, length, magic numbers + - [module-quality.md](./module-quality.md) — module size, cohesion, function clusters + - [coupling-quality.md](./coupling-quality.md) — circular deps, instability, coupling drift + - [code-reuse.md](./code-reuse.md) — duplicates, dead code, boilerplate + - [test-quality.md](./test-quality.md) — assertions, coverage, untested functions + - [architecture-rules.md](./architecture-rules.md) — layers, forbidden edges, trait contracts + - [adapter-parity.md](./adapter-parity.md) — keep adapter layers in sync + +## Common flags worth knowing + +| Flag | Use | +|---|---| +| `--no-fail` | Local exploration; don't exit non-zero | +| `--verbose` | Show every function, not just findings | +| `--findings` | One finding per line: `file:line category in fn_name` | +| `--diff [REF]` | Only analyse files changed vs a git ref | +| `--coverage ` | Include coverage-based test-quality checks | +| `--init` | Generate a config tailored to your codebase | +| `--watch` | Re-analyse on file changes | +| `--explain ` | Architecture diagnostic for one file | + +Full flag list: [reference-cli.md](./reference-cli.md). diff --git a/book/legacy-adoption.md b/book/legacy-adoption.md new file mode 100644 index 0000000..b1a7b03 --- /dev/null +++ b/book/legacy-adoption.md @@ -0,0 +1,153 @@ +# Use case: adopting rustqual on an existing codebase + +Running rustqual against a large legacy Rust codebase for the first time will produce a lot of findings. That's expected — rustqual was built around an opinionated set of structural rules (IOSP especially). The trick is not to fix everything at once. This guide shows four adoption patterns ordered from "lightest touch" to "full enforcement". + +## The general principle + +You don't have to enable everything on day one. Each dimension has an `enabled` flag, and you can ratchet up over time. A typical adoption sequence: + +1. Start with **DRY** and **Test Quality** — high-signal, low-controversy findings (duplicates, dead code, untested functions, weak assertions). +2. Add **Complexity** — function length, magic numbers, error-handling patterns. Most findings are quick fixes. +3. Add **SRP** and **Coupling** — module-level structure. Some refactoring required, but each fix is local. +4. Add **Architecture** with layer rules — once you've decided what your layering should look like. +5. Add **IOSP** last. This is the most invasive and benefits most from existing decomposition. + +## Pattern A: shallow adoption — defaults off, ratchet on + +Disable the dimensions you're not ready for. Start with whatever you do feel ready to enforce: + +```toml +# rustqual.toml — minimal initial config +[complexity] +enabled = true +max_function_lines = 80 # initial floor; tighten over time + +[duplicates] +enabled = true + +[test_quality] +enabled = true + +[srp] +enabled = false # enable later + +[coupling] +enabled = false # enable later + +[architecture] +enabled = false # enable when you've decided on layering +``` + +If a dimension produces too much noise to act on right now, disable it with `enabled = false` and re-enable later as you ratchet up. For dimensions you can't selectively disable, Pattern B (baseline) absorbs existing findings without enforcing them. + +## Pattern B: baseline — accept current state, enforce no regression + +The most common adoption pattern for an active codebase. Snapshot the current quality state, then in CI fail only on regression: + +```bash +# Generate the baseline once +rustqual --save-baseline baseline.json +git add baseline.json +git commit -m "Add quality baseline" +``` + +In CI: + +```yaml +- run: rustqual --compare baseline.json --fail-on-regression +``` + +New code must be at least as good as what's there. Existing findings stay, but PRs can't introduce new ones. Regenerate the baseline as part of dedicated refactor PRs: + +```bash +rustqual --save-baseline baseline.json # after refactor lowers the count +``` + +This works without disabling anything. You get the full set of checks active immediately, but you don't have to refactor everything. + +## Pattern C: per-function suppression with rationale + +For specific functions you genuinely don't want to refactor (legacy entry points, generated code, etc.), use `// qual:allow` annotations: + +```rust +// qual:allow(iosp) — legacy handler from the v1 API; superseded by handler_v2. +// Kept for backward compat until 2027 sunset. +pub fn legacy_dispatch(req: Request) -> Response { + if req.is_v1() { + handle_v1(req) + } else { + handle_v2(req) + } +} +``` + +Pros: +- Explicit, reviewable in PRs. +- The rationale is right next to the code that needs it. +- `max_suppression_ratio` (default 5%) caps how much can be suppressed before rustqual itself complains. + +Cons: +- Each function needs an annotation. +- Easy to over-use if you're not careful. + +For genuine public-API surface, prefer `// qual:api`: + +```rust +// qual:api — public re-export, callers live outside this crate +pub fn encode(data: &[f32]) -> Vec { /* … */ } +``` + +This excludes the function from dead-code (DRY-002) and untested-function (TQ-003) detection without counting against the suppression ratio. Other dimensions (complexity, IOSP, etc.) still apply. + +For test-only helpers in `src/` that are called from `tests/`: + +```rust +// qual:test_helper +pub fn assert_in_range(actual: f64, expected: f64, tol: f64) { + assert!((actual - expected).abs() < tol); +} +``` + +Same treatment as `// qual:api` for DRY-002/TQ-003, also no ratio cost. + +Full annotation reference: [reference-suppression.md](./reference-suppression.md). + +## Pattern D: bulk-suppress a directory + +For directories you genuinely don't want to analyse (vendored code, auto-generated files, examples): + +```toml +exclude_files = [ + "src/legacy/**", + "src/generated/**", + "examples/**", +] +``` + +These files are skipped entirely — no findings, no ratio cost. + +## Recommended onboarding sequence + +1. **Day 1.** `cargo install rustqual && rustqual --init`. Read the generated config. Don't change it yet. +2. **Day 1.** Run `rustqual --no-fail`. Look at the dimension scores. Note which dimensions are at 100% already (free wins) and which are far off. +3. **Day 1.** Disable the dimensions that produce noise you can't act on yet. Keep the ones that produce actionable findings. +4. **Week 1.** Add a CI step with `--compare baseline.json --fail-on-regression`. Commit the baseline. +5. **Week 2–4.** Incrementally fix findings or add `// qual:allow` annotations with rationale. Re-baseline after each batch. +6. **Month 2+.** Re-enable disabled dimensions one by one. Tighten thresholds in `rustqual.toml` as you go. +7. **Month 3+.** Switch to `--min-quality-score 90` or similar, and drop the baseline. + +This stages the cost over weeks instead of front-loading it on day one. + +## Things that often surprise people + +**IOSP scores are usually low at first.** A typical Rust codebase has 30-60% IOSP compliance before refactoring. Don't panic — that's the dimension's whole point. The `--suggestions` flag gives pattern-based hints for fixing common cases. + +**The Architecture dimension defaults to "strict_error" for unmatched files.** If your `[architecture.layers]` globs don't cover every production file, rustqual will tell you. Either widen the globs, mark the file as a re-export point, or set `unmatched_behavior = "composition_root"` to opt out of strict mode. + +**`--init` produces a config tailored to your current metrics.** It's intentional: starting with realistic thresholds means most findings are real ones, not aspirational ones. + +## Related + +- [ci-integration.md](./ci-integration.md) — for `--compare` and `--fail-on-regression` CI patterns +- [reference-suppression.md](./reference-suppression.md) — full annotation reference +- [reference-configuration.md](./reference-configuration.md) — every config option diff --git a/book/module-quality.md b/book/module-quality.md new file mode 100644 index 0000000..d47b7c8 --- /dev/null +++ b/book/module-quality.md @@ -0,0 +1,142 @@ +# Use case: module-level quality + +A module is the unit of cohesion. Functions inside it should belong together — same data, same purpose, same change-axis. When a module grows past a few hundred lines or starts hosting unrelated function clusters, you've usually accumulated two responsibilities pretending to be one. + +rustqual checks this mechanically through SRP — Single Responsibility Principle — applied to both modules (file-level) and structs (struct-level). + +## What goes wrong + +- **Bloated modules** — one file accumulates 800 lines and twelve responsibilities. The new feature lands "wherever there's room" instead of in its own file. +- **God-structs** — a struct with 30 fields and 40 methods, slowly growing because nobody wants to break it apart. +- **Disconnected clusters inside one struct** — methods `a/b/c` only touch fields `x/y`, methods `d/e/f` only touch fields `z/w`. They share a name, not a responsibility. The struct should be two structs. +- **Wide signatures** — functions with 6+ parameters carrying ad-hoc context. The shape of the signature *is* the design: too many parameters means a missing struct. + +## What rustqual catches + +| Rule | Meaning | Default threshold | +|---|---|---| +| `SRP-001` | Struct may violate SRP — too many fields/methods or low cohesion (LCOM4 > 2) | composite score, `max_fields = 12`, `max_methods = 20` | +| `SRP-002` | Module file too long | warn at 300 lines, hard at 800 | +| `SRP-003` | Function has too many parameters | `max_parameters = 5` | + +`SRP-001` is a composite score: it weighs field count, method count, fan-out, and LCOM4 cohesion together. A struct that's slightly over on fields but cohesive elsewhere doesn't fire; one with disjoint clusters does. + +## LCOM4 and responsibility clusters + +LCOM4 (Lack of Cohesion of Methods, version 4) detects when a struct's methods form **disjoint groups** that share no fields. If you have: + +- `Cluster A`: `methods = [authenticate, refresh_token]`, `fields = [token, expires_at]` +- `Cluster B`: `methods = [render_avatar, set_theme]`, `fields = [avatar_url, theme]` + +…rustqual reports two responsibility clusters and flags the struct. The fix is usually to split into two types — one for auth, one for presentation. + +The verbose output names each cluster so the refactor is mechanical: + +``` +✗ SRP-001 UserSession (line 12) — 2 disjoint clusters + cluster 1: [authenticate, refresh_token] over [token, expires_at] + cluster 2: [render_avatar, set_theme] over [avatar_url, theme] +``` + +## Module length + +Production-line counting excludes blank lines, single-line `//` comments, and `#[cfg(test)]` blocks. So a 1000-line file with 600 lines of tests counts as ~400 production lines. The thresholds: + +- `file_length_baseline = 300` — soft warn +- `file_length_ceiling = 800` — hard finding + +Tests don't push you over the limit. Comments don't either. The number tracks production code only, which is what actually carries the maintenance cost. + +## Configure thresholds + +```toml +[srp] +enabled = true +# max_fields = 12 +# max_methods = 20 +# max_fan_out = 10 +# max_parameters = 5 +# lcom4_threshold = 2 +# file_length_baseline = 300 +# file_length_ceiling = 800 +# max_independent_clusters = 2 +# min_cluster_statements = 5 +# smell_threshold = 0.6 # composite score for SRP-001 +``` + +`--init` calibrates these to your current codebase metrics so initial findings are realistic. + +## Refactor patterns + +**Bloated module** — split by responsibility, not by alphabetical order: + +``` +# Before +src/order/mod.rs # 850 lines: validation + pricing + persistence + email + +# After +src/order/mod.rs # re-exports +src/order/validation.rs # 180 lines +src/order/pricing.rs # 220 lines +src/order/persistence.rs # 190 lines +src/order/notification.rs # 140 lines +``` + +**God-struct with disjoint clusters** — split into types that match the clusters: + +```rust +// Before: SRP-001 with 2 clusters +struct UserSession { + token: Token, expires_at: DateTime, + avatar_url: String, theme: Theme, +} + +// After +struct AuthSession { token: Token, expires_at: DateTime } +struct UserPresentation { avatar_url: String, theme: Theme } +``` + +**Parameter sprawl** — context struct or builder: + +```rust +// SRP-003 +fn render(width: u32, height: u32, dpi: u32, theme: Theme, + locale: Locale, watermark: Option<&str>) { /* … */ } + +// Better +fn render(opts: &RenderOptions) { /* … */ } +``` + +## Suppression + +For modules you genuinely can't split right now (legacy entry points, autogenerated config schemas): + +```rust +// qual:allow(srp) — entire module is one well-defined responsibility, +// length comes from a 600-line lookup table that has to live together. +``` + +The annotation goes on the first item of the file (the topmost `pub use`, `pub fn`, struct, etc.) and applies to the file-level finding. Counts against `max_suppression_ratio`. + +For struct-level suppression: + +```rust +// qual:allow(srp) — public-API struct, fields are stable and intentional +pub struct Config { /* 18 fields */ } +``` + +## Self-compliance + +rustqual itself runs at 100% SRP and ratchets file lengths down aggressively. The CLAUDE.md note worth knowing if you adopt the same workflow: + +> New report code pushes files over 300 production line SRP threshold — extract into submodules proactively. + +It's cheaper to split early than to do a 5-file rewrite once the threshold trips on the next feature. + +## Related + +- [function-quality.md](./function-quality.md) — IOSP, complexity, SRP-003 parameter count +- [coupling-quality.md](./coupling-quality.md) — what happens between modules +- [code-reuse.md](./code-reuse.md) — duplication often signals a missing module +- [reference-rules.md](./reference-rules.md) — every rule code with details +- [reference-configuration.md](./reference-configuration.md) — every config option diff --git a/book/reference-cli.md b/book/reference-cli.md new file mode 100644 index 0000000..1462de6 --- /dev/null +++ b/book/reference-cli.md @@ -0,0 +1,108 @@ +# Reference: CLI flags + +``` +rustqual [OPTIONS] [PATH] +``` + +`PATH` defaults to `.`. Run from the project root so architecture globs (`src/**`) match. + +## Output and verbosity + +| Flag | Description | +|---|---| +| `-v`, `--verbose` | Show every function with metrics, not just findings. | +| `--findings` | One finding per line: `file:line category detail in fn_name`. Useful for piping. | +| `--format ` | Output format. One of: `text` (default), `json`, `github`, `dot`, `sarif`, `html`, `ai`, `ai-json`. See [reference-output-formats.md](./reference-output-formats.md). | +| `--json` | Shortcut for `--format json`. | +| `--suggestions` | Show refactoring suggestions for IOSP violations. | + +## Analysis behaviour + +| Flag | Description | +|---|---| +| `-c`, `--config ` | Path to config. Default: `rustqual.toml` in the target directory. | +| `--diff [REF]` | Only analyse files changed vs a git ref (default: `HEAD`). Conflicts with `--watch`. | +| `--coverage ` | Path to LCOV coverage file. Enables TQ-004 / TQ-005. | +| `--explain ` | Diagnostic mode: explain architecture-rule classification for one file. | +| `--watch` | Watch for file changes and re-analyse continuously. | + +## Strictness toggles + +| Flag | Description | +|---|---| +| `--strict-closures` | Treat closures as logic (stricter IOSP). | +| `--strict-iterators` | Treat iterator chains (`.map`, `.filter`, …) as logic. | +| `--strict-error-propagation` | Count `?` as logic (implicit control flow). | +| `--allow-recursion` | Allow recursive calls — don't count as violations. | + +## Exit-code controls + +| Flag | Description | +|---|---| +| `--no-fail` | Don't exit `1` on findings. Useful for local exploration. | +| `--fail-on-warnings` | Treat warnings (suppression-ratio overrun, etc.) as errors. | +| `--min-quality-score ` | Minimum overall quality score (0–100). Exit `1` if below. | +| `--fail-on-regression` | Used with `--compare`. Exit `1` only when quality regresses vs baseline. | + +## Baseline / regression + +| Flag | Description | +|---|---| +| `--save-baseline ` | Save current results as baseline JSON. | +| `--compare ` | Compare current results against a saved baseline. | + +Typical workflow: + +```bash +rustqual --save-baseline baseline.json +git add baseline.json && git commit -m "Add quality baseline" + +# In CI: +rustqual --compare baseline.json --fail-on-regression +``` + +## Sorting + +| Flag | Description | +|---|---| +| `--sort-by-effort` | Sort IOSP violations by refactoring effort (highest first). | + +## Project setup + +| Flag | Description | +|---|---| +| `--init` | Generate a `rustqual.toml` calibrated to your current codebase metrics. | +| `--completions ` | Emit shell completions. Supported: `bash`, `zsh`, `fish`, `elvish`, `powershell`. | + +## Exit codes + +| Code | Meaning | +|---|---| +| `0` | Success — no findings, or `--no-fail` set. | +| `1` | Findings; or regression vs baseline; or score below `--min-quality-score`; or warnings present (`--fail-on-warnings`). | +| `2` | Configuration error — invalid or unreadable `rustqual.toml`. | + +## Common compositions + +```bash +# Local exploration +rustqual --no-fail --verbose + +# CI hard gate with coverage and PR annotations +rustqual --coverage lcov.info --min-quality-score 90 --fail-on-warnings --format github + +# PR-only analysis +rustqual --diff origin/main --format github + +# Baseline-based regression gate +rustqual --compare baseline.json --fail-on-regression --format github + +# Explain why a file is failing the architecture dimension +rustqual --explain src/foo/bar.rs +``` + +## Related + +- [reference-configuration.md](./reference-configuration.md) — every config option in `rustqual.toml` +- [reference-output-formats.md](./reference-output-formats.md) — every `--format` value with examples +- [ci-integration.md](./ci-integration.md) — putting flags together in CI diff --git a/book/reference-configuration.md b/book/reference-configuration.md new file mode 100644 index 0000000..9e0984e --- /dev/null +++ b/book/reference-configuration.md @@ -0,0 +1,297 @@ +# Reference: configuration + +`rustqual.toml` lives next to `Cargo.toml` and configures every dimension. Generate a starter file calibrated to your codebase: + +```bash +rustqual --init +``` + +Below is the full schema, grouped by section. Every field has a default; a minimal config can omit anything you don't want to tune. + +## Top-level + +| Key | Default | Meaning | +|---|---|---| +| `ignore_functions` | `["main", "run", "visit_*"]` | Function names (or `prefix*` patterns) excluded from all dimensions | +| `exclude_files` | `[]` | Glob patterns for files to skip entirely | +| `strict_closures` | `false` | Treat closures as logic (stricter IOSP) | +| `strict_iterator_chains` | `false` | Treat `.map`/`.filter`/`.fold` as logic | +| `allow_recursion` | `false` | Allow self-calls without counting as IOSP violation | +| `strict_error_propagation` | `false` | Count `?` as logic | +| `max_suppression_ratio` | `0.05` | Cap on `qual:allow` annotations as fraction of functions | + +```toml +ignore_functions = ["main", "run", "visit_*"] +exclude_files = ["examples/**", "vendor/**"] +max_suppression_ratio = 0.05 +``` + +## `[complexity]` + +| Key | Default | Meaning | +|---|---|---| +| `enabled` | `true` | Enable the dimension | +| `max_cognitive` | `15` | `CX-001` threshold | +| `max_cyclomatic` | `10` | `CX-002` threshold | +| `max_nesting_depth` | `4` | `CX-005` threshold | +| `max_function_lines` | `60` | `CX-004` threshold | +| `detect_unsafe` | `true` | Emit `CX-006` on `unsafe` blocks | +| `detect_error_handling` | `true` | Emit `A20` on `unwrap`/`expect`/`panic!`/`todo!` | +| `allow_expect` | `false` | Permit `.expect()` while still flagging `.unwrap()` | + +## `[duplicates]` + +```toml +[duplicates] +enabled = true +``` + +`DRY-001` similarity threshold (95% by default) and `DRY-003` minimum-fragment-length (6 lines) are currently fixed. + +## `[boilerplate]` + +```toml +[boilerplate] +enabled = true +``` + +The full `BP-*` family. Disable if your project deliberately avoids derive macros. + +## `[srp]` + +| Key | Default | Meaning | +|---|---|---| +| `enabled` | `true` | Enable the dimension | +| `smell_threshold` | `0.6` | Composite score threshold for `SRP-001` | +| `max_fields` | `12` | Field count over which `SRP-001` weighs more | +| `max_methods` | `20` | Method count over which `SRP-001` weighs more | +| `max_fan_out` | `10` | Per-struct fan-out bound | +| `max_parameters` | `5` | `SRP-003` threshold | +| `lcom4_threshold` | `2` | Number of disjoint clusters before LCOM4 contributes | +| `file_length_baseline` | `300` | Soft warn for `SRP-002` (production lines) | +| `file_length_ceiling` | `800` | Hard finding for `SRP-002` | +| `max_independent_clusters` | `2` | Max disjoint cluster count | +| `min_cluster_statements` | `5` | Minimum statements for a cluster to count | + +## `[coupling]` + +| Key | Default | Meaning | +|---|---|---| +| `enabled` | `true` | Enable the dimension | +| `max_instability` | `0.8` | Warn when module instability exceeds this | +| `max_fan_in` | `15` | Per-module fan-in bound | +| `max_fan_out` | `12` | Per-module fan-out bound | +| `check_sdp` | `true` | Stable Dependencies Principle (`CP-002`) | + +## `[structural]` + +Binary checks: BTC, SLM, NMS, OI, SIT, DEH, IET. + +| Key | Default | Meaning | +|---|---|---| +| `enabled` | `true` | Enable the dimension | +| `check_btc` | `true` | Broken trait contract | +| `check_slm` | `true` | Selfless method | +| `check_nms` | `true` | Needless `&mut self` | +| `check_oi` | `true` | Orphaned impl | +| `check_sit` | `true` | Single-impl trait | +| `check_deh` | `true` | Downcast escape hatch | +| `check_iet` | `true` | Inconsistent error types | + +## `[test_quality]` + +| Key | Default | Meaning | +|---|---|---| +| `enabled` | `true` | Enable the dimension | +| `extra_assertion_macros` | `[]` | Custom macro names to recognise as assertions in `TQ-001` | + +```toml +[test_quality] +extra_assertion_macros = ["verify", "check_invariant", "expect_that"] +``` + +`TQ-004` and `TQ-005` activate when `--coverage ` is supplied. + +## `[weights]` + +Quality-score weights. Must sum to `1.0`. + +```toml +[weights] +iosp = 0.22 +complexity = 0.18 +dry = 0.13 +srp = 0.18 +coupling = 0.09 +test_quality = 0.10 +architecture = 0.10 +``` + +## `[architecture]` + +The largest section. Top-level toggle: + +```toml +[architecture] +enabled = true +``` + +Then one or more rule families: + +### `[architecture.layers]` + +```toml +[architecture.layers] +order = ["domain", "ports", "infrastructure", "analysis", "application"] +unmatched_behavior = "strict_error" # or "composition_root", "warn" + +[architecture.layers.domain] +paths = ["src/domain/**"] + +[architecture.layers.application] +paths = ["src/app/**"] +``` + +`unmatched_behavior` controls files outside any layer: + +- `"strict_error"` — fail (recommended). +- `"composition_root"` — treat as the root that may import any layer. +- `"warn"` — soft warning. + +### `[architecture.reexport_points]` + +```toml +[architecture.reexport_points] +paths = ["src/lib.rs", "src/main.rs", "src/bin/**", "src/cli/**", "tests/**"] +``` + +Files marked here bypass the layer rule. + +### `[architecture.external_crates]` + +For multi-crate workspaces: + +```toml +[architecture.external_crates] +my_domain_types = "domain" +my_port_traits = "ports" +``` + +### `[[architecture.forbidden]]` + +Repeatable. Per-rule fields: `from`, `to`, `except` (optional), `reason`. + +```toml +[[architecture.forbidden]] +from = "src/adapters/**" +to = "src/app/**" +reason = "Adapters know domain + ports, not application" +``` + +### `[[architecture.pattern]]` + +Repeatable symbol-pattern rules. + +| Field | Meaning | +|---|---| +| `name` | Identifier shown in findings | +| `forbid_path_prefix` | List of `crate::` / `module::` prefixes to forbid | +| `forbid_method_call` | List of method names to forbid (`unwrap`, `expect`, …) | +| `forbid_macro_call` | List of macro names to forbid (`println`, `dbg`, …) | +| `forbid_glob_import` | `true` to forbid `use foo::*;` | +| `forbidden_in` | Globs where the rule fires | +| `allowed_in` | Globs where the rule is exempted | +| `except` | Globs in `forbidden_in` that are exempted | +| `reason` | Mandatory rationale | + +### `[[architecture.trait_contract]]` + +Repeatable trait-shape rules. + +| Field | Meaning | +|---|---| +| `name` | Identifier shown in findings | +| `scope` | Glob of files where the rule applies | +| `receiver_may_be` | Allowed receiver kinds: `"shared_ref"`, `"mut_ref"`, `"owned"` | +| `forbidden_return_type_contains` | Substrings forbidden in return types | +| `forbidden_error_variant_contains` | Substrings forbidden in error types (`Result<_, E>`) | +| `must_be_object_safe` | `true` to require object-safety | +| `required_supertraits_contain` | Required supertrait substrings (`"Send"`, `"Sync"`) | + +### `[architecture.call_parity]` + +Single-instance section. + +| Field | Default | Meaning | +|---|---|---| +| `adapters` | (required) | List of adapter layer names | +| `target` | (required) | Target layer name | +| `call_depth` | `3` | Transitive walk depth | +| `exclude_targets` | `[]` | Globs (module-path form) to skip from Check B | +| `transparent_wrappers` | `[]` | Wrapper type names to peel during receiver-type inference | +| `transparent_macros` | (default list) | Attribute macros treated as transparent | + +```toml +[architecture.call_parity] +adapters = ["cli", "mcp"] +target = "application" +call_depth = 3 +exclude_targets = ["application::admin::*"] +transparent_wrappers = ["State", "Extension", "Json", "Data"] +``` + +## `[report]` + +Workspace-mode aggregation: + +```toml +[report] +aggregation = "loc_weighted" +``` + +Aggregation strategies: `"loc_weighted"` (default), `"unweighted"`. + +## Composition + +Most projects converge on a layout like: + +```toml +ignore_functions = ["main", "run"] +exclude_files = ["examples/**"] +max_suppression_ratio = 0.05 + +[complexity] +enabled = true + +[duplicates] +enabled = true + +[srp] +enabled = true + +[coupling] +enabled = true + +[test_quality] +enabled = true + +[architecture] +enabled = true + +[architecture.layers] +order = ["domain", "ports", "infrastructure", "application"] +unmatched_behavior = "strict_error" + +[architecture.layers.domain] +paths = ["src/domain/**"] +# … etc. +``` + +Use `--init` to bootstrap with calibrated thresholds, then trim. + +## Related + +- [reference-cli.md](./reference-cli.md) — flags that override or supplement config +- [reference-rules.md](./reference-rules.md) — every rule that config keys gate +- [reference-suppression.md](./reference-suppression.md) — `qual:allow` etc. +- [getting-started.md](./getting-started.md) — `--init` and first-run workflow diff --git a/book/reference-output-formats.md b/book/reference-output-formats.md new file mode 100644 index 0000000..651beee --- /dev/null +++ b/book/reference-output-formats.md @@ -0,0 +1,156 @@ +# Reference: output formats + +`--format ` switches the output format. All formats render the same underlying analysis — they differ only in serialisation. + +| Format | Use case | +|---|---| +| `text` (default) | Local exploration, terminal use. Coloured summary. | +| `json` | Machine-readable, full detail. Pipe to `jq`, custom dashboards. | +| `github` | `::error::` / `::warning::` annotations on the GitHub PR diff. | +| `sarif` | GitHub Code Scanning, Azure DevOps, any SARIF v2.1.0 consumer. | +| `dot` | Graphviz module dependency graph. | +| `html` | Self-contained HTML report. Publishable as CI artifact. | +| `ai`, `ai-json` | Compact representations tuned for LLM agents. | + +`--json` is shorthand for `--format json`. + +## `text` (default) + +``` +── src/order.rs + ✓ INTEGRATION process_order (line 12) + ✓ OPERATION calculate_discount (line 28) + ✗ VIOLATION process_payment (line 48) [MEDIUM] + +═══ Summary ═══ + Functions: 24 Quality Score: 82.3% + + IOSP: 85.7% + Complexity: 90.0% + DRY: 95.0% + SRP: 100.0% + Test Quality: 100.0% + Coupling: 100.0% + Architecture: 100.0% + +4 quality findings. Run with --verbose for details. +``` + +`--verbose` adds every function with metrics. `--findings` collapses to one finding per line for piping. + +## `json` + +Full structured output. Top-level keys: + +```jsonc +{ + "version": "1.2.0", + "summary": { "score": 82.3, "functions": 24, "findings": 4, "warnings": 0, + "dimensions": { "iosp": 85.7, "complexity": 90.0, /* ... */ } }, + "findings": [ + { "code": "iosp/violation", "severity": "medium", + "file": "src/order.rs", "line": 48, "function": "process_payment", + "message": "function mixes orchestration with logic" } + ], + "files": [ /* per-file analysis */ ], + "config": { /* effective config */ } +} +``` + +Use this for custom dashboards, regression tracking, or piping into shell tooling: + +```bash +rustqual --format json | jq '.summary.score' +rustqual --format json | jq '.findings[] | select(.severity == "high")' +``` + +## `github` + +GitHub Actions workflow-command annotations. Inline on the PR diff: + +``` +::error file=src/order.rs,line=48,title=IOSP::function mixes orchestration with logic +::warning file=src/utils/legacy.rs,line=12,title=DRY-002::dead code +``` + +Combine with `--diff origin/main` for PR-only analysis: + +```yaml +- run: rustqual --diff origin/main --format github +``` + +## `sarif` + +SARIF v2.1.0. Designed for GitHub Code Scanning, but consumed by Azure DevOps, Sonatype, and any SARIF tool. + +```yaml +- run: rustqual --format sarif > rustqual.sarif +- uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: rustqual.sarif +``` + +Findings show up in the **Security** tab as Code Scanning alerts. Each rule has a stable rule ID (`CX-001`, `DRY-002`, etc.) so dismissals persist across runs. + +## `dot` + +Module dependency graph in Graphviz format: + +```bash +rustqual --format dot | dot -Tpng -o deps.png +rustqual --format dot | dot -Tsvg -o deps.svg +``` + +Useful for spotting cycles or visualising layer separation. Pair with `coupling-quality.md`. + +## `html` + +Self-contained HTML report — no external CSS/JS, no network required. Embed in CI as an artifact: + +```yaml +- run: rustqual --format html > quality.html +- uses: actions/upload-artifact@v4 + with: + name: quality-report + path: quality.html +``` + +The HTML report includes: + +- Per-dimension scores with sparklines. +- Sortable / filterable findings table. +- Per-file drilldown. +- Per-function metrics. + +## `ai` / `ai-json` + +Compact representations tuned for LLM consumption — fewer tokens than full JSON, focused on what an agent needs to act: + +- `ai` — token-efficient text format. Findings only, with file:line, code, and a one-line description. +- `ai-json` — minimal JSON: code, file, line, function, message. No metadata, no per-file tables. + +Useful when you're piping rustqual output into a coding agent (Claude Code, Cursor, etc.) and want to keep the prompt small. + +```bash +rustqual --format ai | claude code "Fix these findings" +``` + +## Choosing a format + +| Audience | Format | +|---|---| +| Developer at a terminal | `text` | +| GitHub PR reviewer | `github` | +| GitHub Code Scanning | `sarif` | +| Custom CI dashboard | `json` | +| Architecture review meeting | `dot` (rendered to PNG/SVG) | +| Stakeholder report | `html` artifact | +| LLM agent prompt | `ai` / `ai-json` | + +Most CI configurations use `github` or `sarif` (or both). Local development: `text`. Tooling integration: `json`. + +## Related + +- [reference-cli.md](./reference-cli.md) — `--format` and other flags +- [ci-integration.md](./ci-integration.md) — CI examples for each format +- [reference-rules.md](./reference-rules.md) — codes referenced in every format diff --git a/book/reference-rules.md b/book/reference-rules.md new file mode 100644 index 0000000..97196bf --- /dev/null +++ b/book/reference-rules.md @@ -0,0 +1,126 @@ +# Reference: rule catalog + +Every rule rustqual emits, grouped by dimension. Codes are stable — they appear in JSON output, SARIF, GitHub annotations, and `// qual:allow` rationales. + +For dimension intent and refactor patterns, see the use-case guides linked at the bottom. + +## IOSP + +| Code | Meaning | +|---|---| +| `iosp/violation` | Function mixes orchestration with logic — split into Integration + Operation(s). | + +## Complexity (CX-*) + +| Code | Meaning | Default threshold | +|---|---|---| +| `CX-001` | Cognitive complexity exceeds threshold | ≤ 15 | +| `CX-002` | Cyclomatic complexity exceeds threshold | ≤ 10 | +| `CX-003` | Magic-number literal in non-const context | (any literal not in `const`/`static`) | +| `CX-004` | Function length exceeds threshold | ≤ 60 lines | +| `CX-005` | Nesting depth exceeds threshold | ≤ 4 | +| `CX-006` | Unsafe block detected | `detect_unsafe = true` | +| `A20` | Error-handling issue (`unwrap`/`expect`/`panic!`/`todo!`) | `detect_error_handling = true` | + +`A20` and `CX-004` skip `#[test]` functions and workspace-root `tests/**` files. + +## DRY + +| Code | Meaning | +|---|---| +| `DRY-001` | Duplicate function (95%+ token similarity) | +| `DRY-002` | Dead code — function defined but never called | +| `DRY-003` | Duplicate code fragment (≥6 lines repeated) | +| `DRY-004` | Wildcard import (`use foo::*;`) | +| `DRY-005` | Repeated match pattern across functions (≥3 arms, ≥3 instances) | + +## Boilerplate (BP-*) + +| Code | Meaning | +|---|---| +| `BP-001` | Trivial `From` impl (derivable) | +| `BP-002` | Trivial `Display` impl (derivable) | +| `BP-003` | Trivial getter/setter (consider field visibility) | +| `BP-004` | Builder pattern (consider derive macro) | +| `BP-005` | Manual `Default` impl (derivable) | +| `BP-006` | Repetitive match mapping | +| `BP-007` | Error enum boilerplate (consider `thiserror`) | +| `BP-008` | Clone-heavy conversion | +| `BP-009` | Struct-update boilerplate | +| `BP-010` | Format-string repetition | + +## SRP + +| Code | Meaning | +|---|---| +| `SRP-001` | Struct may violate Single Responsibility Principle (composite: fields + methods + cohesion) | +| `SRP-002` | Module file too long (default warn 300, hard 800 production lines) | +| `SRP-003` | Function has too many parameters (default > 5) | + +## Coupling + +| Code | Meaning | +|---|---| +| `CP-001` | Circular module dependency | +| `CP-002` | Stable Dependencies Principle violation | + +## Structural binary checks + +Part of SRP (BTC, SLM, NMS) and Coupling (OI, SIT, DEH, IET). + +| Code | Meaning | +|---|---| +| `BTC` | Broken trait contract — every method in an `impl Trait` block is a stub | +| `SLM` | Selfless method — takes `self` but never references it | +| `NMS` | Needless `&mut self` — declares mutable receiver but never mutates | +| `OI` | Orphaned impl — `impl Foo` block in different file from `struct Foo` | +| `SIT` | Single-impl trait — non-`pub` trait with exactly one implementation | +| `DEH` | Downcast escape hatch — use of `Any::downcast` | +| `IET` | Inconsistent error types within a module | + +## Test Quality (TQ-*) + +| Code | Meaning | +|---|---| +| `TQ-001` | Test function has no assertions | +| `TQ-002` | Test function does not call any production function | +| `TQ-003` | Production function is untested (no test calls it) | +| `TQ-004` | Production function has no coverage (LCOV-based, requires `--coverage`) | +| `TQ-005` | Untested logic branches — covered function with uncovered lines | + +## Architecture + +Architecture findings carry the originating rule kind and the source rule's name: + +| Code | Meaning | +|---|---| +| `ARCH-LAYER` | Layer rule violation — file imports outside its allowed direction | +| `ARCH-FORBID` | Forbidden-edge violation — `[[architecture.forbidden]]` rule fired | +| `ARCH-PATTERN` | Symbol-pattern violation — `[[architecture.pattern]]` rule fired | +| `ARCH-TRAIT` | Trait-contract violation — `[[architecture.trait_contract]]` rule fired | +| `ARCH-CALL-PARITY` | Call-parity violation — Check A (no delegation) or Check B (missing adapter) | + +## Suppression / governance + +| Code | Meaning | +|---|---| +| `SUP-001` | Suppression ratio exceeds configured maximum (default 5%). Warn by default; error with `--fail-on-warnings`. | +| `ORPHAN-001` | Stale `qual:allow` marker — no finding in the annotation window. | + +## Severity & default-fail + +By default, every finding fails the build (exit code `1`). Override with `--no-fail` for local exploration, or `--min-quality-score ` to allow some findings as long as the overall score holds. + +Warnings (`SUP-001`) don't fail by default — pass `--fail-on-warnings` to flip that. + +## Related + +- [reference-configuration.md](./reference-configuration.md) — every config option in `rustqual.toml` +- [reference-suppression.md](./reference-suppression.md) — `qual:allow`, `qual:api`, etc. +- [function-quality.md](./function-quality.md) — IOSP, CX, A20 +- [module-quality.md](./module-quality.md) — SRP-* +- [coupling-quality.md](./coupling-quality.md) — CP-*, OI, SIT, DEH, IET +- [code-reuse.md](./code-reuse.md) — DRY-*, BP-* +- [test-quality.md](./test-quality.md) — TQ-* +- [architecture-rules.md](./architecture-rules.md) — ARCH-* +- [adapter-parity.md](./adapter-parity.md) — ARCH-CALL-PARITY diff --git a/book/reference-suppression.md b/book/reference-suppression.md new file mode 100644 index 0000000..14ee58f --- /dev/null +++ b/book/reference-suppression.md @@ -0,0 +1,165 @@ +# Reference: suppression annotations + +Five annotation forms, ordered from most-restricted to least: + +| Annotation | Scope | Counts against `max_suppression_ratio` | +|---|---|---| +| `// qual:allow` | All dimensions | Yes | +| `// qual:allow()` | One dimension (`iosp`, `complexity`, `dry`, `srp`, `coupling`, `test_quality`, `architecture`) | Yes | +| `// qual:allow(unsafe)` | `CX-006` only | No | +| `// qual:api` | Excludes from `DRY-002`, `TQ-003` | No | +| `// qual:test_helper` | Excludes from `DRY-002` (testonly), `TQ-003` | No | +| `// qual:inverse()` | Suppresses near-duplicate `DRY-001` for inverse pairs | No | +| `// qual:recursive` | Removes self-calls from own-calls before leaf reclassification | No | + +Each annotation lives in a `//`-comment block immediately above the item it applies to. The block extends upward until a blank line or a non-`//` line breaks it. `#[derive(...)]` and other attributes between the comment block and the item are fine — they don't break the block. + +## `// qual:allow` — suppress all dimensions + +```rust +// qual:allow — autogenerated, hand-edits forbidden +pub fn _generated_table() -> &'static [Entry] { /* … */ } +``` + +Useful for files / functions that genuinely cannot be reasoned about by any quality dimension. Counts against `max_suppression_ratio`. + +## `// qual:allow()` — suppress one dimension + +```rust +// qual:allow(iosp) — match dispatcher; arms intentionally inlined +fn dispatch(cmd: Command) -> Result<()> { + match cmd { + Command::Sync => sync_handler(), + Command::Diff => diff_handler(), + } +} + +// qual:allow(complexity) — large lookup table; splitting hurts readability +fn rule_table() -> &'static [Rule] { /* … */ } + +// qual:allow(architecture) — port adapter must call registry directly here +// for serialization round-trip; pure domain accessor would lose ordering. +use crate::adapters::registry::lookup; +``` + +Always pair with a rationale (`— `). Reviewers and future-you need to know *why* the rule is being bypassed. + +Legacy `// iosp:allow` is an alias for `// qual:allow(iosp)`. + +## `// qual:allow(unsafe)` — for `CX-006` specifically + +```rust +// qual:allow(unsafe) — FFI boundary, audited 2026-Q1 +unsafe fn raw_call() { /* … */ } +``` + +Separate path that does *not* count against `max_suppression_ratio`. Intended for FFI shims and low-level optimisations where `unsafe` is intrinsic. + +## `// qual:api` — public API entry points + +```rust +// qual:api — public re-export, callers live outside this crate +pub fn parse_config(input: &str) -> Result { /* … */ } +``` + +Excludes from: + +- `DRY-002` (dead code) — function isn't dead, it's exported. +- `TQ-003` (untested) — function may be tested by downstream consumers. + +Other dimensions still apply (complexity, IOSP, etc.). Doesn't count against `max_suppression_ratio`. + +## `// qual:test_helper` — `src/` helpers used only from `tests/` + +```rust +// qual:test_helper +pub fn assert_in_range(actual: f64, expected: f64, tol: f64) { + assert!((actual - expected).abs() < tol); +} +``` + +Same exclusions as `qual:api` (`DRY-002`, `TQ-003`). Use when a helper lives in `src/` so it's importable from integration tests in `tests/`, but isn't called from any production code. + +Differs from `ignore_functions` in `rustqual.toml`: `ignore_functions` silences *every* dimension on a function, while `qual:test_helper` only silences DRY-002 and TQ-003 — complexity / SRP / IOSP all still apply. + +## `// qual:inverse()` — inverse method pairs + +```rust +// qual:inverse(decode) +pub fn encode(input: &Value) -> Vec { /* … */ } + +// qual:inverse(encode) +pub fn decode(input: &[u8]) -> Result { /* … */ } +``` + +Suppresses the near-duplicate `DRY-001` finding between encode/decode pairs whose structural similarity is intentional. The annotation must be reciprocal — both functions name each other. Doesn't count against `max_suppression_ratio`. + +## `// qual:recursive` — recursive helpers + +```rust +// qual:recursive +fn collect_descendants(node: &Node, out: &mut Vec) { + out.push(node.id); + for child in &node.children { + collect_descendants(child, out); // self-call: ignored for IOSP reclassification + } +} +``` + +Removes self-calls from `own_calls` before the leaf-reclassification pass. Useful for tree/graph helpers that don't otherwise violate IOSP. Doesn't count against `max_suppression_ratio`. + +## Module-level suppression + +For *coupling* findings (which are module-global, not function-local), use the inner-doc form: + +```rust +//! qual:allow(coupling) — orchestration layer, intentionally depends on every adapter. + +use crate::adapters::a; +use crate::adapters::b; +// … +``` + +The `//!` form attaches to the module, not to a single item. + +## Suppression ratio (`SUP-001`) + +The `max_suppression_ratio` config (default 5%) caps how much of the codebase can be under `qual:allow`. Once you exceed that ratio, `SUP-001` warns (or errors with `--fail-on-warnings`). + +The intent is that suppression is for legitimate exceptions, not a backdoor. If you're hitting the cap, the right response is either: + +- Refactor the suppressed functions, or +- Loosen a threshold so the underlying findings stop firing legitimately, or +- Raise `max_suppression_ratio` *with intent* (and document why in `rustqual.toml`). + +Don't silently raise it to make the warning go away. The whole point of the cap is to keep suppression from drifting upward over months. + +## Orphan detection (`ORPHAN-001`) + +A `// qual:allow(...)` marker that *doesn't match a finding in its window* emits `ORPHAN-001`. This catches stale annotations after a refactor — the underlying issue is gone, but the suppression is still there. + +The detector reads raw complexity metrics against config thresholds, not the `*_warning` flags that suppressions clear. So if you bump a threshold, the finding stops firing, *and* the orphan check then flags the now-redundant suppression. Coupling-only markers are skipped because coupling warnings are module-global. + +`ORPHAN-001` is visible in every output format and counts toward `total_findings()`, `--fail-on-warnings`, and default-fail. + +## Composition-root and reexport-point modules + +Files marked as reexport points in `[architecture.reexport_points]` (typically `lib.rs`, `main.rs`, `bin/**`, `cli/**`, `tests/**`) bypass the layer rule entirely — no `qual:allow` needed. Use this for the composition root that wires layers together. + +## Summary: when to use which + +| You want… | Use | +|---|---| +| To skip one finding in production code temporarily | `// qual:allow()` with rationale | +| To mark a function as exposed externally | `// qual:api` | +| To mark a `src/` helper used only from `tests/` | `// qual:test_helper` | +| To accept structurally-similar inverse pairs | `// qual:inverse()` | +| To allow a recursive helper through IOSP | `// qual:recursive` | +| To accept FFI / `unsafe` blocks | `// qual:allow(unsafe)` | +| To exempt a whole module from coupling | `//! qual:allow(coupling)` | + +## Related + +- [reference-rules.md](./reference-rules.md) — every rule code each annotation can suppress +- [reference-configuration.md](./reference-configuration.md) — `max_suppression_ratio`, `ignore_functions`, layer rules +- [legacy-adoption.md](./legacy-adoption.md) — adoption patterns using suppressions vs baselines diff --git a/book/test-quality.md b/book/test-quality.md new file mode 100644 index 0000000..1da240c --- /dev/null +++ b/book/test-quality.md @@ -0,0 +1,154 @@ +# Use case: test quality + +Coverage is a number; *test quality* is whether the tests actually catch regressions. A 95%-covered codebase with assertion-free tests is worse than a 60%-covered one with sharp ones — the former gives false confidence, the latter is honest about what's checked. + +rustqual's Test Quality dimension measures both. It runs static checks on every test function (assertion presence, SUT calls, untested production functions) and — when LCOV coverage data is supplied — adds branch-level uncovered-logic detection. + +## What goes wrong + +- **Tests without assertions.** `#[test] fn it_works() { run_thing(); }` exercises code without checking anything. Coverage looks great, real coverage is zero. +- **Tests that don't call the SUT.** A test in `src/auth/tests/login.rs` that only constructs values and asserts on them — but never calls `login()` — isn't testing anything in `auth`. +- **Untested production functions.** A `pub fn` with no test anywhere — neither unit, nor integration — slipped through the review. +- **Uncovered logic branches.** A function is "tested" but the `else` arm of its main `if` was never executed. + +## What rustqual catches + +| Rule | Meaning | +|---|---| +| `TQ-001` | Test function has no assertions | +| `TQ-002` | Test function does not call any production function | +| `TQ-003` | Production function is untested (no test anywhere calls it) | +| `TQ-004` | Production function has no coverage (LCOV-based, requires `--coverage`) | +| `TQ-005` | Untested logic branches — covered function with uncovered lines | + +`TQ-001`, `TQ-002`, `TQ-003` are static and run on every analysis. `TQ-004` and `TQ-005` need LCOV data. + +## Static checks + +### TQ-001 — assertion-free tests + +A test is anything with `#[test]`, `#[tokio::test]`, etc. rustqual scans the function body for: + +- `assert!`, `assert_eq!`, `assert_ne!`, `debug_assert*!` (and configurable extras via `extra_assertion_macros`) +- Custom prefixes — anything starting with `assert*` or `debug_assert*` matches + +If none are present, `TQ-001` fires. The fix is usually to add an assertion; if the test exists for compile-time/typecheck reasons only, mark it: + +```rust +// qual:allow(test_quality) — compile-time check only, no runtime assertion needed +#[test] fn signatures_compile() { + let _: fn(&str) -> Result = parse; +} +``` + +### TQ-002 — tests without SUT + +A test that constructs values and asserts on them but never calls a production function. Usually a hint that: + +- the test is testing the test fixtures, not the system under test, or +- the SUT call moved out during a refactor and the test stayed. + +The check looks at all calls in the test body and verifies at least one resolves to a function inside `src/` (excluding test helpers). + +### TQ-003 — untested production functions + +Reverse-walk: for every `pub fn` in `src/`, is there a test (anywhere) that calls it? + +Three escape hatches: + +```rust +// qual:api — public-API entry, callers live outside this crate +pub fn parse_config(input: &str) -> Result { /* … */ } + +// qual:test_helper — used only from tests/ +pub fn build_test_session() -> Session { /* … */ } + +// qual:allow(test_quality) — initialised at startup, untestable in isolation +pub fn install_signal_handlers() { /* … */ } +``` + +`qual:api` and `qual:test_helper` don't count against `max_suppression_ratio`. They mirror the same annotations under DRY-002 (dead code), since a function that's untested *and* unused is usually one finding category, not two. + +## Coverage-based checks + +For TQ-004 and TQ-005, supply LCOV coverage data: + +```bash +cargo install cargo-llvm-cov +cargo llvm-cov --lcov --output-path coverage.lcov +rustqual --coverage coverage.lcov +``` + +### TQ-004 — uncovered production functions + +A `pub fn` whose lines never executed during tests. Differs from `TQ-003` in that it considers *runtime* coverage, not just static call graph: a function might be statically referenced from a test but the test path never executes its body. + +### TQ-005 — uncovered logic branches + +The most surgical of the test-quality checks. For functions that *are* covered, find the **uncovered lines** and report them as "untested logic". This is where coverage gaps actually hurt: a 95%-covered function whose 5% is the error-handling branch is one production incident away from a regression. + +``` +⚠ TQ-005 src/auth/login.rs::authenticate (line 48) + covered: 22/26 lines (84.6%) + uncovered branches at: 51, 52, 67, 88 +``` + +## Configure + +```toml +[test_quality] +enabled = true +# extra_assertion_macros = ["verify", "check", "expect_that"] +``` + +For projects with custom assertion DSLs (`mockall`, `proptest`, in-house frameworks), add the macro names to `extra_assertion_macros` so TQ-001 doesn't flag tests using them. + +## What you'll see + +``` +✗ TQ-001 tests/auth_test.rs::test_login (line 12) — no assertions +✗ TQ-002 tests/parser_test.rs::test_ast (line 38) — does not call any production fn +✗ TQ-003 src/api/handlers.rs::cmd_admin_purge (line 89) — untested +⚠ TQ-004 src/utils/format.rs::pad_left (line 22) — no coverage (LCOV) +⚠ TQ-005 src/auth/login.rs::authenticate (line 48) — 4 uncovered branches +``` + +## Strategy + +The TQ rules form a ladder, lightest to strictest: + +1. **TQ-001 / TQ-002** — fix first. Cheap, mechanical, eliminates fake tests. +2. **TQ-003** — second. Either write a test, mark `qual:api`, or delete the unused function. +3. **TQ-004** — once you have coverage in CI. Catches functions that compile but never run. +4. **TQ-005** — top of the ladder. Forces real branch-level testing. + +Most teams sit at level 2-3 and turn 4-5 on as the coverage culture matures. The dimension's quality score reflects all five, weighted; you don't have to enable every check on day one. + +## CI integration + +```yaml +- run: cargo install rustqual cargo-llvm-cov +- run: cargo llvm-cov --lcov --output-path lcov.info +- run: rustqual --coverage lcov.info --format github +``` + +Inline PR annotations show *exactly which test* lacks an assertion or *which production function* is uncovered. The agent or developer can fix the specific gap rather than guess from a coverage percentage. + +## Why this matters for AI-generated code + +AI agents are notorious for generating tests that exercise code without checking it — `let result = foo(); println!("{result:?}");` looks like a test, passes the type-checker, and bumps the coverage number. `TQ-001` catches that mechanically every PR. + +Pair it with the agent instruction file pattern from [ai-coding-workflow.md](./ai-coding-workflow.md): + +> Every test function must contain at least one assertion (`assert!`, `assert_eq!`, etc.). +> For public-API functions that are intentionally untested in this crate, mark with `// qual:api` instead of writing a stub test. + +The agent self-corrects; reviewer time goes to the actual logic, not to spotting fake tests. + +## Related + +- [function-quality.md](./function-quality.md) — IOSP and complexity for the production code being tested +- [code-reuse.md](./code-reuse.md) — `DRY-002` (dead code) and `TQ-003` (untested) share the call graph +- [ai-coding-workflow.md](./ai-coding-workflow.md) — agent instruction template that includes assertion rules +- [reference-rules.md](./reference-rules.md) — every rule code with details +- [reference-suppression.md](./reference-suppression.md) — `qual:api`, `qual:test_helper`, `qual:allow(test_quality)`