hunyi 0.4.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
//! Dyn-trait exposure (`semantic-dyn-trait-boundary`): a module's public API must not expose
//! trait-object (`dyn`) syntax. Shape-only when no operands are named (react on the *presence* of a
//! `dyn` node); operand-scoped when a forbidden set is given (resolve each `dyn`'s principal trait).

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

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

use crate::collect::collect_item_dyn_exposures;
use crate::crate_scope::dependency_names;
use crate::driver::run_boundaries;
use crate::dsl::DynTraitBoundary;
use crate::emit::{SingleModuleViolationContext, 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};
use crate::rules::DYN_TRAIT_RULE;
use crate::shape_scan::{operand_module_findings, shape_module_findings};

/// Run the dyn-trait boundaries against the Cargo workspace at `manifest_path`.
///
/// Mirrors [`crate::check`]: resolve each boundary's crate and module anchor, observe the module's
/// public-API surface for trait-object (`dyn`) 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 per-capability entry remains for direct use; the
/// shell composes via [`crate::check_all`].
pub fn check_dyn_trait(boundaries: &[DynTraitBoundary], manifest_path: &Path) -> Outcome {
    run_boundaries(boundaries, manifest_path, check_dyn_trait_boundary)
}

pub(crate) fn check_dyn_trait_boundary(
    metadata: &Value,
    boundary: &DynTraitBoundary,
    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();

            // Empty operand set ⇒ shape-only (any dyn), using the resolution-free path unchanged; a
            // named set ⇒ operand-scoped, resolving each dyn's principal trait against the forbidden set.
            let findings = if boundary.forbidden_operands.is_empty() {
                dyn_module_findings(
                    src_dir,
                    root_file,
                    &boundary.module,
                    &boundary.crate_package,
                )?
            } else {
                dyn_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: DYN_TRAIT_RULE,
                    rule_key: boundary.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 dyn-trait-boundary, testable without spawning `cargo`: resolve the
/// module's items and return the sorted, deduplicated rendered `dyn` shapes exposed in its
/// public surface. The *reaction* is on the *presence* of a `dyn` node (shape-only), so it needs
/// no name resolution and no re-export closure — `pub use`-chain following is inert for a `dyn`
/// (a re-export carries a name, never a `dyn` node). The `use`-map it does collect serves only to
/// canonicalize an inherent impl's self-type **owner** in the seam (a finding-identity concern,
/// not detection); no re-export closure is needed for that.
pub(crate) fn dyn_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_dyn_exposures(item, module, uses, ordinal, out);
            Ok(())
        },
        |exposure| shape_finding(exposure, ExposureKind::DynTrait),
    )
}

/// The pure heart of the **operand-scoped** dyn-trait boundary: like [`dyn_module_findings`]
/// but keeps only the `dyn` nodes whose **principal (non-auto) trait** resolves into the forbidden
/// operand set. See `semantic-dyn-trait-operand-boundary`'s "A dyn of a forbidden trait operand is
/// a violation" requirement for the full principal-trait-matching and resolver-coverage rationale.
/// Resolved and canonicalized via [`crate::crate_scope::resolve_principal`] →
/// `expand_canonical_paths` → `matches_forbidden`, exactly as `exposure::module_findings` resolves
/// an exposed type.
pub(crate) fn dyn_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::DynTrait, collect_item_dyn_exposures),
    )
}