hunyi 0.5.0

渾儀 (Hunyi) — Tianheng's semantic (AST/syn) observation dimension, the complement of the static import boundary. Declare in Rust how a module's public surface must behave: what its API must not expose (types — including named public re-exports and, opt-in, a trait impl's impl-site positions — and no dyn / impl Trait or async fn seam), where a trait may be implemented, that it declares no bare pub, and which markers a type must not acquire — observed via syn, reacted in CI. The heavy syn dependency is quarantined here, never in the core.
Documentation
//! Impl-trait (existential) exposure (`semantic-impl-trait-boundary`): a module's public API must
//! not return a written `impl Trait` (RPIT). Shape-only when no operands are named; operand-scoped
//! when a forbidden set is given (resolve each returned `impl Trait`'s principal traits).

use std::path::{Path, PathBuf};

use serde_json::Value;
use xuanji::{Outcome, Polarity, Violation};

use crate::collect::collect_item_return_impl_traits;
use crate::crate_scope::{
    ExternResolution, FileExternScope, dependency_names, extern_resolution, file_extern_scope,
};
use crate::driver::run_boundaries;
use crate::dsl::ImplTraitBoundary;
use crate::emit::{
    MultiModuleViolationContext, SingleModuleViolationContext, push_multi_module_violations,
    push_single_module_violations,
};
use crate::errors::unknown_module_error;
use crate::file_scope::{is_anchor_absent_from_unit, resolve_crate_units};
use crate::finding::{ExposureKind, SemanticFact, shape_finding, sort_attributed_facts};
use crate::resolve::{
    ShapeExposure, UseMap, canonical_path_str, collect_uses, validate_path_operands,
};
use crate::rules::IMPL_TRAIT_RULE;
use crate::scan::walk_subtree_modules;
use crate::shape_scan::{
    matches_forbidden_principal, operand_module_findings, shape_module_findings,
};

/// Run the impl-trait boundaries against the Cargo workspace at `manifest_path`.
///
/// Mirrors [`crate::check_dyn_trait`]: resolve each boundary's crate and module anchor, observe the
/// module's public-API **return** positions for written `impl Trait` (RPIT) nodes at any depth,
/// and react. An unresolvable crate or module (or an unreadable/unparseable source) is a
/// constitution error (exit 2), never a silent pass. The shell composes via [`crate::check_all`].
pub fn check_impl_trait(boundaries: &[ImplTraitBoundary], manifest_path: &Path) -> Outcome {
    run_boundaries(boundaries, manifest_path, check_impl_trait_boundary)
}

pub(crate) fn check_impl_trait_boundary(
    metadata: &Value,
    boundary: &ImplTraitBoundary,
    violations: &mut Vec<Violation>,
) -> Result<(), String> {
    let (package, units) = resolve_crate_units(metadata, &boundary.crate_package)?;
    // Each of a package's crate roots is its own compilation unit: same module path `crate`,
    // separate module graph. Evaluated once per unit so an exposure in a `bin` beside a library
    // is observed, with the unit carried into each finding's identity.
    let mut governed_somewhere = false;
    let mut deferred: Option<String> = None;
    for (root_file, src_dir, unit) in &units {
        let unit_outcome = (|| -> Result<(), String> {
            let src_dir = src_dir.as_path();
            let unit = unit.as_str();
            let rule_key = boundary.rule_key();

            // Subtree opt-in: descend the anchored module's whole subtree, emitting per-module findings.
            // The default path governs only the anchored module's own seam (byte-identical to before).
            if boundary.including_submodules() {
                let findings = if boundary.forbidden_operands.is_empty() {
                    impl_trait_subtree_findings(
                        src_dir,
                        root_file,
                        &boundary.module,
                        &boundary.crate_package,
                    )?
                } else {
                    impl_trait_operand_subtree_findings(
                        src_dir,
                        root_file,
                        &boundary.module,
                        &boundary.forbidden_operands,
                        &boundary.crate_package,
                        &dependency_names(package),
                    )?
                };
                push_multi_module_violations(
                    violations,
                    MultiModuleViolationContext {
                        target: &boundary.module,
                        rule: IMPL_TRAIT_RULE,
                        rule_key,
                        reason: &boundary.reason,
                        severity: boundary.severity,
                        anchor: boundary.anchor(),
                        polarity: Polarity::DenyBreach,
                        crate_package: &boundary.crate_package,
                        unit,
                    },
                    findings,
                );
                return Ok(());
            }

            // Empty operand set ⇒ shape-only (any returned impl Trait), via the resolution-free path; a
            // named set ⇒ operand-scoped, resolving each returned impl Trait's principal trait.
            let findings = if boundary.forbidden_operands.is_empty() {
                impl_trait_module_findings(
                    src_dir,
                    root_file,
                    &boundary.module,
                    &boundary.crate_package,
                )?
            } else {
                impl_trait_operand_module_findings(
                    src_dir,
                    root_file,
                    &boundary.module,
                    &boundary.forbidden_operands,
                    &boundary.crate_package,
                    &dependency_names(package),
                )?
            };

            push_single_module_violations(
                violations,
                SingleModuleViolationContext {
                    module: &boundary.module,
                    rule: IMPL_TRAIT_RULE,
                    rule_key,
                    reason: &boundary.reason,
                    severity: boundary.severity,
                    anchor: boundary.anchor(),
                    crate_package: &boundary.crate_package,
                    unit,
                },
                findings,
            );
            Ok(())
        })();
        match unit_outcome {
            Ok(()) => governed_somewhere = true,
            Err(reason)
                if is_anchor_absent_from_unit(
                    &reason,
                    &unknown_module_error(&boundary.module, &boundary.crate_package),
                ) =>
            {
                if deferred.is_none() {
                    deferred = Some(reason);
                }
            }
            Err(reason) => return Err(reason),
        }
    }
    match deferred {
        Some(reason) if !governed_somewhere => Err(reason),
        _ => Ok(()),
    }
}

/// The pure heart of the **subtree** impl-trait reaction: walk the anchored module's whole subtree
/// and return the sorted, deduplicated `(finding, enclosing module, file)` triples — every returned
/// `impl Trait` at or below the anchor, each attributed to the module that declares it AND the real
/// file that module's own branch was resolved from (never re-resolved afterward from the module
/// string alone, which misattributes a finding once two `#[cfg]`-split branches share one module
/// path). The subtree analogue of [`impl_trait_module_findings`]: same per-item collector
/// ([`collect_item_return_impl_traits`], so a seam finding is byte-identical to the single-module
/// path), applied at every module the subtree walk yields.
///
/// The `ordinal` passed to the collector is ONE counter incrementing continuously across every
/// item the subtree walk yields — never reset per module or per branch (unlike async's collector,
/// which ignores it). See `semantic-impl-trait-boundary`'s "Subtree scope opt-in" requirement's
/// unrenderable-Self-type paragraph for why a positional fallback is never published as identity
/// (`reject_positional_identity`, invoked by [`sort_attributed_facts`] below, fails the whole
/// reaction loud instead); a non-unique ordinal would still let two genuinely distinct unrenderable
/// sites collide into one internal value before that gate ever runs, so it must be threaded
/// correctly rather than relying on the gate alone.
pub(crate) fn impl_trait_subtree_findings(
    src_dir: &Path,
    root_file: &Path,
    module: &str,
    crate_package: &str,
) -> Result<Vec<(SemanticFact, String, PathBuf)>, String> {
    let modules = walk_subtree_modules(src_dir, root_file, module, crate_package)?;
    collect_impl_trait_subtree_findings(modules, &ImplTraitSubtreeFilter::Any)
}

/// Operand-scoped subtree analogue of [`impl_trait_operand_module_findings`]. Module traversal
/// remains owned by [`walk_subtree_modules`]; each returned branch builds its own `use` and
/// extern-shadow scope while sharing one crate-wide extern/re-export resolution. This keeps
/// mutually-exclusive cfg branches isolated and applies the same principal matcher as the
/// single-module operand reaction.
pub(crate) fn impl_trait_operand_subtree_findings(
    src_dir: &Path,
    root_file: &Path,
    module: &str,
    forbidden: &[String],
    crate_package: &str,
    dep_names: &[String],
) -> Result<Vec<(SemanticFact, String, PathBuf)>, String> {
    // A forbidden operand with an empty `::`-segment could never match a resolved canonical
    // principal — checked before any resolution work, exactly as the non-subtree operand path
    // (`shape_scan::operand_module_findings`) guards its own forbidden set.
    validate_path_operands(forbidden)?;
    let modules = walk_subtree_modules(src_dir, root_file, module, crate_package)?;
    let resolution = extern_resolution(src_dir, root_file, crate_package, dep_names)?;
    let filter = ImplTraitSubtreeFilter::Forbidden {
        resolution,
        paths: forbidden
            .iter()
            .map(|path| canonical_path_str(path))
            .collect(),
    };
    collect_impl_trait_subtree_findings(modules, &filter)
}

enum ImplTraitSubtreeFilter {
    Any,
    Forbidden {
        resolution: ExternResolution,
        paths: Vec<String>,
    },
}

impl ImplTraitSubtreeFilter {
    fn retain(
        &self,
        module: &str,
        uses: &UseMap,
        file_scope: Option<&FileExternScope>,
        exposures: &mut Vec<ShapeExposure>,
    ) {
        let Self::Forbidden { resolution, paths } = self else {
            return;
        };
        let file_scope =
            file_scope.expect("a forbidden subtree filter precomputes one scope per module");
        exposures.retain(|exposure| {
            matches_forbidden_principal(exposure, uses, module, resolution, file_scope, paths)
        });
    }
}

fn collect_impl_trait_subtree_findings(
    modules: Vec<(String, Vec<syn::Item>, PathBuf)>,
    filter: &ImplTraitSubtreeFilter,
) -> Result<Vec<(SemanticFact, String, PathBuf)>, String> {
    let mut findings = Vec::new();
    let mut ordinal = 0usize;

    for (mod_path, items, file) in &modules {
        let uses = collect_uses(items);
        let file_scope = match filter {
            ImplTraitSubtreeFilter::Any => None,
            ImplTraitSubtreeFilter::Forbidden { resolution, .. } => {
                Some(file_extern_scope(resolution, items))
            }
        };
        for item in items {
            let mut collected = Vec::new();
            collect_item_return_impl_traits(item, mod_path, &uses, ordinal, &mut collected);
            ordinal += 1;
            filter.retain(mod_path, &uses, file_scope.as_ref(), &mut collected);
            findings.extend(collected.into_iter().map(|exposure| {
                (
                    shape_finding(exposure, ExposureKind::ImplTrait),
                    mod_path.clone(),
                    file.clone(),
                )
            }));
        }
    }

    sort_attributed_facts(&mut findings)?;
    Ok(findings)
}

/// The pure heart of impl-trait-boundary, testable without spawning `cargo`: resolve the module's
/// items and return the sorted, deduplicated rendered `impl …` shapes appearing in a **return
/// position** of the module's public functions/methods. Shape-only, so no name resolution is
/// involved. Governs return positions only — argument-position `impl Trait` (APIT) is universal,
/// not existential, and is never visited; a trait-*impl* method's return is dictated by the trait
/// declaration (governed there), so it is excluded.
pub(crate) fn impl_trait_module_findings(
    src_dir: &Path,
    root_file: &Path,
    module: &str,
    crate_package: &str,
) -> Result<Vec<(SemanticFact, PathBuf)>, String> {
    shape_module_findings(
        src_dir,
        root_file,
        module,
        crate_package,
        |item, module, uses, ordinal, out| {
            collect_item_return_impl_traits(item, module, uses, ordinal, out);
            Ok(())
        },
        |exposure| shape_finding(exposure, ExposureKind::ImplTrait),
    )
}

/// The pure heart of the **operand-scoped** impl-trait boundary: like [`impl_trait_module_findings`]
/// but keeps only the returned `impl Trait` nodes **any of whose non-auto traits** resolves into
/// the forbidden operand set. See `semantic-impl-trait-operand-boundary`'s "A returned impl Trait
/// of a forbidden operand is a violation" requirement for the full multi-trait-bound and
/// resolver-coverage rationale, and its "Empty operand set degenerates to shape-only" requirement
/// for the empty-set behavior. The exact pipeline `dyn_trait::dyn_operand_module_findings` uses
/// (`resolve_principal` → `expand_canonical_paths` → `matches_forbidden`).
pub(crate) fn impl_trait_operand_module_findings(
    src_dir: &Path,
    root_file: &Path,
    module: &str,
    forbidden: &[String],
    crate_package: &str,
    dep_names: &[String],
) -> Result<Vec<(SemanticFact, PathBuf)>, String> {
    operand_module_findings(
        src_dir,
        root_file,
        module,
        forbidden,
        crate_package,
        dep_names,
        (ExposureKind::ImplTrait, collect_item_return_impl_traits),
    )
}