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
//! Shared scan hearts for the existential-exposure boundaries (dyn-trait, impl-trait,
//! async-exposure). Each boundary's per-module finding computation is the same skeleton — resolve
//! the module's items, walk each with a per-item collector, then render, sort, and dedup — differing
//! only in the collector (and, for the operand-scoped path, an added principal-resolution filter).
//! These two helpers hold that skeleton once so the three boundary modules pass only their
//! collector, rather than each re-implementing the identical pipeline.

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

use crate::containment::matches_forbidden;
use crate::crate_scope::{extern_resolution, file_extern_scope, resolve_principal};
use crate::finding::{ExposureKind, SemanticFact, shape_finding, sort_faceted_facts};
use crate::module_resolve::resolve_module_items_with_files;
use crate::resolve::{
    ShapeExposure, UseMap, canonical_path_str, collect_uses, validate_path_operands,
};

/// A `use`-map per BRANCH, not one shared map over the flattened cross-branch union: two
/// mutually-exclusive `#[cfg]` branches are never compiled together, so merging their `use`
/// declarations into one map lets the branch unioned last silently overwrite an earlier branch's
/// alias for the same local name (a realistic per-platform shim), misresolving a bare reference in
/// the FIRST branch through the SECOND branch's `use` — a confirmed false negative, found on a
/// round-6 adversarial review; see `PROJECT.md`'s Decisions. Keyed on the branch index, not the
/// file alone: two mutually-exclusive **inline** `#[cfg]` siblings share one identical enclosing
/// file, so a file-keyed map would re-merge them — the identical conflation one hop past item
/// observation, found on a round-8 adversarial review; see `PROJECT.md`'s Decisions.
/// Group `items_with_files` by their branch index, dropping the file. Keyed on the branch index
/// rather than file alone: two mutually-exclusive inline `#[cfg]` siblings share one identical
/// enclosing file, so a file-keyed map would re-merge them (round-8 adversarial review; see
/// `PROJECT.md`'s Decisions). Shared by [`uses_by_branch`] and [`operand_module_findings`], which
/// each derive a different per-branch map (a `UseMap`, a `FileExternScope`) from the identical
/// grouping.
fn group_items_by_branch(
    items_with_files: &[(syn::Item, PathBuf, usize)],
) -> HashMap<usize, Vec<syn::Item>> {
    let mut items_by_branch: HashMap<usize, Vec<syn::Item>> = HashMap::new();
    for (item, _file, branch) in items_with_files {
        items_by_branch
            .entry(*branch)
            .or_default()
            .push(item.clone());
    }
    items_by_branch
}

fn uses_by_branch(items_with_files: &[(syn::Item, PathBuf, usize)]) -> HashMap<usize, UseMap> {
    group_items_by_branch(items_with_files)
        .iter()
        .map(|(branch, branch_items)| (*branch, collect_uses(branch_items)))
        .collect()
}

/// Whether any principal trait on `exposure` resolves into `forbidden` in this module branch.
///
/// Both the single-module and subtree operand reactions use this leaf so their resolution
/// semantics cannot drift. The caller owns branch isolation: `uses` and `file_scope` must both
/// come from the exact branch that produced `exposure`.
pub(crate) fn matches_forbidden_principal(
    exposure: &ShapeExposure,
    uses: &UseMap,
    module: &str,
    resolution: &crate::crate_scope::ExternResolution,
    file_scope: &crate::crate_scope::FileExternScope,
    forbidden: &[String],
) -> bool {
    forbidden.is_empty()
        || exposure.principals.iter().any(|path| {
            resolve_principal(path, uses, module, resolution, file_scope)
                .iter()
                .any(|canonical| matches_forbidden(canonical, forbidden))
        })
}

