hunyi 0.6.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
use std::path::PathBuf;

use xuanji::{BoundaryKind, Polarity, RuleKey, Severity, Violation, ViolationId};

use crate::finding::SemanticFact;

pub(crate) struct SingleModuleViolationContext<'a> {
    pub(crate) module: &'a str,
    pub(crate) rule: &'a str,
    pub(crate) rule_key: RuleKey,
    pub(crate) reason: &'a str,
    pub(crate) severity: Severity,
    pub(crate) anchor: Option<&'a str>,
    /// The crate this boundary was declared against (`boundary.crate_package`) — threaded into the
    /// fact's identity so two crates sharing the identical module path + rule stay distinct.
    pub(crate) crate_package: &'a str,
    /// The compilation unit these observations came from — see `SemanticFact::into_finding`.
    pub(crate) unit: &'a str,
}

/// The common shape both violation-pushing entry points below build once, then feed to
/// [`push_violation`] per finding — the single place that assembles a `Violation`. `target` and
/// `crate_package` map identity (`ViolationId::new` / `finding.into_finding`); `rule`, `reason`,
/// `severity`, `anchor`, and `polarity` are metadata attached to every finding under this context.
struct ViolationContext<'a> {
    /// The compilation unit these observations came from — see `SemanticFact::into_finding`.
    unit: &'a str,
    target: &'a str,
    rule: &'a str,
    rule_key: RuleKey,
    reason: &'a str,
    severity: Severity,
    anchor: Option<String>,
    polarity: Polarity,
    crate_package: &'a str,
}

/// Convert one finding into a `Violation` and push it — the single assembly point both
/// [`push_single_module_violations`] and [`push_multi_module_violations`] share.
fn push_violation(
    violations: &mut Vec<Violation>,
    context: &ViolationContext<'_>,
    finding: SemanticFact,
    file: PathBuf,
) {
    let finding = finding.into_finding(context.crate_package, context.unit);
    let id = ViolationId::new(
        context.target,
        context.rule_key.clone(),
        finding.fact().clone(),
    );
    violations.push(
        Violation::new(
            BoundaryKind::Semantic,
            id,
            context.rule,
            finding.text(),
            context.reason.to_string(),
            context.severity,
        )
        .with_file(Some(file.display().to_string()))
        .with_anchor(context.anchor.clone())
        .with_polarity(context.polarity),
    );
}

/// Add deny-style violations for a boundary whose findings all sit on one governed module seam.
/// Each finding carries the real file its own item's branch was resolved from (see
/// [`crate::module_resolve::resolve_module_items_with_files`]) — never a single, first-branch file
/// for the whole module, which would misattribute a finding produced by a non-first `#[cfg]`-split
/// branch.
/// Every capability supplies `(target, rule key, structured fact)` identity; presentation, file,
/// anchor, and polarity remain metadata.
pub(crate) fn push_single_module_violations(
    violations: &mut Vec<Violation>,
    context: SingleModuleViolationContext<'_>,
    findings: Vec<(SemanticFact, PathBuf)>,
) {
    let shared = ViolationContext {
        target: context.module,
        rule: context.rule,
        rule_key: context.rule_key,
        reason: context.reason,
        severity: context.severity,
        anchor: context.anchor.map(str::to_string),
        polarity: Polarity::DenyBreach,
        crate_package: context.crate_package,
        unit: context.unit,
    };
    for (finding, file) in findings {
        push_violation(violations, &shared, finding, file);
    }
}

pub(crate) struct MultiModuleViolationContext<'a> {
    /// The violation `target` — the boundary's anchored module, kept stable so identity
    /// `(target, rule key, structured fact)` does not shift as the governed subtree grows.
    pub(crate) target: &'a str,
    pub(crate) rule: &'a str,
    pub(crate) rule_key: RuleKey,
    pub(crate) reason: &'a str,
    pub(crate) severity: Severity,
    pub(crate) anchor: Option<&'a str>,
    /// The finding's polarity metadata (deny-breach vs allowlist-gap). Not part of the violation
    /// identity, so each capability passes its own without shifting structured identity.
    pub(crate) polarity: Polarity,
    /// The crate this boundary was declared against (`boundary.crate_package`) — threaded into the
    /// fact's identity so two crates sharing the identical anchor + rule stay distinct.
    /// `unsafe_confinement`'s own fact conversion ignores this (its `target` above is already
    /// `boundary.crate_package`, so its identity already varies by crate); every other capability
    /// routed through this context consumes it.
    pub(crate) crate_package: &'a str,
    /// The compilation unit these observations came from — see `SemanticFact::into_finding`.
    pub(crate) unit: &'a str,
}

/// Add violations for a boundary whose findings sit across many modules — the shared emitter for
/// every whole-crate-scan capability (forbidden-marker, trait-impl, unsafe-confinement, and the
/// async-exposure subtree branch), of either polarity: each caller supplies its own `polarity` via
/// the context. Each finding carries its enclosing module (metadata, never part of the identity)
/// AND the real file that module's own branch was resolved from, collected directly at the site
/// (`ImplSite`/`TypeDef`/`UnsafeSite`, or the subtree walker's own per-branch file) rather than
/// re-resolved afterward by module string — a re-resolution keyed only by the module string
/// misattributes a finding whenever two `#[cfg]`-split branches share one module path — the same
/// shape [`push_single_module_violations`]'s doc names, one hop further downstream. The violation
/// `target` stays the
/// boundary's anchor, so a finding's structured identity is stable.
pub(crate) fn push_multi_module_violations(
    violations: &mut Vec<Violation>,
    context: MultiModuleViolationContext<'_>,
    findings: Vec<(SemanticFact, String, PathBuf)>,
) {
    let shared = ViolationContext {
        target: context.target,
        rule: context.rule,
        rule_key: context.rule_key,
        reason: context.reason,
        severity: context.severity,
        anchor: context.anchor.map(str::to_string),
        polarity: context.polarity,
        crate_package: context.crate_package,
        unit: context.unit,
    };
    for (finding, _module, file) in findings {
        push_violation(violations, &shared, finding, file);
    }
}