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
//! Async-fn (implicit existential) exposure (`semantic-async-exposure-boundary`): a module's
//! public API must not declare an `async fn`. Shape-only — observed from `sig.asyncness`, no name
//! resolution.

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

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

use crate::collect::collect_item_async_exposures;
use crate::driver::run_boundaries;
use crate::dsl::AsyncExposureBoundary;
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::{SemanticFact, sort_attributed_facts};
use crate::resolve::collect_uses;
use crate::rules::ASYNC_EXPOSURE_RULE;
use crate::scan::walk_subtree_modules;
use crate::shape_scan::shape_module_findings;

/// Run the async-exposure boundaries against the Cargo workspace at `manifest_path`.
///
/// Mirrors [`crate::check_impl_trait`]: resolve each boundary's crate and module anchor, observe the
/// module's public-API `async fn` declarations, and react. An unresolvable crate or module (or an
/// unreadable/unparseable source) is a constitution error (exit 2). The shell composes via
/// [`crate::check_all`].
pub fn check_async_exposure(boundaries: &[AsyncExposureBoundary], manifest_path: &Path) -> Outcome {
    run_boundaries(boundaries, manifest_path, check_async_exposure_boundary)
}

pub(crate) fn check_async_exposure_boundary(
    metadata: &Value,
    boundary: &AsyncExposureBoundary,
    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 = async_exposure_subtree_findings(
                    src_dir,
                    root_file,
                    &boundary.module,
                    &boundary.crate_package,
                )?;
                push_multi_module_violations(
                    violations,
                    MultiModuleViolationContext {
                        target: &boundary.module,
                        rule: ASYNC_EXPOSURE_RULE,
                        rule_key,
                        reason: &boundary.reason,
                        severity: boundary.severity,
                        anchor: boundary.anchor(),
                        polarity: Polarity::DenyBreach,
                        crate_package: &boundary.crate_package,
                        unit,
                    },
                    findings,
                );
                return Ok(());
            }

            let findings = async_exposure_module_findings(
                src_dir,
                root_file,
                &boundary.module,
                &boundary.crate_package,
            )?;

            push_single_module_violations(
                violations,
                SingleModuleViolationContext {
                    module: &boundary.module,
                    rule: ASYNC_EXPOSURE_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** async-exposure reaction: walk the anchored module's whole
/// subtree and return the sorted, deduplicated `(finding, enclosing module, file)` triples — every
/// public `async fn` 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 [`async_exposure_module_findings`]: same per-module
/// collector ([`collect_item_async_exposures`], so a seam finding is byte-identical to the
/// single-module path), applied at every module the subtree walk yields.
///
/// Owner identity is structural and never position-derived — see `semantic-async-exposure-boundary`'s
/// "The finding is an owner-qualified item identity" requirement for why an unrenderable owner
/// fails loud rather than falling back to a cfg-branch or subtree-order ordinal.
pub(crate) fn async_exposure_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)?;
    let mut findings: Vec<(SemanticFact, String, PathBuf)> = Vec::new();
    for (mod_path, items, file) in &modules {
        let uses = collect_uses(items);
        for item in items {
            let mut collected = Vec::new();
            collect_item_async_exposures(item, mod_path, &uses, 0, &mut collected)?;
            findings.extend(
                collected
                    .into_iter()
                    .map(|finding| (finding, mod_path.clone(), file.clone())),
            );
        }
    }
    sort_attributed_facts(&mut findings)?;
    Ok(findings)
}

/// The pure heart of async-exposure-boundary: resolve the module's items and return the sorted,
/// deduplicated **owner-qualified** identities of the public `async fn`s it declares — public free
/// fns, public inherent methods, and public trait method declarations (observed from
/// `sig.asyncness`). Trait-*impl* methods (asyncness dictated by the trait) and private items are
/// excluded. Shape-only: no name resolution, no return-type walk.
pub(crate) fn async_exposure_module_findings(
    src_dir: &Path,
    root_file: &Path,
    module: &str,
    crate_package: &str,
) -> Result<Vec<(SemanticFact, PathBuf)>, String> {
    // async collectors emit owner-qualified `String` identities directly, so the shared shape heart
    // renders with the identity function (no `shape_finding` map, unlike the dyn / impl-trait path).
    shape_module_findings(
        src_dir,
        root_file,
        module,
        crate_package,
        collect_item_async_exposures,
        |identity| identity,
    )
}