/// Resolve `module`'s items, collect each item's exposures with `collect`, render each to a finding
/// with `render`, then sort + dedup. The shape-only heart shared by the dyn / impl-trait / async
/// boundaries: `collect` is the only per-boundary difference. The dyn and impl-trait boundaries
/// collect [`ShapeExposure`] and pass [`shape_finding`] as `render`; async collects owner-qualified
/// `String` identities directly and passes the identity `render`. `uses` is collected because the
/// collectors canonicalize an inherent impl's self-type owner in the seam (a finding-identity
/// concern), even where the boundary itself needs no name resolution. A collector may fail loud
/// when it cannot produce non-positional identity. Each finding pairs with the
/// real file its own item's branch was resolved from — never a single first-branch file for the
/// whole module, which would misattribute a finding produced by a non-first `#[cfg]`-split branch.
pub(crate) fn shape_module_findings<E>(
    src_dir: &Path,
    root_file: &Path,
    module: &str,
    crate_package: &str,
    collect: impl Fn(&syn::Item, &str, &UseMap, usize, &mut Vec<E>) -> Result<(), String>,
    render: impl Fn(E) -> SemanticFact,
) -> Result<Vec<(SemanticFact, PathBuf)>, String> {
    let items_with_files =
        resolve_module_items_with_files(src_dir, root_file, module, crate_package)?;
    let uses_by_branch = uses_by_branch(&items_with_files);
    let mut collected: Vec<(E, PathBuf)> = Vec::new();
    for (ordinal, (item, file, branch)) in items_with_files.iter().enumerate() {
        let uses = &uses_by_branch[branch];
        let mut buf = Vec::new();
        collect(item, module, uses, ordinal, &mut buf)?;
        collected.extend(buf.into_iter().map(|exposure| (exposure, file.clone())));
    }
    let mut findings: Vec<(SemanticFact, PathBuf)> = collected
        .into_iter()
        .map(|(exposure, file)| (render(exposure), file))
        .collect();
    sort_faceted_facts(&mut findings)?;
    Ok(findings)
}

/// The operand-scoped heart shared by the dyn / impl-trait boundaries: like
/// [`shape_module_findings`] over [`ShapeExposure`], but additionally resolves each exposure's
/// principal traits via `resolve_principal` → `matches_forbidden` and keeps only those any of
/// whose principal resolves into `forbidden`. See each boundary's "Empty operand set degenerates
/// to shape-only, never a silent no-op" requirement for why an empty `forbidden` set keeps every
/// exposure rather than acting as an inert no-op, and its "A dyn/returned impl Trait of a
/// forbidden operand is a violation" requirement for the unresolvable-principal resolver-coverage
/// bound. `collect` is the only per-boundary difference. Each finding pairs with the real file its
/// own item's branch was resolved from, like [`shape_module_findings`].
pub(crate) fn operand_module_findings(
    src_dir: &Path,
    root_file: &Path,
    module: &str,
    forbidden: &[String],
    crate_package: &str,
    dep_names: &[String],
    (fact_kind, collect): (
        ExposureKind,
        impl Fn(&syn::Item, &str, &UseMap, usize, &mut Vec<ShapeExposure>),
    ),
) -> Result<Vec<(SemanticFact, PathBuf)>, String> {
    // A forbidden operand with an empty `::`-segment could never match a resolved canonical
    // principal — checked before any resolution work, exactly as `exposure::module_findings`
    // guards its own forbidden set (both share the identical `extern_verbatim_renamed` resolver,
    // which never produces a leading-`::` canonical path).
    validate_path_operands(forbidden)?;
    let items_with_files =
        resolve_module_items_with_files(src_dir, root_file, module, crate_package)?;
    let uses_by_branch = uses_by_branch(&items_with_files);
    // Per-branch, not crate-wide and not per-file: `externs_type`/`renames_bare` derive from a
    // specific branch's own child-module names, so a #[cfg]-split module's several branches must
    // never share one (see `file_extern_scope`'s doc — the identical conflation class round 6 fixed
    // for the use-map).
    let items_by_branch = group_items_by_branch(&items_with_files);
    let resolution = extern_resolution(src_dir, root_file, crate_package, dep_names)?;
    let file_scopes: HashMap<usize, crate::crate_scope::FileExternScope> = items_by_branch
        .iter()
        .map(|(branch, branch_items)| (*branch, file_extern_scope(&resolution, branch_items)))
        .collect();
    let forbidden: Vec<String> = forbidden.iter().map(|f| canonical_path_str(f)).collect();

    let mut exposures: Vec<(ShapeExposure, PathBuf, usize)> = Vec::new();
    for (ordinal, (item, file, branch)) in items_with_files.iter().enumerate() {
        let uses = &uses_by_branch[branch];
        let mut buf = Vec::new();
        collect(item, module, uses, ordinal, &mut buf);
        exposures.extend(
            buf.into_iter()
                .map(|exposure| (exposure, file.clone(), *branch)),
        );
    }

    let mut findings: Vec<(SemanticFact, PathBuf)> = exposures
        .into_iter()
        .filter(|(exposure, _file, branch)| {
            let uses = &uses_by_branch[branch];
            let file_scope = &file_scopes[branch];
            matches_forbidden_principal(exposure, uses, module, &resolution, file_scope, &forbidden)
        })
        .map(|(exposure, file, _branch)| (shape_finding(exposure, fact_kind), file))
        .collect();
    sort_faceted_facts(&mut findings)?;
    Ok(findings)
}