Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

mikronova/modgunn

src/scip.rs

//! Engine B — SCIP whole-world dep-tree **behaviour-delta** + tripwires.
//!
//! Takes in the whole world (or a product's transitive closure) and keeps delta
//! track of **every** change in the code tree — including deep transitive
//! behaviour change ("a 5th-level dependency jar that hasn't changed in years
//! suddenly changed behaviour"). Heavy by design (128GB–1TB RAM); dynamic plane
//! only — never compiled into static-holger.
//!
//! ## modgunn owns its graph input
//! modgunn does **not** depend on nornir's SCIP types (that would recreate the
//! cycle). Instead it defines its own minimal graph vocabulary — [`Symbol`],
//! [`CallEdge`], [`DepNode`], [`DepEdge`], [`VulnSink`], [`Capability`] — that a
//! producer (nornir's SCIP/knowledge extractor) populates. A [`WorldSnapshot`] is
//! an immutable bundle of those facts at one point in time.
//!
//! ## The analyses (catalog families F1–F7)
//! - [`reachability_gated_vulns`] — **A8**: a CVE only fires if its vulnerable
//!   symbol is call-reachable from an entrypoint (kills version-only false
//!   positives).
//! - [`blast_radius`] / [`BlastRadiusTripwire`] — **A13**: PageRank centrality over
//!   the dep-graph → monitoring priority.
//! - [`capability_surface`] — **A2/A9**: the dangerous-capability set reachable
//!   from entrypoints.
//! - [`diff`] folds the **delta** tripwires: [`CallGraphDeltaTripwire`] (A1),
//!   [`CapabilityDriftTripwire`] (A9-drift / A10), [`SinkNewlyReachableTripwire`]
//!   (A8-delta, family-1 #5), [`FingerprintDriftTripwire`] (A22) and
//!   [`ObfuscationTripwire`] (A17 — entropy spike) into a [`BehaviorDelta`].
//!
//! ## Point-in-time analyses (catalog families F2–F5)
//! - [`diamond_conflicts`] — **A15**: the same package resolved at ≥2 versions in one
//!   tree (a diamond / multi-version conflict).
//! - [`sink_chokepoints`] — **A14**: the single-vertex cuts on every entrypoint→sink
//!   path — the chokepoints you pin/patch to sever the most reachability.
//! - [`bus_factor_findings`] — **A12**: a highly-central dep with a single maintainer
//!   (a single point of failure), correlating [`blast_radius`] × maintainer count.
//! - [`typosquat_findings`] — **A8-supply**: names one edit away from a popular
//!   package (typosquat) or an internal namespace resolvable publicly
//!   (dependency-confusion).
//! - [`dep_fanin_outliers`] — **A16**: dep-graph fan-in metric outliers (z-score over
//!   the in-degree distribution).
//! - [`release_cadence_anomalies`] — **family-2 #7**: per-package release
//!   inter-arrival outliers — a long-dormant package suddenly publishing (a
//!   takeover-shaped cadence anomaly), via the same z-score primitive.
//! - [`owner_cluster_findings`] — **A13-cluster (family-3 #13)**: label-propagation
//!   community detection over the co-ownership graph ([`DepNode::owners`]) — deps
//!   controlled by one entity are correlated, not independent, risk.
//! - [`change_velocity_anomalies`] — **family-7 #25**: per-package release-rate
//!   (churn-velocity) bursts — a rolling trailing-window release count whose spike
//!   is a z-score outlier vs the package's own change-rate history.
//! - [`license_transition_findings`] — **family-5 #19**: a dep that relicenses to a
//!   riskier license across its version history ([`DepNode::license_history`]),
//!   ranked by [`license_risk_tier`] (MIT→AGPL rug-pulls, slides to proprietary).
//! - [`embedding_novelty_findings`] — **family-4 #18**: a package version whose code
//!   embedding ([`DepNode::embedding`]) sits far (cosine distance) from the ecosystem
//!   norm — semantically-novel / freshly-injected code.
//! - [`taint_path_findings`] — **taint-path (source→sink)**: call-graph paths from
//!   an untrusted [`Symbol::taint_source`] to a sensitive-capability sink.
//!
//! Every analysis emits [`core::Finding`](crate::core::Finding)s, folded into a
//! [`core::Verdict`](crate::core::Verdict) via [`verdict_from`], plus the
//! [`BehaviorDelta`] is the world-delta tripwire event stream.

use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};

use crate::core::{ArtifactRef, Decision, Finding, Severity, Verdict};

/// A dangerous capability a symbol can directly exercise (the labelled sink set
/// the capability-surface closure A9 tracks).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Capability {
    Network,
    Filesystem,
    Process,
    Env,
    DynamicCode,
    Reflection,
    Ffi,
    Unsafe,
    Clock,
    Rng,
}

impl Capability {
    /// A short stable label for findings.
    pub fn label(&self) -> &'static str {
        match self {
            Capability::Network => "network",
            Capability::Filesystem => "filesystem",
            Capability::Process => "process",
            Capability::Env => "env",
            Capability::DynamicCode => "dynamic-code",
            Capability::Reflection => "reflection",
            Capability::Ffi => "ffi",
            Capability::Unsafe => "unsafe",
            Capability::Clock => "clock",
            Capability::Rng => "rng",
        }
    }

    /// Inverse of [`label`](Capability::label): parse a capability back from its
    /// stable label — used to reconstruct a [`Symbol`] from the persisted
    /// `scip_symbols` table when reloading a [`WorldSnapshot`] from the warehouse.
    /// An unknown label yields `None` (skipped, never a wrong capability).
    pub fn from_label(s: &str) -> Option<Self> {
        Some(match s.trim() {
            "network" => Capability::Network,
            "filesystem" => Capability::Filesystem,
            "process" => Capability::Process,
            "env" => Capability::Env,
            "dynamic-code" => Capability::DynamicCode,
            "reflection" => Capability::Reflection,
            "ffi" => Capability::Ffi,
            "unsafe" => Capability::Unsafe,
            "clock" => Capability::Clock,
            "rng" => Capability::Rng,
            _ => return None,
        })
    }
}

/// A SCIP symbol / node in the call graph. `id` is a stable moniker (cross-repo
/// symbol identity); `capabilities` are the sinks this symbol *directly* exercises.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Symbol {
    pub id: String,
    pub display: String,
    /// Owning package identity (e.g. `cargo:serde@1.0.0`) — used to tell whether a
    /// behaviour change came with a version change (A22).
    pub package: String,
    pub capabilities: BTreeSet<Capability>,
    pub is_entrypoint: bool,
    /// Whether this symbol is an **untrusted taint source** — a point where
    /// untrusted input enters (a network / IPC / deserialization read). The
    /// source side of the taint-path analysis ([`taint_path_findings`]). Defaults
    /// to `false`; when no symbol is marked, the analysis simply doesn't fire.
    pub is_taint_source: bool,
}

impl Symbol {
    /// A plain (non-entrypoint, capability-free) symbol.
    pub fn new(id: impl Into<String>, package: impl Into<String>) -> Self {
        let id = id.into();
        Self {
            display: id.clone(),
            id,
            package: package.into(),
            capabilities: BTreeSet::new(),
            is_entrypoint: false,
            is_taint_source: false,
        }
    }
    pub fn entrypoint(mut self) -> Self {
        self.is_entrypoint = true;
        self
    }
    /// Mark this symbol as an untrusted taint source (the source side of
    /// [`taint_path_findings`]).
    pub fn taint_source(mut self) -> Self {
        self.is_taint_source = true;
        self
    }
    pub fn with_capability(mut self, c: Capability) -> Self {
        self.capabilities.insert(c);
        self
    }
}

/// A directed call edge `from` → `to` (by [`Symbol::id`]).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CallEdge {
    pub from: String,
    pub to: String,
}

impl CallEdge {
    pub fn new(from: impl Into<String>, to: impl Into<String>) -> Self {
        Self {
            from: from.into(),
            to: to.into(),
        }
    }
}

/// A resolved dependency-graph node (one package@version).
// Not `Eq`: `embedding` carries `f32` (family-4 #18), which is only `PartialEq`.
#[derive(Debug, Clone, PartialEq)]
pub struct DepNode {
    pub key: String,
    pub ecosystem: String,
    pub name: String,
    pub version: String,
    /// Whether this dep carries verified provenance (drives the A13 priority bump).
    pub verified: bool,
    /// Number of distinct maintainers/owners of this package, if known — the
    /// bus-factor input (A12). `None` when registry metadata was not captured, in
    /// which case [`bus_factor_findings`] simply doesn't fire on this node.
    pub maintainers: Option<u32>,
    /// Publish timestamps (Unix epoch **seconds**) of **all** known releases of
    /// this package — the release-cadence input (family-2 #7). Sourced from
    /// registry metadata / the warehouse historized series, not from the resolved
    /// closure (one snapshot pins a single version), so a producer attaches the
    /// package's full publish history here. Empty when it was not captured, in
    /// which case [`release_cadence_anomalies`] simply doesn't fire on this node.
    pub release_epochs: Vec<i64>,
    /// Distinct owner / publisher identities of this package (registry account
    /// handles, org ids), if known — the owner-cluster input (family-3 #13).
    /// Sourced from registry ownership metadata, not the resolved closure. Empty
    /// when it was not captured, in which case [`owner_cluster_findings`] simply
    /// doesn't consider this node.
    pub owners: Vec<String>,
    /// Per-version SPDX license history of this package in **release order**
    /// (`(version, spdx)` pairs), if known — the license-transition input
    /// (family-5 #19). Sourced from registry / SBOM metadata across the package's
    /// versions, not the resolved closure (one snapshot pins one version), so a
    /// producer attaches the full license timeline here. Empty (or a single point)
    /// when it was not captured, in which case [`license_transition_findings`]
    /// simply doesn't fire on this node.
    pub license_history: Vec<(String, String)>,
    /// The package-version **code embedding** (a dense semantic vector from the
    /// nornir embedder), if computed — the embedding-novelty input (family-4 #18).
    /// modgunn does not run the embedder itself (that is nornir's job); a producer
    /// attaches the vector here, the same honest-optional-metadata way `owners` /
    /// `release_epochs` are supplied. `None` when not computed, in which case
    /// [`embedding_novelty_findings`] simply doesn't consider this node.
    pub embedding: Option<Vec<f32>>,
}

impl DepNode {
    pub fn new(ecosystem: &str, name: &str, version: &str) -> Self {
        Self {
            key: format!("{ecosystem}:{name}@{version}"),
            ecosystem: ecosystem.into(),
            name: name.into(),
            version: version.into(),
            verified: false,
            maintainers: None,
            release_epochs: Vec::new(),
            owners: Vec::new(),
            license_history: Vec::new(),
            embedding: None,
        }
    }

    /// Attach this package's distinct owner/publisher identities from registry
    /// ownership metadata — the owner-cluster input (family-3 #13). Order is
    /// irrelevant; [`owner_cluster_findings`] treats them as a set.
    pub fn with_owners<S: Into<String>>(mut self, owners: impl IntoIterator<Item = S>) -> Self {
        self.owners = owners.into_iter().map(Into::into).collect();
        self
    }

    /// Record the maintainer/owner count from registry metadata (the A12 input).
    pub fn with_maintainers(mut self, n: u32) -> Self {
        self.maintainers = Some(n);
        self
    }

    /// Attach this package's release publish timestamps (Unix epoch seconds) from
    /// registry metadata — the release-cadence input (family-2 #7). Order is
    /// irrelevant; [`release_cadence_anomalies`] sorts before differencing.
    pub fn with_release_epochs(mut self, epochs: impl IntoIterator<Item = i64>) -> Self {
        self.release_epochs = epochs.into_iter().collect();
        self
    }

    /// Attach this package's per-version SPDX license history in **release order**
    /// (`(version, spdx)` pairs) from registry / SBOM metadata — the
    /// license-transition input (family-5 #19).
    pub fn with_license_history<V: Into<String>, L: Into<String>>(
        mut self,
        history: impl IntoIterator<Item = (V, L)>,
    ) -> Self {
        self.license_history = history
            .into_iter()
            .map(|(v, l)| (v.into(), l.into()))
            .collect();
        self
    }

    /// Attach this package-version's code embedding (dense semantic vector from the
    /// nornir embedder) — the embedding-novelty input (family-4 #18).
    pub fn with_embedding(mut self, v: impl Into<Vec<f32>>) -> Self {
        self.embedding = Some(v.into());
        self
    }

    /// Mark this dep as carrying verified provenance (the A13 priority input).
    pub fn verified(mut self) -> Self {
        self.verified = true;
        self
    }
}

/// A directed dependency edge `from` depends-on `to` (by [`DepNode::key`]).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DepEdge {
    pub from: String,
    pub to: String,
}

impl DepEdge {
    pub fn new(from: impl Into<String>, to: impl Into<String>) -> Self {
        Self {
            from: from.into(),
            to: to.into(),
        }
    }
}

/// A vulnerable-function sink: [`Symbol::id`] is the function an advisory's CVE
/// lives in. Reachability to this node is what A8 gates on.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VulnSink {
    pub symbol: String,
    pub advisory: String,
    pub severity: Severity,
}

#[cfg(feature = "scan")]
impl VulnSink {
    /// Build a reachability sink from an OSV **symbol-level** record — the Go vuln
    /// DB's `ecosystem_specific.imports` (see [`crate::osv::parse_osv_vuln_symbols`]).
    /// This is the production seed for [`WorldSnapshot::vuln_sinks`]: without it the
    /// A8 gate has nothing real to gate on. The call-graph node id follows Go's
    /// fully-qualified convention `<import-path>.<symbol>` (a method arrives from OSV
    /// as `Type.Method`, giving `path.Type.Method`) — the SAME id a Go SCIP indexer
    /// emits for [`Symbol::id`], so [`reachability_gated_vulns`] joins by string
    /// equality. A package-level record (`symbol == ""`) has no single function to
    /// gate on and yields `None`; the present-on-disk base join covers whole-package
    /// vulnerabilities instead.
    pub fn from_vuln_symbol(vs: &crate::osv::VulnSymbol) -> Option<Self> {
        if vs.symbol.is_empty() || vs.package.is_empty() {
            return None;
        }
        Some(VulnSink {
            symbol: format!("{}.{}", vs.package, vs.symbol),
            advisory: vs.id.clone(),
            severity: vs.severity,
        })
    }
}

/// An immutable snapshot of the (sub-)world: the SCIP call graph + the resolved
/// dep-graph + the known vulnerable sinks, keyed by `id` so deltas are exact.
// Not `Eq`: holds `DepNode`s whose `embedding` (#18) is `f32` / only `PartialEq`.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct WorldSnapshot {
    pub id: String,
    pub symbols: Vec<Symbol>,
    pub calls: Vec<CallEdge>,
    pub dep_nodes: Vec<DepNode>,
    pub dep_edges: Vec<DepEdge>,
    pub vuln_sinks: Vec<VulnSink>,
}

impl WorldSnapshot {
    pub fn new(id: impl Into<String>) -> Self {
        Self {
            id: id.into(),
            ..Default::default()
        }
    }

    /// Forward call-graph adjacency: caller-id → callee-ids.
    fn call_adjacency(&self) -> HashMap<&str, Vec<&str>> {
        let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
        for e in &self.calls {
            adj.entry(&e.from).or_default().push(&e.to);
        }
        adj
    }

    fn entrypoints(&self) -> Vec<&str> {
        self.symbols
            .iter()
            .filter(|s| s.is_entrypoint)
            .map(|s| s.id.as_str())
            .collect()
    }

    /// The set of symbol ids reachable from `sources` over the call graph (multi-
    /// source BFS).
    fn reachable_from<'a>(&'a self, sources: &[&'a str]) -> HashSet<&'a str> {
        let adj = self.call_adjacency();
        let mut seen: HashSet<&str> = HashSet::new();
        let mut q: VecDeque<&str> = VecDeque::new();
        for s in sources {
            if seen.insert(s) {
                q.push_back(s);
            }
        }
        while let Some(n) = q.pop_front() {
            if let Some(next) = adj.get(n) {
                for &m in next {
                    if seen.insert(m) {
                        q.push_back(m);
                    }
                }
            }
        }
        seen
    }

    /// The symbol ids reachable from this snapshot's entrypoints.
    fn reachable_from_entrypoints(&self) -> HashSet<&str> {
        let eps = self.entrypoints();
        self.reachable_from(&eps)
    }

    /// [`reachable_from`], but treating `excluded` as removed from the graph — the
    /// single-vertex-cut probe A14 ([`sink_chokepoints`]) uses to test whether a node
    /// is a chokepoint (its removal disconnects the entrypoints from a sink).
    fn reachable_from_excluding<'a>(
        &'a self,
        sources: &[&'a str],
        excluded: &str,
    ) -> HashSet<&'a str> {
        let adj = self.call_adjacency();
        let mut seen: HashSet<&str> = HashSet::new();
        let mut q: VecDeque<&str> = VecDeque::new();
        for s in sources {
            if *s != excluded && seen.insert(s) {
                q.push_back(s);
            }
        }
        while let Some(n) = q.pop_front() {
            if let Some(next) = adj.get(n) {
                for &m in next {
                    if m != excluded && seen.insert(m) {
                        q.push_back(m);
                    }
                }
            }
        }
        seen
    }

    fn symbol(&self, id: &str) -> Option<&Symbol> {
        self.symbols.iter().find(|s| s.id == id)
    }
}

/// A behaviour change detected between two [`WorldSnapshot`]s — the world-delta
/// tripwire event stream.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BehaviorDelta {
    pub from: String,
    pub to: String,
    pub findings: Vec<Finding>,
}

impl BehaviorDelta {
    /// The strongest gate decision implied by the delta's findings.
    pub fn decision(&self) -> Decision {
        fold_decision(&self.findings)
    }
}

/// The gate [`Decision`] implied by a finding's severity (same table Engine A
/// uses; duplicated here so Engine B needs no `scan` feature).
fn decision_for(severity: Severity) -> Decision {
    match severity {
        Severity::Critical | Severity::High => Decision::Block,
        Severity::Medium | Severity::Low => Decision::Warn,
        Severity::Info => Decision::Pass,
    }
}

fn fold_decision(findings: &[Finding]) -> Decision {
    findings
        .iter()
        .map(|f| decision_for(f.severity))
        .max()
        .unwrap_or(Decision::Pass)
}

/// Fold a set of Engine-B findings into a [`Verdict`] for `artifact` — the same
/// verdict shape Engine A emits, so consumers handle both engines uniformly.
pub fn verdict_from(artifact: ArtifactRef, findings: Vec<Finding>, snapshot_id: &str) -> Verdict {
    let decision = fold_decision(&findings);
    Verdict {
        artifact,
        decision,
        findings,
        policy_version: format!("engine-b@{snapshot_id}"),
        advisory_db_rev: snapshot_id.to_string(),
    }
}

// ── A8 — reachability-gated vuln prioritization ──────────────────────────────

/// **A8**: only emit a finding for a [`VulnSink`] whose vulnerable symbol is
/// call-reachable from an entrypoint. This is the precision win — version-only
/// matchers fire on every present CVE; this fires only on the reachable ones.
pub fn reachability_gated_vulns(snap: &WorldSnapshot) -> Vec<Finding> {
    let reachable = snap.reachable_from_entrypoints();
    snap.vuln_sinks
        .iter()
        .filter(|s| reachable.contains(s.symbol.as_str()))
        .map(|s| Finding {
            severity: s.severity,
            id: s.advisory.clone(),
            summary: format!("reachable vulnerable symbol `{}`", s.symbol),
        })
        .collect()
}

/// The vuln sinks **not** reachable from entrypoints (the noise A8 suppresses) —
/// surfaced as `Info` so a consumer can still see what was present-but-unreachable.
pub fn unreachable_vulns(snap: &WorldSnapshot) -> Vec<Finding> {
    let reachable = snap.reachable_from_entrypoints();
    snap.vuln_sinks
        .iter()
        .filter(|s| !reachable.contains(s.symbol.as_str()))
        .map(|s| Finding {
            severity: Severity::Info,
            id: s.advisory.clone(),
            summary: format!("unreachable vulnerable symbol `{}` (suppressed)", s.symbol),
        })
        .collect()
}

// ── A2/A9 — capability surface ───────────────────────────────────────────────

/// **A2/A9**: the set of dangerous [`Capability`]s reachable from entrypoints over
/// the call graph (the labelled-sink reachability closure).
pub fn capability_surface(snap: &WorldSnapshot) -> BTreeSet<Capability> {
    let reachable = snap.reachable_from_entrypoints();
    let mut caps = BTreeSet::new();
    for s in &snap.symbols {
        if reachable.contains(s.id.as_str()) {
            caps.extend(s.capabilities.iter().copied());
        }
    }
    caps
}

// ── A13 — blast-radius centrality (PageRank over the dep-graph) ───────────────

/// **A13**: PageRank centrality over the dep-graph, returned descending by score.
/// A highly-central dep, if compromised, touches the most surface → monitoring
/// priority. `damping` 0.85 / 40 iterations is the standard configuration.
pub fn blast_radius(snap: &WorldSnapshot) -> Vec<(String, f64)> {
    let nodes: Vec<&str> = snap.dep_nodes.iter().map(|n| n.key.as_str()).collect();
    let n = nodes.len();
    if n == 0 {
        return Vec::new();
    }
    let idx: HashMap<&str, usize> = nodes.iter().enumerate().map(|(i, &k)| (k, i)).collect();
    // out-edges per node
    let mut out: Vec<Vec<usize>> = vec![Vec::new(); n];
    for e in &snap.dep_edges {
        if let (Some(&a), Some(&b)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) {
            out[a].push(b);
        }
    }
    let d = 0.85;
    let base = (1.0 - d) / n as f64;
    let mut rank = vec![1.0 / n as f64; n];
    for _ in 0..40 {
        let mut next = vec![base; n];
        let mut dangling = 0.0;
        for i in 0..n {
            if out[i].is_empty() {
                dangling += rank[i];
            } else {
                let share = d * rank[i] / out[i].len() as f64;
                for &j in &out[i] {
                    next[j] += share;
                }
            }
        }
        // redistribute dangling mass uniformly
        let dshare = d * dangling / n as f64;
        for v in next.iter_mut() {
            *v += dshare;
        }
        rank = next;
    }
    let mut scored: Vec<(String, f64)> = nodes
        .iter()
        .enumerate()
        .map(|(i, &k)| (k.to_string(), rank[i]))
        .collect();
    scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
    scored
}

/// **A13** as findings: flag the top-`k` most central deps; an **unverified**
/// central dep is escalated to `Medium` (the bus-factor / blast-radius priority).
pub fn blast_radius_findings(snap: &WorldSnapshot, k: usize) -> Vec<Finding> {
    let verified: HashMap<&str, bool> = snap
        .dep_nodes
        .iter()
        .map(|n| (n.key.as_str(), n.verified))
        .collect();
    blast_radius(snap)
        .into_iter()
        .take(k)
        .map(|(key, score)| {
            let unverified = !verified.get(key.as_str()).copied().unwrap_or(false);
            Finding {
                severity: if unverified {
                    Severity::Medium
                } else {
                    Severity::Info
                },
                id: format!("MODGUNN-BLAST-RADIUS:{key}"),
                summary: format!(
                    "blast-radius centrality {score:.4}{}",
                    if unverified { " (unverified dep)" } else { "" }
                ),
            }
        })
        .collect()
}

// ── A15 — diamond / multi-version conflict ───────────────────────────────────

/// **A15**: the same `(ecosystem, name)` resolved at **two or more** versions in the
/// tree — a diamond dependency. Each conflict is a finding listing the versions;
/// escalated to `High` when any of the conflicting nodes is **unverified** (an
/// unverified version silently pulled into a diamond is the risky case), else `Low`.
pub fn diamond_conflicts(snap: &WorldSnapshot) -> Vec<Finding> {
    // (ecosystem, name) → set of (version, verified)
    let mut by_pkg: HashMap<(&str, &str), BTreeSet<String>> = HashMap::new();
    let mut any_unverified: HashMap<(&str, &str), bool> = HashMap::new();
    for n in &snap.dep_nodes {
        let k = (n.ecosystem.as_str(), n.name.as_str());
        by_pkg.entry(k).or_default().insert(n.version.clone());
        let e = any_unverified.entry(k).or_insert(false);
        *e = *e || !n.verified;
    }
    let mut out = Vec::new();
    // Deterministic order (BTree over the key) so findings are stable across runs.
    let mut keys: Vec<_> = by_pkg.keys().copied().collect();
    keys.sort_unstable();
    for k in keys {
        let versions = &by_pkg[&k];
        if versions.len() < 2 {
            continue;
        }
        let unverified = any_unverified.get(&k).copied().unwrap_or(false);
        out.push(Finding {
            severity: if unverified {
                Severity::High
            } else {
                Severity::Low
            },
            id: format!("MODGUNN-DIAMOND:{}:{}", k.0, k.1),
            summary: format!(
                "`{}` ({}) resolved at {} versions [{}]{}",
                k.1,
                k.0,
                versions.len(),
                versions.iter().cloned().collect::<Vec<_>>().join(", "),
                if unverified {
                    " — an unverified version is in the diamond"
                } else {
                    ""
                }
            ),
        });
    }
    out
}

// ── A14 — entrypoint→sink path analysis + single-vertex chokepoints ───────────

/// **A14**: for every vuln sink **reachable** from an entrypoint, the set of
/// *chokepoint* symbols — single-vertex cuts whose removal disconnects **all**
/// entrypoints from that sink. These are the nodes to pin / patch / sever to kill
/// the most reachability. Returned as findings (`High` — a chokepoint is a
/// high-leverage control point on a live vulnerable path).
///
/// *Algorithm:* for each reachable sink, test each candidate symbol on a path to it
/// by re-running the entrypoint BFS with that node removed; if the sink is no longer
/// reachable, the node is a chokepoint. `Θ(sinks × V × (V+E))` — the heavy-tier
/// min-cut proxy (single-vertex cuts, not full max-flow).
pub fn sink_chokepoints(snap: &WorldSnapshot) -> Vec<Finding> {
    let eps: Vec<&str> = snap.entrypoints();
    if eps.is_empty() {
        return Vec::new();
    }
    let reachable = snap.reachable_from(&eps);
    let mut out = Vec::new();
    for sink in &snap.vuln_sinks {
        let target = sink.symbol.as_str();
        if !reachable.contains(target) {
            continue; // A8 already handles unreachable sinks
        }
        // Candidates: reachable symbols that are neither an entrypoint nor the sink.
        let mut chokes: Vec<&str> = Vec::new();
        for cand in snap.symbols.iter().map(|s| s.id.as_str()) {
            if cand == target || eps.contains(&cand) || !reachable.contains(cand) {
                continue;
            }
            if !snap.reachable_from_excluding(&eps, cand).contains(target) {
                chokes.push(cand);
            }
        }
        if !chokes.is_empty() {
            chokes.sort_unstable();
            out.push(Finding {
                severity: Severity::High,
                id: format!("MODGUNN-CHOKEPOINT:{}", sink.advisory),
                summary: format!(
                    "vulnerable `{}` ({}) reachable only via chokepoint(s) [{}] — pin/patch to sever",
                    sink.symbol,
                    sink.advisory,
                    chokes.join(", ")
                ),
            });
        }
    }
    out
}

// ── A12 — bus-factor / single point of failure ───────────────────────────────

/// **A12**: a dep among the top-`k` most central ([`blast_radius`]) that has a
/// **single maintainer** (`maintainers <= 1`) — a highly-depended-on package one
/// account-compromise away from poisoning the tree. Fires only for nodes whose
/// maintainer count was captured; `High` (single point of failure).
pub fn bus_factor_findings(snap: &WorldSnapshot, k: usize) -> Vec<Finding> {
    let maint: HashMap<&str, Option<u32>> = snap
        .dep_nodes
        .iter()
        .map(|n| (n.key.as_str(), n.maintainers))
        .collect();
    blast_radius(snap)
        .into_iter()
        .take(k)
        .filter_map(|(key, score)| {
            let m = maint.get(key.as_str()).copied().flatten()?;
            (m <= 1).then(|| Finding {
                severity: Severity::High,
                id: format!("MODGUNN-BUS-FACTOR:{key}"),
                summary: format!(
                    "central dep (blast-radius {score:.4}) has a single maintainer — single point of failure"
                ),
            })
        })
        .collect()
}

// ── A8-supply — typosquat & dependency-confusion ─────────────────────────────

/// Levenshtein edit distance between two strings (iterative, one row of state).
fn levenshtein(a: &str, b: &str) -> usize {
    let b: Vec<char> = b.chars().collect();
    let mut prev: Vec<usize> = (0..=b.len()).collect();
    let mut cur = vec![0usize; b.len() + 1];
    for (i, ca) in a.chars().enumerate() {
        cur[0] = i + 1;
        for (j, &cb) in b.iter().enumerate() {
            let cost = if ca == cb { 0 } else { 1 };
            cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
        }
        std::mem::swap(&mut prev, &mut cur);
    }
    prev[b.len()]
}

/// **A8 (supply-chain)**: name-based typosquat + dependency-confusion detection.
/// - **typosquat**: a dep name within edit distance 1 of a `popular` name it is not
///   equal to → `High` (a look-alike of a trusted package).
/// - **dependency-confusion**: a dep name under one of your `internal_namespaces`
///   (prefix match) that resolved from a **public** registry → `Critical` (an
///   internal-only name should never resolve externally).
///
/// Pure and cheap (set membership + edit distance); runs in the gate latency budget.
pub fn typosquat_findings(
    snap: &WorldSnapshot,
    popular: &[&str],
    internal_namespaces: &[&str],
) -> Vec<Finding> {
    let mut out = Vec::new();
    // Deterministic: iterate dep nodes in key order.
    let mut nodes: Vec<&DepNode> = snap.dep_nodes.iter().collect();
    nodes.sort_by(|a, b| a.key.cmp(&b.key));
    for n in nodes {
        let name = n.name.as_str();
        if let Some(pfx) = internal_namespaces.iter().find(|p| name.starts_with(**p)) {
            out.push(Finding {
                severity: Severity::Critical,
                id: format!("MODGUNN-DEP-CONFUSION:{name}"),
                summary: format!(
                    "internal-namespace package `{name}` (`{pfx}`) resolved from a public registry"
                ),
            });
            continue; // an internal name is not also a typosquat of a public one
        }
        if let Some(pop) = popular
            .iter()
            .find(|p| **p != name && levenshtein(name, p) == 1)
        {
            out.push(Finding {
                severity: Severity::High,
                id: format!("MODGUNN-TYPOSQUAT:{name}"),
                summary: format!(
                    "dep `{name}` is one edit from popular package `{pop}` (possible typosquat)"
                ),
            });
        }
    }
    out
}

// ── A16 — metric-outlier detection (z-score) ─────────────────────────────────

/// Population z-score outliers: `(key, value, z)` for every sample whose absolute
/// z-score exceeds `z_threshold`. Empty / zero-variance input yields no outliers.
/// The reusable statistical core behind the A16 metric-anomaly analyses.
pub fn zscore_outliers(samples: &[(String, f64)], z_threshold: f64) -> Vec<(String, f64, f64)> {
    let n = samples.len();
    if n < 2 {
        return Vec::new();
    }
    let mean = samples.iter().map(|(_, v)| *v).sum::<f64>() / n as f64;
    let var = samples.iter().map(|(_, v)| (v - mean).powi(2)).sum::<f64>() / n as f64;
    let sd = var.sqrt();
    if sd == 0.0 {
        return Vec::new();
    }
    samples
        .iter()
        .filter_map(|(k, v)| {
            let z = (v - mean) / sd;
            (z.abs() > z_threshold).then(|| (k.clone(), *v, z))
        })
        .collect()
}

/// **A16**: dep-graph **fan-in** outliers — packages depended on by an anomalously
/// large (or small) number of others, by z-score over the in-degree distribution.
/// A sudden fan-in spike is a centrality/adoption anomaly worth review; reported
/// `Medium` for a positive-z (high fan-in) outlier, else `Info`.
pub fn dep_fanin_outliers(snap: &WorldSnapshot, z_threshold: f64) -> Vec<Finding> {
    let mut fanin: HashMap<&str, f64> = snap
        .dep_nodes
        .iter()
        .map(|n| (n.key.as_str(), 0.0))
        .collect();
    for e in &snap.dep_edges {
        if let Some(c) = fanin.get_mut(e.to.as_str()) {
            *c += 1.0;
        }
    }
    let mut samples: Vec<(String, f64)> = fanin.iter().map(|(k, v)| (k.to_string(), *v)).collect();
    samples.sort_by(|a, b| a.0.cmp(&b.0)); // determinism
    let mut out: Vec<Finding> = zscore_outliers(&samples, z_threshold)
        .into_iter()
        .map(|(key, v, z)| Finding {
            severity: if z > 0.0 {
                Severity::Medium
            } else {
                Severity::Info
            },
            id: format!("MODGUNN-FANIN-OUTLIER:{key}"),
            summary: format!("dep fan-in {v:.0} is a z={z:.2} outlier"),
        })
        .collect();
    out.sort_by(|a, b| a.id.cmp(&b.id));
    out
}

// ── Family-2 #7 — release-cadence anomaly ────────────────────────────────────

/// **Family-2 #7 (release-cadence / timeline anomaly)**: per-package release
/// **inter-arrival** analysis. A package with a stable publish rhythm that goes
/// long-dormant and then suddenly publishes is a classic account-takeover /
/// project-revival signal — the newest release arrives after a gap that is a
/// statistical outlier against the package's own history.
///
/// *Input:* [`DepNode::release_epochs`] — the package's publish timestamps (epoch
/// seconds) from registry metadata / the warehouse series. A node with fewer than
/// **3** timestamps (< 2 inter-arrival gaps, so [`zscore_outliers`] has nothing to
/// baseline against) is skipped, as is a package with a perfectly regular cadence
/// (zero-variance gaps ⇒ no outlier).
///
/// *Algo:* sort the timestamps, difference into consecutive inter-arrival gaps,
/// then run the shared [`zscore_outliers`] primitive over the gap series. A
/// **positive** z-score gap is anomalously long (a dormancy). If that dormancy is
/// the **most recent** gap — i.e. the newest release ends a long silence — it is
/// the takeover-shaped signal and fires `High`; a positive outlier gap buried in
/// the history (an old freeze-then-resume) fires `Medium`. Packages are visited in
/// `(ecosystem, name)` key order and, within a name, the node carrying the longest
/// captured series wins, so findings are deterministic.
pub fn release_cadence_anomalies(snap: &WorldSnapshot, z_threshold: f64) -> Vec<Finding> {
    // One release history per package identity (ecosystem, name): a snapshot pins a
    // single version, so the timeline is a package property, not a per-node one —
    // keep the longest captured series if several nodes carry one.
    let mut by_pkg: HashMap<(&str, &str), &[i64]> = HashMap::new();
    for n in &snap.dep_nodes {
        if n.release_epochs.is_empty() {
            continue;
        }
        let k = (n.ecosystem.as_str(), n.name.as_str());
        let entry = by_pkg.entry(k).or_default();
        if n.release_epochs.len() > entry.len() {
            *entry = n.release_epochs.as_slice();
        }
    }
    let mut keys: Vec<_> = by_pkg.keys().copied().collect();
    keys.sort_unstable();

    let mut out = Vec::new();
    for k in keys {
        let mut epochs = by_pkg[&k].to_vec();
        epochs.sort_unstable();
        if epochs.len() < 3 {
            continue; // need ≥ 2 gaps to have a distribution to be an outlier of
        }
        // Consecutive inter-arrival gaps (days), keyed by their index so the
        // z-score outlier can be located back to a position in the timeline.
        let gaps: Vec<(String, f64)> = epochs
            .windows(2)
            .enumerate()
            .map(|(i, w)| (i.to_string(), (w[1] - w[0]) as f64 / 86_400.0))
            .collect();
        let last_gap_idx = gaps.len() - 1;
        // The single most-anomalous *long* (positive-z) gap for this package.
        let mut worst: Option<(usize, f64, f64)> = None; // (gap index, days, z)
        for (idx_str, days, z) in zscore_outliers(&gaps, z_threshold) {
            if z <= 0.0 {
                continue; // a short gap (burst) is not the dormancy signal
            }
            let idx: usize = idx_str.parse().unwrap_or(0);
            if worst.map_or(true, |(_, _, wz)| z > wz) {
                worst = Some((idx, days, z));
            }
        }
        if let Some((idx, days, z)) = worst {
            let dormant_then_publish = idx == last_gap_idx;
            out.push(Finding {
                severity: if dormant_then_publish { Severity::High } else { Severity::Medium },
                id: format!("MODGUNN-RELEASE-CADENCE:{}:{}", k.0, k.1),
                summary: format!(
                    "`{}` ({}) {} after a {:.0}-day dormancy (z={z:.2} vs its release history) — {}",
                    k.1,
                    k.0,
                    if dormant_then_publish { "published its newest release" } else { "resumed publishing" },
                    days,
                    if dormant_then_publish {
                        "possible account takeover / revival"
                    } else {
                        "historical freeze-then-resume"
                    }
                ),
            });
        }
    }
    out
}

// ── Family-7 #25 — change-velocity baselines ─────────────────────────────────

/// **Family-7 #25 (change-velocity baselines)**: per-package **release-rate**
/// (churn-velocity) anomaly. Sibling of, and complementary to, the release-cadence
/// analysis (family-2 #7, [`release_cadence_anomalies`]): where #7 flags a single
/// anomalous inter-arrival *gap* (a dormancy that ends in a publish), this models
/// the package's **rate of change over time** — a rolling count of releases in a
/// trailing window — and flags a **burst**: a window in which the release rate
/// spikes far above the package's own baseline velocity. A sudden flurry of
/// publishes (the "freeze-then-spike" / rapid-fire re-release shape) is the churn
/// signal of a hijacked or thrashing project.
///
/// *Input:* [`DepNode::release_epochs`] — the package's publish timestamps (epoch
/// seconds), the same historized series #7 consumes. A package with fewer than
/// **5** releases has too short a velocity series to baseline against and is
/// skipped, as is a perfectly regular (constant-velocity) cadence (zero-variance ⇒
/// no outlier). Empty series ⇒ doesn't fire.
///
/// *Algo:* sort the timestamps; for each release compute its **trailing-window
/// velocity** = the number of releases within the `window_days`-day window ending
/// at (and including) that release. Run the shared [`zscore_outliers`] primitive
/// over the velocity series; a **positive** z-score is a burst (rate spike). Report
/// the single worst (highest-z) burst per package: `High` when the burst is the
/// **most recent** release (an active spike), else `Medium` (a historical burst).
/// Deterministic: packages visited in `(ecosystem, name)` key order, longest
/// captured series wins.
pub fn change_velocity_anomalies(
    snap: &WorldSnapshot,
    window_days: f64,
    z_threshold: f64,
) -> Vec<Finding> {
    let window_secs = (window_days * 86_400.0) as i64;
    // One release history per package identity (same folding as #7): a snapshot
    // pins one version, so the timeline is a package property, not a per-node one.
    let mut by_pkg: HashMap<(&str, &str), &[i64]> = HashMap::new();
    for n in &snap.dep_nodes {
        if n.release_epochs.is_empty() {
            continue;
        }
        let k = (n.ecosystem.as_str(), n.name.as_str());
        let entry = by_pkg.entry(k).or_default();
        if n.release_epochs.len() > entry.len() {
            *entry = n.release_epochs.as_slice();
        }
    }
    let mut keys: Vec<_> = by_pkg.keys().copied().collect();
    keys.sort_unstable();

    let mut out = Vec::new();
    for k in keys {
        let mut epochs = by_pkg[&k].to_vec();
        epochs.sort_unstable();
        if epochs.len() < 5 {
            continue; // too short a series to baseline a change-rate against
        }
        let last_idx = epochs.len() - 1;
        // Trailing-window velocity per release: releases in `(t - window, t]`.
        let velocity: Vec<(String, f64)> = epochs
            .iter()
            .enumerate()
            .map(|(i, &t)| {
                let lo = t - window_secs;
                let count = epochs.iter().filter(|&&u| u > lo && u <= t).count();
                (i.to_string(), count as f64)
            })
            .collect();
        // The single most-anomalous *burst* (highest positive z) for this package.
        let mut worst: Option<(usize, f64, f64)> = None; // (release index, velocity, z)
        for (idx_str, v, z) in zscore_outliers(&velocity, z_threshold) {
            if z <= 0.0 {
                continue; // a slowdown (negative z) is not the burst signal
            }
            let idx: usize = idx_str.parse().unwrap_or(0);
            if worst.map_or(true, |(_, _, wz)| z > wz) {
                worst = Some((idx, v, z));
            }
        }
        if let Some((idx, v, z)) = worst {
            let active = idx == last_idx;
            out.push(Finding {
                severity: if active { Severity::High } else { Severity::Medium },
                id: format!("MODGUNN-CHANGE-VELOCITY:{}:{}", k.0, k.1),
                summary: format!(
                    "`{}` ({}) {} — {:.0} releases within a {:.0}-day window (z={z:.2} vs its own change-rate history){}",
                    k.1,
                    k.0,
                    if active { "is in an active release burst" } else { "had a historical release burst" },
                    v,
                    window_days,
                    if active { " → possible hijack / rapid-fire churn" } else { "" }
                ),
            });
        }
    }
    out
}

// ── Family-3 #13 — owner-cluster correlation (label-propagation communities) ──

/// **Family-3 #13 (owner-cluster correlation)**: community detection over the
/// **co-ownership graph** to surface clusters of dependencies controlled by the
/// same small set of owners — packages that *look* independent in the resolved
/// tree but share a maintainer/publisher, so their supply-chain risk is
/// **correlated, not independent** (one compromised account poisons the whole
/// cluster; a single entity quietly owning a swath of the tree is a
/// concentration / collusion signal).
///
/// *Input:* [`DepNode::owners`] — the distinct owner/publisher identities of a
/// package from registry ownership metadata. Nodes without captured owners are
/// ignored; a snapshot with fewer than two owned deps has no community to find and
/// the analysis stays silent (empty input ⇒ doesn't fire).
///
/// *Algo:* build the undirected **co-ownership graph** (an edge between any two
/// deps sharing ≥ 1 owner), then run **label propagation** — every node starts
/// with a unique label and repeatedly adopts the most frequent label among its
/// neighbours (ties broken by the smallest label, for determinism) until stable.
/// The resulting communities are the owner clusters. For each community of at
/// least `min_cluster_size` deps, the **dominant owner** (present in the most deps
/// of the cluster) and its coverage are reported: `High` when one owner controls
/// **every** dep in the cluster (a pure single-entity cluster), else `Medium`.
/// Deterministic: nodes are visited in key order and findings are emitted sorted
/// by each cluster's representative (smallest) dep key.
pub fn owner_cluster_findings(snap: &WorldSnapshot, min_cluster_size: usize) -> Vec<Finding> {
    // Deps that carry ownership metadata, in key order (determinism).
    let mut owned: Vec<&DepNode> = snap
        .dep_nodes
        .iter()
        .filter(|n| !n.owners.is_empty())
        .collect();
    owned.sort_by(|a, b| a.key.cmp(&b.key));
    let n = owned.len();
    if n < 2 {
        return Vec::new();
    }
    // owner → indices of deps carrying that owner.
    let mut by_owner: HashMap<&str, Vec<usize>> = HashMap::new();
    for (i, d) in owned.iter().enumerate() {
        for o in &d.owners {
            by_owner.entry(o.as_str()).or_default().push(i);
        }
    }
    // Co-ownership adjacency: connect every pair of deps that share an owner.
    let mut adj: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n];
    for members in by_owner.values() {
        for (a_pos, &a) in members.iter().enumerate() {
            for &b in &members[a_pos + 1..] {
                adj[a].insert(b);
                adj[b].insert(a);
            }
        }
    }
    // Label propagation: adopt the most frequent neighbour label (tie → smallest).
    let mut label: Vec<usize> = (0..n).collect();
    for _ in 0..100 {
        let mut changed = false;
        for i in 0..n {
            if adj[i].is_empty() {
                continue;
            }
            let mut counts: HashMap<usize, usize> = HashMap::new();
            for &j in &adj[i] {
                *counts.entry(label[j]).or_default() += 1;
            }
            // max by count, then smallest label (b.0.cmp(a.0) makes the smaller win).
            let best = *counts
                .iter()
                .max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0)))
                .map(|(l, _)| l)
                .unwrap();
            if best != label[i] {
                label[i] = best;
                changed = true;
            }
        }
        if !changed {
            break;
        }
    }
    // Group deps by final label into communities.
    let mut communities: HashMap<usize, Vec<usize>> = HashMap::new();
    for (i, &l) in label.iter().enumerate() {
        communities.entry(l).or_default().push(i);
    }
    let mut clusters: Vec<Vec<usize>> = communities.into_values().collect();
    for c in clusters.iter_mut() {
        c.sort_unstable();
    }
    clusters.sort_by(|a, b| owned[a[0]].key.cmp(&owned[b[0]].key));

    let mut out = Vec::new();
    for c in clusters {
        if c.len() < min_cluster_size {
            continue;
        }
        // The dominant owner across the cluster (tie → lexicographically smallest).
        let mut cov: HashMap<&str, usize> = HashMap::new();
        for &i in &c {
            for o in &owned[i].owners {
                *cov.entry(o.as_str()).or_default() += 1;
            }
        }
        let (dominant, dcov) = cov
            .iter()
            .max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0)))
            .map(|(o, n)| (*o, *n))
            .unwrap();
        let full = dcov == c.len();
        let members: Vec<&str> = c.iter().map(|&i| owned[i].name.as_str()).collect();
        out.push(Finding {
            severity: if full { Severity::High } else { Severity::Medium },
            id: format!("MODGUNN-OWNER-CLUSTER:{}", owned[c[0]].key),
            summary: format!(
                "owner cluster of {} deps [{}] — owner `{dominant}` controls {dcov}/{} ({}) → correlated supply-chain risk",
                c.len(),
                members.join(", "),
                c.len(),
                if full { "the entire cluster" } else { "the plurality" }
            ),
        });
    }
    out
}

// ── Family-5 #19 — license-transition tracking ───────────────────────────────

/// The supply-chain **risk tier** of an SPDX license id, ascending — higher means
/// a heavier disclosure / copyleft-infection obligation on downstream consumers:
/// **0** permissive / public-domain, **1** weak (file/library) copyleft, **2**
/// strong (whole-work) copyleft, **3** network/server-side copyleft, **4** non-open
/// / proprietary / unstated. A relicense to a *higher* tier is the
/// [`license_transition_findings`] signal. Matching is case-insensitive and
/// substring-based over the SPDX id so version/suffix variants
/// (`GPL-3.0-only`, `AGPL-3.0-or-later`, `Apache-2.0`) fold to the right tier.
pub fn license_risk_tier(spdx: &str) -> u8 {
    let s = spdx.trim().to_ascii_uppercase();
    if s.is_empty() || s == "NOASSERTION" || s == "UNKNOWN" || s == "NONE" {
        return 4; // an unstated / unknown license is a risk of its own
    }
    // Network / server-side copyleft — the strongest infection surface. Checked
    // first because AGPL/OSL substrings also match weaker rules.
    if ["AGPL", "SSPL", "OSL", "EUPL"]
        .iter()
        .any(|p| s.contains(p))
    {
        return 3;
    }
    // Weak (file/library) copyleft. LGPL is here (and must precede the plain-GPL
    // rule, since "LGPL" contains "GPL").
    if ["LGPL", "MPL", "EPL", "CDDL", "CPL", "MS-RL"]
        .iter()
        .any(|p| s.contains(p))
    {
        return 1;
    }
    // Strong (whole-work) copyleft — plain GPL, once AGPL/LGPL are ruled out.
    if s.contains("GPL") {
        return 2;
    }
    // Permissive / public-domain.
    if [
        "MIT",
        "APACHE",
        "BSD",
        "ISC",
        "ZLIB",
        "0BSD",
        "UNLICENSE",
        "CC0",
        "WTFPL",
        "BSL-1",
        "BOOST",
        "PSF",
        "PYTHON",
        "POSTGRESQL",
        "NCSA",
        "ARTISTIC",
    ]
    .iter()
    .any(|p| s.contains(p))
    {
        return 0;
    }
    // Everything else (commercial / proprietary / source-available EULAs like
    // BUSL / Elastic / custom) is treated as non-open / encumbered.
    4
}

/// **Family-5 #19 (license-transition tracking)**: flag a dependency that
/// **relicenses to a riskier license** across its version history — the classic
/// MIT→AGPL "rug-pull", or a slide to an unstated / proprietary license. This is a
/// legal-drift signal: a transitive relicense can silently impose new
/// disclosure / copyleft-contamination obligations on everything downstream of it.
///
/// *Input:* [`DepNode::license_history`] — the package's `(version, spdx)` license
/// timeline in **release order** from registry / SBOM metadata. A node with fewer
/// than two license points has no transition to detect and is skipped (empty ⇒
/// doesn't fire).
///
/// *Algo:* walk consecutive license entries; a transition to a higher
/// [`license_risk_tier`] is a riskward relicense. Report the single **largest tier
/// jump** per package (ties broken by the higher destination tier); the destination
/// tier governs severity: `High` for a move to network-copyleft or non-open /
/// unstated (tier ≥ 3), `Medium` for strong copyleft (tier 2), `Low` for weak
/// copyleft (tier 1). A relicense to an equal-or-lower tier (e.g. GPL→MIT) is never
/// flagged. Deterministic: packages visited in `(ecosystem, name)` key order; the
/// longest captured history wins per package.
///
/// *Scope:* this is the relicense-transition core; the transitive
/// copyleft-contamination-path cut (which downstream nodes a copyleft term reaches)
/// is a further extension over the dep graph.
pub fn license_transition_findings(snap: &WorldSnapshot) -> Vec<Finding> {
    // One license timeline per package identity (same folding as #7/#25): a
    // snapshot pins one version, so the history is a package property.
    let mut by_pkg: HashMap<(&str, &str), &[(String, String)]> = HashMap::new();
    for n in &snap.dep_nodes {
        if n.license_history.len() < 2 {
            continue;
        }
        let k = (n.ecosystem.as_str(), n.name.as_str());
        let entry = by_pkg.entry(k).or_default();
        if n.license_history.len() > entry.len() {
            *entry = n.license_history.as_slice();
        }
    }
    let mut keys: Vec<_> = by_pkg.keys().copied().collect();
    keys.sort_unstable();

    let mut out = Vec::new();
    for k in keys {
        let hist = by_pkg[&k];
        // The largest riskward tier jump across consecutive versions (tie → higher
        // destination tier).
        let mut worst: Option<(u8, u8, &(String, String), &(String, String))> = None;
        for w in hist.windows(2) {
            let ft = license_risk_tier(&w[0].1);
            let tt = license_risk_tier(&w[1].1);
            if tt <= ft {
                continue; // equal-or-safer relicense is not a risk signal
            }
            let jump = tt - ft;
            let better = worst.map_or(true, |(wj, wt, _, _)| jump > wj || (jump == wj && tt > wt));
            if better {
                worst = Some((jump, tt, &w[0], &w[1]));
            }
        }
        if let Some((_, tt, from, to)) = worst {
            let severity = match tt {
                t if t >= 3 => Severity::High,
                2 => Severity::Medium,
                _ => Severity::Low,
            };
            out.push(Finding {
                severity,
                id: format!("MODGUNN-LICENSE-TRANSITION:{}:{}", k.0, k.1),
                summary: format!(
                    "`{}` ({}) relicensed to a riskier license: `{}` (v{}) → `{}` (v{}) — legal-drift / copyleft-contamination risk",
                    k.1, k.0, from.1, from.0, to.1, to.0
                ),
            });
        }
    }
    out
}

// ── Family-4 #18 — embedding-novelty ─────────────────────────────────────────

/// Cosine distance `1 - cos(a, b)` between two equal-length vectors — `0.0` for
/// identical direction, up to `2.0` for opposed. Returns `None` when the lengths
/// differ or either vector has zero magnitude (distance undefined).
fn cosine_distance(a: &[f32], b: &[f32]) -> Option<f64> {
    if a.len() != b.len() {
        return None;
    }
    let mut dot = 0.0f64;
    let mut na = 0.0f64;
    let mut nb = 0.0f64;
    for (&x, &y) in a.iter().zip(b) {
        dot += x as f64 * y as f64;
        na += x as f64 * x as f64;
        nb += y as f64 * y as f64;
    }
    if na == 0.0 || nb == 0.0 {
        return None;
    }
    Some(1.0 - dot / (na.sqrt() * nb.sqrt()))
}

/// **Family-4 #18 (embedding-novelty)**: flag a package version whose code
/// embedding sits **far from the ecosystem norm** — semantically-novel code, the
/// shape of freshly-injected / unrelated payload smuggled into a package.
///
/// *Input:* [`DepNode::embedding`] — the package-version code vector from the
/// nornir embedder, attached by a producer (modgunn does not run the embedder).
/// Nodes without an embedding are ignored; an ecosystem with fewer than **3**
/// embedded nodes has no stable norm and is skipped (empty ⇒ doesn't fire).
///
/// *Algo:* per ecosystem, compute the **centroid** (component-wise mean) of its
/// embeddings, then the [`cosine_distance`] of each node to that centroid. A node
/// whose distance exceeds `distance_threshold` is a novelty outlier: `High` when it
/// is *very* far (distance > `2 × distance_threshold`), else `Medium`. Vectors
/// whose dimension disagrees with the ecosystem's first (key-sorted) embedded node
/// are skipped defensively. Deterministic: nodes visited in key order, findings
/// emitted sorted by id.
pub fn embedding_novelty_findings(snap: &WorldSnapshot, distance_threshold: f64) -> Vec<Finding> {
    // Embedded nodes grouped by ecosystem, in key order (determinism).
    let mut embedded: Vec<&DepNode> = snap
        .dep_nodes
        .iter()
        .filter(|n| n.embedding.is_some())
        .collect();
    embedded.sort_by(|a, b| a.key.cmp(&b.key));

    let mut by_eco: HashMap<&str, Vec<&DepNode>> = HashMap::new();
    for n in embedded {
        by_eco.entry(n.ecosystem.as_str()).or_default().push(n);
    }
    let mut ecos: Vec<&str> = by_eco.keys().copied().collect();
    ecos.sort_unstable();

    let mut out = Vec::new();
    for eco in ecos {
        let nodes = &by_eco[eco];
        // Consistent-dimension cohort: the first (key-sorted) node fixes the dim.
        let dim = nodes[0].embedding.as_ref().unwrap().len();
        let cohort: Vec<&DepNode> = nodes
            .iter()
            .copied()
            .filter(|n| n.embedding.as_ref().unwrap().len() == dim)
            .collect();
        if cohort.len() < 3 || dim == 0 {
            continue; // too few embeddings (or empty vectors) for a stable norm
        }
        // Component-wise centroid.
        let mut centroid = vec![0.0f64; dim];
        for n in &cohort {
            for (c, &x) in centroid.iter_mut().zip(n.embedding.as_ref().unwrap()) {
                *c += x as f64;
            }
        }
        for c in centroid.iter_mut() {
            *c /= cohort.len() as f64;
        }
        let centroid_f32: Vec<f32> = centroid.iter().map(|&c| c as f32).collect();
        for n in &cohort {
            let Some(dist) = cosine_distance(n.embedding.as_ref().unwrap(), &centroid_f32) else {
                continue;
            };
            if dist > distance_threshold {
                let very = dist > 2.0 * distance_threshold;
                out.push(Finding {
                    severity: if very { Severity::High } else { Severity::Medium },
                    id: format!("MODGUNN-EMBEDDING-NOVELTY:{}", n.key),
                    summary: format!(
                        "`{}` ({}) embedding is cosine-distance {dist:.3} from the {eco} ecosystem norm (> {distance_threshold:.2}) — semantically-novel code",
                        n.name, n.ecosystem
                    ),
                });
            }
        }
    }
    out.sort_by(|a, b| a.id.cmp(&b.id));
    out
}

// ── Taint-path (untrusted source → sensitive sink) ───────────────────────────

/// Relative danger rank of a taint sink capability — picks the *worst* sink a
/// symbol carries so a finding reports (and is severity-scored by) the most
/// dangerous capability reached.
fn taint_sink_rank(c: Capability) -> u8 {
    match c {
        Capability::DynamicCode => 5,
        Capability::Process => 4,
        Capability::Ffi => 3,
        Capability::Filesystem => 2,
        Capability::Env => 1,
        _ => 0,
    }
}

/// The default **sensitive-sink** capability set for taint analysis: reaching one
/// of these with untrusted (tainted) data is the dangerous event — command/process
/// spawn, dynamic code load, filesystem write, native FFI, and environment/secret
/// read. ([`Capability::Network`] is treated as the *source* side — where untrusted
/// input enters — not a sink.)
pub fn default_taint_sinks() -> BTreeSet<Capability> {
    [
        Capability::Process,
        Capability::DynamicCode,
        Capability::Filesystem,
        Capability::Ffi,
        Capability::Env,
    ]
    .into_iter()
    .collect()
}

/// **Taint-path analysis (source→sink)**: flag call-graph paths from an
/// **untrusted source** to a **sensitive sink** — untrusted data that can flow
/// into a dangerous operation. A source is a symbol marked
/// [`Symbol::taint_source`] (a network / IPC / deserialization read); a sink is
/// any symbol that directly exercises a capability in `sink_caps` (see
/// [`default_taint_sinks`]). A path exists when the sink symbol is call-reachable
/// from the source over the SCIP call graph.
///
/// *Input:* [`Symbol::is_taint_source`] flags (none marked ⇒ the analysis doesn't
/// fire) plus the call graph and per-symbol [`Symbol::capabilities`] already
/// carried by the snapshot.
///
/// *Algo:* per-source forward BFS ([`WorldSnapshot::reachable_from`]); every
/// reached *distinct* symbol bearing a sink capability is a source→sink taint
/// path. `Critical` when the sink is code/command execution
/// ([`Capability::DynamicCode`] / [`Capability::Process`]) — the worst case —
/// else `High`. Emitted sorted by `(source, sink)` so output is deterministic and
/// independent of input order.
pub fn taint_path_findings(snap: &WorldSnapshot, sink_caps: &BTreeSet<Capability>) -> Vec<Finding> {
    let mut sources: Vec<&str> = snap
        .symbols
        .iter()
        .filter(|s| s.is_taint_source)
        .map(|s| s.id.as_str())
        .collect();
    if sources.is_empty() || sink_caps.is_empty() {
        return Vec::new();
    }
    sources.sort_unstable();
    let mut out = Vec::new();
    for src in sources {
        let reachable = snap.reachable_from(&[src]);
        // (sink id, worst sink capability), deterministic order.
        let mut sinks: Vec<(&str, Capability)> = Vec::new();
        for s in &snap.symbols {
            if s.id == src || !reachable.contains(s.id.as_str()) {
                continue; // a genuine path needs a distinct, reachable sink endpoint
            }
            if let Some(cap) = s
                .capabilities
                .iter()
                .filter(|c| sink_caps.contains(c))
                .copied()
                .max_by_key(|c| taint_sink_rank(*c))
            {
                sinks.push((s.id.as_str(), cap));
            }
        }
        sinks.sort_by(|a, b| a.0.cmp(b.0));
        for (sink_id, cap) in sinks {
            let critical = matches!(cap, Capability::DynamicCode | Capability::Process);
            out.push(Finding {
                severity: if critical { Severity::Critical } else { Severity::High },
                id: format!("MODGUNN-TAINT-PATH:{src}->{sink_id}"),
                summary: format!(
                    "untrusted source `{src}` reaches sensitive `{}` sink `{sink_id}` — tainted data can flow into a {} operation",
                    cap.label(),
                    cap.label()
                ),
            });
        }
    }
    out
}

/// Shannon entropy (bits/symbol) of a string's bytes — the obfuscation metric A17
/// keys on (minified/encoded identifiers have near-maximal entropy). Re-exported
/// from [`crate::core`] so the A17 tripwire here and the A23 behaviour-feature
/// extractor (`scan`) share ONE definition, never a per-feature twin (L5). The
/// stable path `modgunn::scip::shannon_entropy` is preserved for existing callers.
pub use crate::core::shannon_entropy;

// ── A22 — behaviour fingerprint ──────────────────────────────────────────────

/// FNV-1a over a symbol's sorted transitive call closure — a stable in-process
/// fingerprint of its *effective behaviour*.
fn fingerprint(snap: &WorldSnapshot, root: &str) -> u64 {
    let closure = snap.reachable_from(&[root]);
    let mut ids: Vec<&str> = closure.into_iter().collect();
    ids.sort_unstable();
    let mut h: u64 = 0xcbf29ce484222325;
    for id in ids {
        for b in id.bytes() {
            h ^= b as u64;
            h = h.wrapping_mul(0x100000001b3);
        }
        h ^= 0xff; // field separator
        h = h.wrapping_mul(0x100000001b3);
    }
    h
}

// ── Tripwires (the delta event stream) ───────────────────────────────────────

/// A tripwire: a rule/algorithm that fires on the world-delta between two
/// snapshots. Concrete tripwires implement the catalog's delta analyses.
pub trait Tripwire {
    fn name(&self) -> &str;
    /// Evaluate the delta `from` → `to`; return any findings raised.
    fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding>;
}

/// **A1** — call-graph delta: new / removed edges between two snapshots.
#[derive(Debug, Default)]
pub struct CallGraphDeltaTripwire;

impl Tripwire for CallGraphDeltaTripwire {
    fn name(&self) -> &str {
        "call-graph-delta"
    }
    fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding> {
        let old: HashSet<&CallEdge> = from.calls.iter().collect();
        let new: HashSet<&CallEdge> = to.calls.iter().collect();
        let mut findings = Vec::new();
        let added = new.difference(&old).count();
        let removed = old.difference(&new).count();
        if added > 0 || removed > 0 {
            findings.push(Finding {
                severity: Severity::Info,
                id: "MODGUNN-CALLGRAPH-DELTA".into(),
                summary: format!("{added} call edge(s) added, {removed} removed"),
            });
        }
        findings
    }
}

/// **A9-drift / A10** — capability-surface drift: a capability reachable from
/// entrypoints in `to` that was NOT reachable in `from` (the surface grew).
#[derive(Debug, Default)]
pub struct CapabilityDriftTripwire;

impl Tripwire for CapabilityDriftTripwire {
    fn name(&self) -> &str {
        "capability-drift"
    }
    fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding> {
        let before = capability_surface(from);
        let after = capability_surface(to);
        after
            .difference(&before)
            .map(|c| Finding {
                severity: Severity::High,
                id: format!("MODGUNN-CAPABILITY-GAINED:{}", c.label()),
                summary: format!(
                    "capability `{}` became reachable from entrypoints",
                    c.label()
                ),
            })
            .collect()
    }
}

/// **A8-delta / family-1 #5** — sink-newly-reachable: a vuln sink reachable in
/// `to` but not in `from`. The highest-signal delta event.
#[derive(Debug, Default)]
pub struct SinkNewlyReachableTripwire;

impl Tripwire for SinkNewlyReachableTripwire {
    fn name(&self) -> &str {
        "sink-newly-reachable"
    }
    fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding> {
        let before = from.reachable_from_entrypoints();
        let before: HashSet<String> = before.into_iter().map(str::to_string).collect();
        let after = to.reachable_from_entrypoints();
        to.vuln_sinks
            .iter()
            .filter(|s| after.contains(s.symbol.as_str()) && !before.contains(&s.symbol))
            .map(|s| Finding {
                severity: s.severity.max(Severity::High),
                id: format!("MODGUNN-SINK-NEWLY-REACHABLE:{}", s.advisory),
                summary: format!(
                    "vulnerable symbol `{}` ({}) is newly reachable",
                    s.symbol, s.advisory
                ),
            })
            .collect()
    }
}

/// **A22** — behaviour-fingerprint drift: a symbol whose transitive call closure
/// (its effective behaviour) changed while its package **version** and public API
/// (`display`) did NOT — the silent 5th-level-dependency signal.
#[derive(Debug, Default)]
pub struct FingerprintDriftTripwire;

impl Tripwire for FingerprintDriftTripwire {
    fn name(&self) -> &str {
        "fingerprint-drift"
    }
    fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding> {
        let mut findings = Vec::new();
        for s_new in &to.symbols {
            let Some(s_old) = from.symbol(&s_new.id) else {
                continue;
            };
            // Only the *silent* case: identical package version + public API.
            if s_old.package != s_new.package || s_old.display != s_new.display {
                continue;
            }
            if fingerprint(from, &s_old.id) != fingerprint(to, &s_new.id) {
                findings.push(Finding {
                    severity: Severity::High,
                    id: format!("MODGUNN-FINGERPRINT-DRIFT:{}", s_new.id),
                    summary: format!(
                        "behaviour of `{}` changed with NO version/API change ({})",
                        s_new.id, s_new.package
                    ),
                });
            }
        }
        findings
    }
}

/// **A17** — obfuscation / entropy spike: a symbol **newly introduced** in `to`
/// whose display name has Shannon entropy at/above `min_entropy` bits/byte — the
/// minified / base64 / packed-identifier signal of injected obfuscated code. Only
/// *new* symbols are flagged (a delta), so a stable high-entropy name doesn't
/// re-fire every diff.
#[derive(Debug)]
pub struct ObfuscationTripwire {
    /// Entropy threshold in bits/byte (English identifiers sit ~3–4; packed/base64
    /// blobs approach the 8-bit ceiling). Default [`Self::DEFAULT_MIN_ENTROPY`].
    pub min_entropy: f64,
    /// Ignore names shorter than this (short names have unstable entropy).
    pub min_len: usize,
}

impl ObfuscationTripwire {
    /// A conservative default: 4.0 bits/byte over names ≥ 12 chars.
    pub const DEFAULT_MIN_ENTROPY: f64 = 4.0;
}

impl Default for ObfuscationTripwire {
    fn default() -> Self {
        Self {
            min_entropy: Self::DEFAULT_MIN_ENTROPY,
            min_len: 12,
        }
    }
}

impl Tripwire for ObfuscationTripwire {
    fn name(&self) -> &str {
        "obfuscation-entropy-spike"
    }
    fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding> {
        let old: HashSet<&str> = from.symbols.iter().map(|s| s.id.as_str()).collect();
        let mut findings = Vec::new();
        for s in &to.symbols {
            if old.contains(s.id.as_str()) {
                continue; // only newly-introduced symbols
            }
            if s.display.len() < self.min_len {
                continue;
            }
            let h = shannon_entropy(&s.display);
            if h >= self.min_entropy {
                findings.push(Finding {
                    severity: Severity::Medium,
                    id: format!("MODGUNN-OBFUSCATION:{}", s.id),
                    summary: format!(
                        "new symbol `{}` ({}) has high-entropy name ({h:.2} bits/byte) — possible obfuscation",
                        s.display, s.package
                    ),
                });
            }
        }
        findings.sort_by(|a, b| a.id.cmp(&b.id));
        findings
    }
}

// ── Family-5 #20 — SBOM drift (component set-diff between two snapshots) ──────

/// **Family-5 #20 (SBOM drift)**: the auditable **component set-diff** between two
/// world snapshots — *what entered and what left the dependency world, and when*.
/// This is the SBOM-level companion to the raw `warehouse::sbom_drift` helper:
/// where that returns bare `(name, version)` sets off the persisted
/// `sbom_components` table, this surfaces the drift as catalog [`Finding`]s over an
/// in-memory [`WorldSnapshot`] pair — the same shape every other Engine-B analysis
/// emits, so a consumer folds it into a [`BehaviorDelta`] / [`Verdict`] uniformly.
///
/// *Input:* [`WorldSnapshot::dep_nodes`] on both sides; a component's identity is
/// its [`DepNode::key`] (`ecosystem:name@version`), so a version bump reads as one
/// component leaving and another entering (the honest SBOM record).
///
/// *Severity:* an **entered unverified** component is `Low` (a new dependency
/// arrived carrying no provenance — the reviewable event); every other entered /
/// left component is `Info` (the neutral auditable record). Deterministic:
/// findings emitted sorted by id.
#[derive(Debug, Default)]
pub struct SbomDriftTripwire;

impl Tripwire for SbomDriftTripwire {
    fn name(&self) -> &str {
        "sbom-drift"
    }
    fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding> {
        let old: HashSet<&str> = from.dep_nodes.iter().map(|n| n.key.as_str()).collect();
        let new: HashSet<&str> = to.dep_nodes.iter().map(|n| n.key.as_str()).collect();
        let mut out = Vec::new();
        for n in &to.dep_nodes {
            if old.contains(n.key.as_str()) {
                continue;
            }
            let unverified = !n.verified;
            out.push(Finding {
                severity: if unverified {
                    Severity::Low
                } else {
                    Severity::Info
                },
                id: format!("MODGUNN-SBOM-ENTERED:{}", n.key),
                summary: format!(
                    "component `{}` entered the world{}",
                    n.key,
                    if unverified { " (no provenance)" } else { "" }
                ),
            });
        }
        for n in &from.dep_nodes {
            if new.contains(n.key.as_str()) {
                continue;
            }
            out.push(Finding {
                severity: Severity::Info,
                id: format!("MODGUNN-SBOM-LEFT:{}", n.key),
                summary: format!("component `{}` left the world", n.key),
            });
        }
        out.sort_by(|a, b| a.id.cmp(&b.id));
        out
    }
}

// ── Family-2 #6 — publisher / maintainer anomaly (ownership-transfer delta) ───

/// **Family-2 #6 (publisher/maintainer anomaly)**: a delta tripwire over the
/// **ownership** of a package between two snapshots — the account-takeover /
/// ownership-transfer signal. Where #7 ([`release_cadence_anomalies`]) watches
/// *when* a package publishes, this watches *who controls it*: a package whose
/// [`DepNode::owners`] set changes between snapshots has had an ownership event,
/// and a package whose owners are **entirely replaced** (no surviving owner) is
/// the shape of a hostile takeover — the exact pre-condition of a supply-chain
/// injection.
///
/// *Input:* [`DepNode::owners`] on the same `(ecosystem, name)` in both snapshots
/// (registry ownership metadata). A package present in only one snapshot, or with
/// no captured owners on either side, is not an *ownership-transfer* event and is
/// skipped (a newly-added dep is the SBOM-drift / policy surface, not this one).
/// [`DepNode::maintainers`] is a secondary signal: a maintainer **count collapse**
/// (many → one) on an unchanged owner set is a bus-factor-worsening co-signal.
///
/// *Severity:* `High` when the owner set is **fully replaced** (disjoint old/new —
/// a takeover), `Medium` when it changes but overlaps (a partial ownership change /
/// added or dropped co-owner), `Low` for a maintainer-count collapse with an
/// unchanged owner set. Deterministic: findings emitted sorted by package key.
#[derive(Debug, Default)]
pub struct MaintainerAnomalyTripwire;

impl Tripwire for MaintainerAnomalyTripwire {
    fn name(&self) -> &str {
        "maintainer-anomaly"
    }
    fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding> {
        // Latest owner/maintainer facts per (ecosystem, name) on each side. A
        // snapshot pins one version, but ownership is a package property, so we
        // fold to the package identity (last-writer in vec order is irrelevant —
        // owners of the same package agree within a snapshot).
        fn by_pkg(snap: &WorldSnapshot) -> HashMap<(&str, &str), (&Vec<String>, Option<u32>)> {
            let mut m: HashMap<(&str, &str), (&Vec<String>, Option<u32>)> = HashMap::new();
            for n in &snap.dep_nodes {
                if n.owners.is_empty() {
                    continue;
                }
                m.entry((n.ecosystem.as_str(), n.name.as_str()))
                    .or_insert((&n.owners, n.maintainers));
            }
            m
        }
        let old = by_pkg(from);
        let new = by_pkg(to);
        let mut keys: Vec<_> = new
            .keys()
            .copied()
            .filter(|k| old.contains_key(k))
            .collect();
        keys.sort_unstable();

        let mut out = Vec::new();
        for k in keys {
            let (old_owners, old_maint) = old[&k];
            let (new_owners, new_maint) = new[&k];
            let before: BTreeSet<&str> = old_owners.iter().map(String::as_str).collect();
            let after: BTreeSet<&str> = new_owners.iter().map(String::as_str).collect();
            if before == after {
                // No ownership change — check for a maintainer-count collapse.
                if let (Some(o), Some(n)) = (old_maint, new_maint) {
                    if o >= 2 && n < o && n <= 1 {
                        out.push(Finding {
                            severity: Severity::Low,
                            id: format!("MODGUNN-MAINTAINER-COLLAPSE:{}:{}", k.0, k.1),
                            summary: format!(
                                "`{}` ({}) maintainer count collapsed {o} → {n} with no owner change — bus-factor / takeover-prep signal",
                                k.1, k.0
                            ),
                        });
                    }
                }
                continue;
            }
            let disjoint = before.is_disjoint(&after);
            let severity = if disjoint {
                Severity::High
            } else {
                Severity::Medium
            };
            let removed: Vec<&str> = before.difference(&after).copied().collect();
            let added: Vec<&str> = after.difference(&before).copied().collect();
            out.push(Finding {
                severity,
                id: format!("MODGUNN-MAINTAINER-ANOMALY:{}:{}", k.0, k.1),
                summary: format!(
                    "`{}` ({}) ownership {}: owners [{}] → [{}] (dropped [{}], added [{}]) — {} anomaly",
                    k.1,
                    k.0,
                    if disjoint { "fully replaced" } else { "changed" },
                    old_owners.join(", "),
                    new_owners.join(", "),
                    removed.join(", "),
                    added.join(", "),
                    if disjoint { "account-takeover-shaped" } else { "publisher/maintainer" }
                ),
            });
        }
        out
    }
}

// ── Family-6 #23 — honeytoken / canary ───────────────────────────────────────

/// **Family-6 #23 (honeytoken / canary)** — a planted marker whose *appearance in
/// the observed world is itself the alarm*. The operator seeds these; the analysis
/// never fires on a clean world, so any finding is high-confidence by construction.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Honeytoken {
    /// An **internal package name** that must NEVER resolve to a real dependency —
    /// a dependency-confusion canary. If a resolved [`DepNode`] bears this name, an
    /// attacker has published a public package under a private name and the resolver
    /// pulled it: the classic dependency-confusion hit. Matched case-insensitively
    /// on [`DepNode::name`].
    InternalName(String),
    /// A **secret-shaped canary token** planted in internal code / config that must
    /// never surface in the published/observed world. If the token appears in any
    /// [`Symbol`] (id or display) or any [`DepNode::key`], the marker has been
    /// exfiltrated or resolved externally. Matched as a substring.
    CanaryToken(String),
}

impl Honeytoken {
    fn value(&self) -> &str {
        match self {
            Honeytoken::InternalName(v) | Honeytoken::CanaryToken(v) => v,
        }
    }
}

/// **Family-6 #23 (honeytoken / canary)**: scan a [`WorldSnapshot`] for any planted
/// [`Honeytoken`] that has surfaced — a resolved internal name (dependency-confusion)
/// or an exfiltrated canary token. Every hit is `Critical`; an unplanted world (empty
/// `planted`) never fires. Deterministic: findings emitted sorted by id.
///
/// *Input:* the operator's planted `planted` set plus the snapshot's own
/// [`WorldSnapshot::dep_nodes`] / [`WorldSnapshot::symbols`]. No external state.
pub fn honeytoken_findings(snap: &WorldSnapshot, planted: &[Honeytoken]) -> Vec<Finding> {
    let mut out = Vec::new();
    for token in planted {
        let needle = token.value();
        if needle.is_empty() {
            continue;
        }
        match token {
            Honeytoken::InternalName(name) => {
                let want = name.to_ascii_lowercase();
                for n in &snap.dep_nodes {
                    if n.name.to_ascii_lowercase() == want {
                        out.push(Finding {
                            severity: Severity::Critical,
                            id: format!("MODGUNN-HONEYTOKEN-CONFUSION:{}", n.key),
                            summary: format!(
                                "internal-name honeytoken `{name}` resolved to a real dependency `{}` — dependency-confusion hit",
                                n.key
                            ),
                        });
                    }
                }
            }
            Honeytoken::CanaryToken(tok) => {
                let mut hits: Vec<String> = Vec::new();
                for s in &snap.symbols {
                    if s.id.contains(tok) || s.display.contains(tok) {
                        hits.push(format!("symbol:{}", s.id));
                    }
                }
                for n in &snap.dep_nodes {
                    if n.key.contains(tok) {
                        hits.push(format!("dep:{}", n.key));
                    }
                }
                for site in hits {
                    out.push(Finding {
                        severity: Severity::Critical,
                        id: format!("MODGUNN-HONEYTOKEN-CANARY:{tok}:{site}"),
                        summary: format!(
                            "canary token `{tok}` surfaced in the observed world at `{site}` — exfiltration / external resolution",
                        ),
                    });
                }
            }
        }
    }
    out.sort_by(|a, b| a.id.cmp(&b.id));
    out.dedup_by(|a, b| a.id == b.id);
    out
}

// ── Family-6 #21 — policy tripwires (declarative rule DSL) ────────────────────

/// **Family-6 #21 (policy tripwires)** — one declarative rule in the policy DSL. A
/// [`PolicyTripwire`] carries a set of these and fires a [`Finding`] on the
/// world-delta whenever a rule is broken. Unlike the fixed algorithmic tripwires
/// (`CallGraphDeltaTripwire` …), these are **operator-authored assertions about
/// what may change** between two snapshots — the "no new native FFI in transitive
/// deps / no dep added without provenance / capability surface may not grow without
/// review" rules the catalog calls for, expressed as data rather than code.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyRule {
    /// *"The reachable capability surface may not grow without review."* Any
    /// [`Capability`] reachable from entrypoints in `to` but not in `from` is a
    /// violation.
    NoCapabilityGrowth,
    /// *"No new native code in transitive deps."* A [`NoCapabilityGrowth`] narrowed
    /// to the native-execution capabilities ([`Capability::Ffi`] /
    /// [`Capability::Unsafe`]) — the common "no new FFI/`unsafe`" gate.
    ///
    /// [`NoCapabilityGrowth`]: PolicyRule::NoCapabilityGrowth
    NoNewNativeCode,
    /// *"No dependency added without provenance."* A [`DepNode`] present in `to` but
    /// not `from` whose [`DepNode::verified`] is `false` is a violation.
    NoUnverifiedDependencyAdded,
    /// *"No dependency from registry X."* A [`DepNode`] added in `to` whose
    /// [`DepNode::ecosystem`] equals the named registry is a violation.
    ForbidEcosystem(String),
}

impl PolicyRule {
    /// A short stable label for findings / logs.
    pub fn label(&self) -> String {
        match self {
            PolicyRule::NoCapabilityGrowth => "no-capability-growth".into(),
            PolicyRule::NoNewNativeCode => "no-new-native-code".into(),
            PolicyRule::NoUnverifiedDependencyAdded => "no-unverified-dep-added".into(),
            PolicyRule::ForbidEcosystem(e) => format!("forbid-ecosystem:{e}"),
        }
    }

    /// Evaluate this single rule against the delta `from` → `to`.
    fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding> {
        let violation = |detail: String, subject: &str| Finding {
            severity: Severity::High,
            id: format!("MODGUNN-POLICY:{}:{subject}", self.label()),
            summary: format!("policy `{}` violated: {detail}", self.label()),
        };
        match self {
            PolicyRule::NoCapabilityGrowth | PolicyRule::NoNewNativeCode => {
                let before = capability_surface(from);
                let after = capability_surface(to);
                let native = matches!(self, PolicyRule::NoNewNativeCode);
                let mut gained: Vec<Capability> = after.difference(&before).copied().collect();
                if native {
                    gained.retain(|c| matches!(c, Capability::Ffi | Capability::Unsafe));
                }
                gained.sort_by_key(|c| c.label());
                gained
                    .into_iter()
                    .map(|c| {
                        violation(
                            format!(
                                "capability `{}` became reachable from entrypoints",
                                c.label()
                            ),
                            c.label(),
                        )
                    })
                    .collect()
            }
            PolicyRule::NoUnverifiedDependencyAdded | PolicyRule::ForbidEcosystem(_) => {
                let old: HashSet<&str> = from.dep_nodes.iter().map(|n| n.key.as_str()).collect();
                let mut added: Vec<&DepNode> = to
                    .dep_nodes
                    .iter()
                    .filter(|n| !old.contains(n.key.as_str()))
                    .collect();
                added.sort_by(|a, b| a.key.cmp(&b.key));
                added
                    .into_iter()
                    .filter_map(|n| match self {
                        PolicyRule::NoUnverifiedDependencyAdded if !n.verified => Some(violation(
                            format!("dependency `{}` was added without provenance", n.key),
                            &n.key,
                        )),
                        PolicyRule::ForbidEcosystem(e) if &n.ecosystem == e => Some(violation(
                            format!(
                                "dependency `{}` was added from forbidden registry `{e}`",
                                n.key
                            ),
                            &n.key,
                        )),
                        _ => None,
                    })
                    .collect()
            }
        }
    }
}

/// **Family-6 #21 (policy tripwires)**: a [`Tripwire`] that fires when any of its
/// declarative [`PolicyRule`]s is broken across the world-delta. Construct it with
/// the operator's ruleset (or [`default_policy`]) and plug it into
/// [`diff_with`] alongside the algorithmic battery.
#[derive(Debug, Clone, Default)]
pub struct PolicyTripwire {
    pub rules: Vec<PolicyRule>,
}

impl PolicyTripwire {
    pub fn new(rules: impl IntoIterator<Item = PolicyRule>) -> Self {
        Self {
            rules: rules.into_iter().collect(),
        }
    }
}

impl Tripwire for PolicyTripwire {
    fn name(&self) -> &str {
        "policy"
    }
    fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding> {
        let mut out = Vec::new();
        for r in &self.rules {
            out.extend(r.evaluate(from, to));
        }
        out
    }
}

/// A sensible default policy: the capability surface may not grow, and no
/// dependency may be added without provenance — the two highest-value assertions
/// for a supply-chain gate.
pub fn default_policy() -> PolicyTripwire {
    PolicyTripwire::new([
        PolicyRule::NoCapabilityGrowth,
        PolicyRule::NoUnverifiedDependencyAdded,
    ])
}

// ── Family-6 #22 — threshold tripwires (declarative threshold DSL) ────────────

/// **Family-6 #22 (threshold tripwires)** — one declarative *numeric* threshold in
/// the tripwire DSL. Where a [`PolicyRule`] (#21) asserts a **boolean** invariant
/// ("no new native code"), a [`ThresholdRule`] fires when a **measured quantity**
/// crosses an operator-set bound — "centrality of an unverified dep crossed T",
/// "entropy spike > σ", "capability set grew by ≥ k" — the catalog's #22.
#[derive(Debug, Clone, PartialEq)]
pub enum ThresholdRule {
    /// Fire on any **unverified** dep whose blast-radius ([`blast_radius`]) PageRank
    /// centrality in `to` strictly exceeds this score — a high-leverage dep the
    /// world can't vouch for.
    UnverifiedCentralityOver(f64),
    /// Fire when the reachable [`capability_surface`] grew by **at least** this many
    /// distinct capabilities across the delta.
    CapabilitySurfaceGrewBy(usize),
    /// Fire on any **newly-introduced** symbol whose display-name Shannon entropy
    /// (over names ≥ 8 chars) strictly exceeds this bits/byte bound — a declarative
    /// twin of [`ObfuscationTripwire`] with an operator-set σ.
    NewSymbolEntropyOver(f64),
    /// Fire on any dep whose dep-graph **fan-in** (in-degree) in `to` is at least
    /// this many — a concentration threshold over the resolved tree.
    FanInAtLeast(usize),
}

impl ThresholdRule {
    /// A short stable label for findings / logs.
    pub fn label(&self) -> String {
        match self {
            ThresholdRule::UnverifiedCentralityOver(t) => format!("unverified-centrality>{t}"),
            ThresholdRule::CapabilitySurfaceGrewBy(k) => format!("capability-growth>={k}"),
            ThresholdRule::NewSymbolEntropyOver(t) => format!("new-symbol-entropy>{t}"),
            ThresholdRule::FanInAtLeast(k) => format!("fan-in>={k}"),
        }
    }

    fn finding(&self, severity: Severity, subject: &str, detail: String) -> Finding {
        Finding {
            severity,
            id: format!("MODGUNN-THRESHOLD:{}:{subject}", self.label()),
            summary: format!("threshold `{}` crossed: {detail}", self.label()),
        }
    }

    fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding> {
        match self {
            ThresholdRule::UnverifiedCentralityOver(t) => {
                let verified: HashMap<&str, bool> = to
                    .dep_nodes
                    .iter()
                    .map(|n| (n.key.as_str(), n.verified))
                    .collect();
                blast_radius(to)
                    .into_iter()
                    .filter(|(k, score)| {
                        score > t && !verified.get(k.as_str()).copied().unwrap_or(false)
                    })
                    .map(|(k, score)| {
                        self.finding(
                            Severity::High,
                            &k,
                            format!("unverified dep `{k}` centrality {score:.4} > {t}"),
                        )
                    })
                    .collect()
            }
            ThresholdRule::CapabilitySurfaceGrewBy(k) => {
                let before = capability_surface(from).len();
                let after = capability_surface(to).len();
                let grew = after.saturating_sub(before);
                if grew >= *k && *k > 0 {
                    vec![self.finding(
                        Severity::High,
                        "surface",
                        format!("capability surface grew by {grew} (≥ {k})"),
                    )]
                } else {
                    Vec::new()
                }
            }
            ThresholdRule::NewSymbolEntropyOver(t) => {
                let old: HashSet<&str> = from.symbols.iter().map(|s| s.id.as_str()).collect();
                let mut new: Vec<&Symbol> = to
                    .symbols
                    .iter()
                    .filter(|s| !old.contains(s.id.as_str()))
                    .collect();
                new.sort_by(|a, b| a.id.cmp(&b.id));
                new.into_iter()
                    .filter(|s| s.display.len() >= 8)
                    .filter_map(|s| {
                        let h = shannon_entropy(&s.display);
                        (h > *t).then(|| {
                            self.finding(
                                Severity::Medium,
                                &s.id,
                                format!(
                                    "new symbol `{}` entropy {h:.2} > {t} bits/byte",
                                    s.display
                                ),
                            )
                        })
                    })
                    .collect()
            }
            ThresholdRule::FanInAtLeast(k) => {
                let mut indeg: HashMap<&str, usize> = HashMap::new();
                for n in &to.dep_nodes {
                    indeg.entry(n.key.as_str()).or_insert(0);
                }
                for e in &to.dep_edges {
                    *indeg.entry(e.to.as_str()).or_insert(0) += 1;
                }
                let mut hits: Vec<(&str, usize)> = indeg
                    .into_iter()
                    .filter(|(_, d)| *d >= *k && *k > 0)
                    .collect();
                hits.sort_by(|a, b| a.0.cmp(b.0));
                hits.into_iter()
                    .map(|(key, d)| {
                        self.finding(
                            Severity::High,
                            key,
                            format!("dep `{key}` fan-in {d} (≥ {k}) — concentration point"),
                        )
                    })
                    .collect()
            }
        }
    }
}

/// **Family-6 #22 (threshold tripwires)**: a [`Tripwire`] firing when any of its
/// declarative [`ThresholdRule`]s is crossed on the world-delta.
#[derive(Debug, Clone, Default)]
pub struct ThresholdTripwire {
    pub rules: Vec<ThresholdRule>,
}

impl ThresholdTripwire {
    pub fn new(rules: impl IntoIterator<Item = ThresholdRule>) -> Self {
        Self {
            rules: rules.into_iter().collect(),
        }
    }
}

impl Tripwire for ThresholdTripwire {
    fn name(&self) -> &str {
        "threshold"
    }
    fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding> {
        let mut out = Vec::new();
        for r in &self.rules {
            out.extend(r.evaluate(from, to));
        }
        out
    }
}

/// The default world-delta tripwire battery (the catalog first-slice).
pub fn default_tripwires() -> Vec<Box<dyn Tripwire>> {
    vec![
        Box::new(CallGraphDeltaTripwire),
        Box::new(CapabilityDriftTripwire),
        Box::new(SinkNewlyReachableTripwire),
        Box::new(FingerprintDriftTripwire),
        Box::new(ObfuscationTripwire::default()),
        Box::new(MaintainerAnomalyTripwire),
        Box::new(SbomDriftTripwire),
    ]
}

/// Diff two world snapshots into a [`BehaviorDelta`] by running the default
/// tripwire battery and folding their findings.
pub fn diff(from: &WorldSnapshot, to: &WorldSnapshot) -> BehaviorDelta {
    diff_with(from, to, &default_tripwires())
}

/// Diff with an explicit tripwire set (for the heavy world-delta site to plug in
/// extra analyses).
pub fn diff_with(
    from: &WorldSnapshot,
    to: &WorldSnapshot,
    tripwires: &[Box<dyn Tripwire>],
) -> BehaviorDelta {
    let mut findings = Vec::new();
    for tw in tripwires {
        findings.extend(tw.evaluate(from, to));
    }
    let delta = BehaviorDelta {
        from: from.id.clone(),
        to: to.id.clone(),
        findings,
    };
    // Introspection marker — Engine B reports each world-delta diff to the test
    // matrix. A clean delta (no tripwire finding) is the healthy verdict.
    #[cfg(feature = "testmatrix")]
    crate::functional_status(
        "modgunn/engine-b-scip",
        "behavior_diff",
        delta.findings.is_empty(),
        &format!(
            "{} → {}: {} tripwire finding(s)",
            delta.from,
            delta.to,
            delta.findings.len()
        ),
    );
    delta
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The OSV-symbol → reachability-sink bridge: a Go `(path, symbol)` becomes the
    /// fully-qualified call-graph id `path.symbol` the A8 gate joins on; a method
    /// keeps its `Type.Method`; a whole-package record (`symbol == ""`) has no single
    /// function to gate on → `None`. RED if the id convention drifts from what a Go
    /// SCIP indexer emits for `Symbol::id`.
    #[cfg(feature = "scan")]
    #[test]
    fn vuln_sink_from_go_symbol_uses_fully_qualified_id() {
        use crate::osv::VulnSymbol;
        let f = VulnSymbol {
            id: "GO-2024-0001".into(),
            ecosystem: "go".into(),
            package: "github.com/foo/bar".into(),
            symbol: "Parse".into(),
            severity: Severity::High,
        };
        let sink = VulnSink::from_vuln_symbol(&f).expect("named symbol yields a sink");
        assert_eq!(
            sink.symbol, "github.com/foo/bar.Parse",
            "path.symbol call-graph id"
        );
        assert_eq!(sink.advisory, "GO-2024-0001");
        let m = VulnSymbol {
            symbol: "Decoder.Decode".into(),
            ..f.clone()
        };
        assert_eq!(
            VulnSink::from_vuln_symbol(&m).unwrap().symbol,
            "github.com/foo/bar.Decoder.Decode"
        );
        let whole = VulnSymbol {
            symbol: String::new(),
            ..f
        };
        assert!(
            VulnSink::from_vuln_symbol(&whole).is_none(),
            "whole-package record is not a single sink"
        );
    }

    /// A snapshot: entrypoint `main` → `mid` → `net_sink` (which does Network),
    /// plus an unreachable `dead` symbol that holds a CVE.
    fn base() -> WorldSnapshot {
        let mut s = WorldSnapshot::new("snap-1");
        s.symbols = vec![
            Symbol::new("main", "app@1.0.0").entrypoint(),
            Symbol::new("mid", "midlib@1.0.0"),
            Symbol::new("net_sink", "netlib@1.0.0").with_capability(Capability::Network),
            Symbol::new("dead", "deadlib@1.0.0"),
        ];
        s.calls = vec![
            CallEdge::new("main", "mid"),
            CallEdge::new("mid", "net_sink"),
        ];
        s.vuln_sinks = vec![VulnSink {
            symbol: "dead".into(),
            advisory: "CVE-DEAD".into(),
            severity: Severity::High,
        }];
        s
    }

    #[test]
    fn a8_reachable_vuln_fires_unreachable_suppressed() {
        let mut s = base();
        // Add a reachable vuln on `net_sink`.
        s.vuln_sinks.push(VulnSink {
            symbol: "net_sink".into(),
            advisory: "CVE-LIVE".into(),
            severity: Severity::Critical,
        });
        let fired = reachability_gated_vulns(&s);
        assert_eq!(fired.len(), 1, "only the reachable vuln fires");
        assert_eq!(fired[0].id, "CVE-LIVE");
        // the dead one is suppressed but visible as Info
        let suppressed = unreachable_vulns(&s);
        assert_eq!(suppressed.len(), 1);
        assert_eq!(suppressed[0].id, "CVE-DEAD");
        assert_eq!(suppressed[0].severity, Severity::Info);
    }

    #[test]
    fn a9_capability_surface_is_reachable_only() {
        let s = base();
        let caps = capability_surface(&s);
        assert!(caps.contains(&Capability::Network));
        assert_eq!(caps.len(), 1);
    }

    #[test]
    fn a13_blast_radius_ranks_the_hub_top() {
        let mut s = WorldSnapshot::new("dep");
        // a -> hub, b -> hub, c -> hub : hub is the most depended-on.
        for (e, n, v) in [
            ("app", "a", "1"),
            ("app", "b", "1"),
            ("app", "c", "1"),
            ("dep", "hub", "1"),
        ] {
            s.dep_nodes.push(DepNode::new(e, n, v));
        }
        let k = |n: &str| format!("{}:{}@1", if n == "hub" { "dep" } else { "app" }, n);
        s.dep_edges = vec![
            DepEdge::new(k("a"), k("hub")),
            DepEdge::new(k("b"), k("hub")),
            DepEdge::new(k("c"), k("hub")),
        ];
        let ranked = blast_radius(&s);
        assert_eq!(ranked[0].0, k("hub"), "the hub has the highest centrality");
    }

    #[test]
    fn capability_drift_tripwire_fires_when_surface_grows() {
        let from = base();
        let mut to = base();
        to.id = "snap-2".into();
        // `mid` now also reaches a filesystem sink.
        to.symbols
            .push(Symbol::new("fs_sink", "fslib@1.0.0").with_capability(Capability::Filesystem));
        to.calls.push(CallEdge::new("mid", "fs_sink"));
        let tw = CapabilityDriftTripwire;
        let f = tw.evaluate(&from, &to);
        assert_eq!(f.len(), 1);
        assert!(f[0].id.contains("filesystem"));
        assert_eq!(f[0].severity, Severity::High);
    }

    #[test]
    fn sink_newly_reachable_is_highest_signal() {
        let from = base();
        let mut to = base();
        to.id = "snap-2".into();
        // `dead` becomes reachable: main -> dead.
        to.calls.push(CallEdge::new("main", "dead"));
        let tw = SinkNewlyReachableTripwire;
        let f = tw.evaluate(&from, &to);
        assert_eq!(f.len(), 1);
        assert!(f[0].id.contains("CVE-DEAD"));
        assert_eq!(f[0].severity, Severity::High);
    }

    #[test]
    fn fingerprint_drift_fires_only_on_silent_change() {
        let from = base();
        let mut to = base();
        to.id = "snap-2".into();
        // `mid`'s behaviour changes (now also calls net via a new edge) but its
        // package version + display are unchanged -> the silent signal.
        to.calls.push(CallEdge::new("mid", "dead"));
        let tw = FingerprintDriftTripwire;
        let f = tw.evaluate(&from, &to);
        assert!(f.iter().any(|x| x.id.contains("FINGERPRINT-DRIFT:mid")));

        // If the version DID change, it is NOT a silent drift -> no finding.
        let mut to2 = to.clone();
        for s in to2.symbols.iter_mut() {
            if s.id == "mid" {
                s.package = "midlib@2.0.0".into();
            }
        }
        let f2 = tw.evaluate(&from, &to2);
        assert!(
            !f2.iter().any(|x| x.id.contains("mid")),
            "version change is not silent drift"
        );
    }

    #[test]
    fn diff_folds_all_tripwires_into_a_behavior_delta() {
        let from = base();
        let mut to = base();
        to.id = "snap-2".into();
        to.calls.push(CallEdge::new("main", "dead")); // sink-newly-reachable
        let delta = diff(&from, &to);
        assert_eq!(delta.from, "snap-1");
        assert_eq!(delta.to, "snap-2");
        assert!(delta
            .findings
            .iter()
            .any(|f| f.id.contains("SINK-NEWLY-REACHABLE")));
        assert!(delta
            .findings
            .iter()
            .any(|f| f.id == "MODGUNN-CALLGRAPH-DELTA"));
        assert_eq!(
            delta.decision(),
            Decision::Block,
            "a newly-reachable High sink blocks"
        );
    }

    #[test]
    fn verdict_from_folds_findings_like_engine_a() {
        let s = base();
        let mut s = s;
        s.vuln_sinks.push(VulnSink {
            symbol: "net_sink".into(),
            advisory: "CVE-LIVE".into(),
            severity: Severity::Critical,
        });
        let findings = reachability_gated_vulns(&s);
        let aref = ArtifactRef {
            ecosystem: "world".into(),
            name: "closure".into(),
            version: "snap-1".into(),
            blob_sha256: "snap-1".into(),
        };
        let v = verdict_from(aref, findings, &s.id);
        assert_eq!(v.decision, Decision::Block);
        assert_eq!(v.advisory_db_rev, "snap-1");
    }

    #[test]
    fn a15_diamond_flags_multi_version_and_escalates_unverified() {
        let mut s = WorldSnapshot::new("d");
        // `serde` at two versions — one unverified → High. `once` single version → none.
        s.dep_nodes = vec![
            DepNode::new("cargo", "serde", "1.0.0").verified(),
            DepNode::new("cargo", "serde", "1.1.0"), // unverified
            DepNode::new("cargo", "once", "1.0.0").verified(),
        ];
        let f = diamond_conflicts(&s);
        assert_eq!(f.len(), 1, "only serde is a diamond");
        assert!(f[0].id.contains("serde"));
        assert_eq!(
            f[0].severity,
            Severity::High,
            "unverified version in the diamond escalates"
        );

        // All-verified diamond stays Low.
        s.dep_nodes[1] = DepNode::new("cargo", "serde", "1.1.0").verified();
        assert_eq!(diamond_conflicts(&s)[0].severity, Severity::Low);
    }

    #[test]
    fn a14_chokepoint_is_the_only_bridge_to_a_sink() {
        // main -> gate -> vuln ; gate is the single-vertex cut to `vuln`.
        let mut s = WorldSnapshot::new("c");
        s.symbols = vec![
            Symbol::new("main", "app@1").entrypoint(),
            Symbol::new("gate", "mid@1"),
            Symbol::new("vuln", "bad@1"),
        ];
        s.calls = vec![CallEdge::new("main", "gate"), CallEdge::new("gate", "vuln")];
        s.vuln_sinks = vec![VulnSink {
            symbol: "vuln".into(),
            advisory: "CVE-X".into(),
            severity: Severity::High,
        }];
        let f = sink_chokepoints(&s);
        assert_eq!(f.len(), 1);
        assert!(f[0].summary.contains("gate"), "gate is the chokepoint");
        assert!(
            !f[0].summary.contains("main"),
            "an entrypoint is never a chokepoint"
        );

        // Add a second independent path main -> vuln : now there is NO single-vertex cut.
        s.calls.push(CallEdge::new("main", "vuln"));
        assert!(
            sink_chokepoints(&s).is_empty(),
            "two disjoint paths ⇒ no single chokepoint"
        );
    }

    #[test]
    fn a12_bus_factor_flags_central_single_maintainer() {
        let mut s = WorldSnapshot::new("b");
        for (e, n, v) in [("app", "a", "1"), ("app", "b", "1"), ("dep", "hub", "1")] {
            let node = DepNode::new(e, n, v);
            s.dep_nodes.push(if n == "hub" {
                node.with_maintainers(1)
            } else {
                node.with_maintainers(9)
            });
        }
        let k = |n: &str| format!("{}:{}@1", if n == "hub" { "dep" } else { "app" }, n);
        s.dep_edges = vec![
            DepEdge::new(k("a"), k("hub")),
            DepEdge::new(k("b"), k("hub")),
        ];
        let f = bus_factor_findings(&s, 3);
        assert_eq!(f.len(), 1, "only the central single-maintainer hub fires");
        assert!(f[0].id.contains("hub"));
        assert_eq!(f[0].severity, Severity::High);
    }

    #[test]
    fn a8_typosquat_and_dependency_confusion() {
        let mut s = WorldSnapshot::new("t");
        s.dep_nodes = vec![
            DepNode::new("npm", "reqiest", "1.0.0"), // one edit from "request" → typosquat
            DepNode::new("npm", "request", "1.0.0"), // exact popular → clean
            DepNode::new("npm", "@acme/secrets", "1.0.0"), // internal namespace → dep-confusion
        ];
        let f = typosquat_findings(&s, &["request", "lodash"], &["@acme/"]);
        assert!(f
            .iter()
            .any(|x| x.id == "MODGUNN-TYPOSQUAT:reqiest" && x.severity == Severity::High));
        assert!(f
            .iter()
            .any(|x| x.id == "MODGUNN-DEP-CONFUSION:@acme/secrets"
                && x.severity == Severity::Critical));
        assert!(
            !f.iter().any(|x| x.id.contains(":request")),
            "the exact popular name is not flagged"
        );
    }

    #[test]
    fn levenshtein_basics() {
        assert_eq!(levenshtein("request", "reqiest"), 1);
        assert_eq!(levenshtein("abc", "abc"), 0);
        assert_eq!(levenshtein("", "abc"), 3);
        assert_eq!(levenshtein("kitten", "sitting"), 3);
    }

    #[test]
    fn a16_fanin_outlier_and_zscore() {
        // hub has fan-in 3, everything else 0 → hub is a positive-z outlier.
        let mut s = WorldSnapshot::new("f");
        for (e, n) in [("app", "a"), ("app", "b"), ("app", "c"), ("dep", "hub")] {
            s.dep_nodes.push(DepNode::new(e, n, "1"));
        }
        let key = |n: &str| format!("{}:{}@1", if n == "hub" { "dep" } else { "app" }, n);
        s.dep_edges = vec![
            DepEdge::new(key("a"), key("hub")),
            DepEdge::new(key("b"), key("hub")),
            DepEdge::new(key("c"), key("hub")),
        ];
        let f = dep_fanin_outliers(&s, 1.0);
        assert!(f
            .iter()
            .any(|x| x.id.contains("hub") && x.severity == Severity::Medium));
        // zero-variance input → no outliers.
        assert!(zscore_outliers(&[("x".into(), 5.0), ("y".into(), 5.0)], 0.5).is_empty());
    }

    #[test]
    fn release_cadence_flags_dormant_then_publish() {
        const DAY: i64 = 86_400;
        let mut s = WorldSnapshot::new("cadence");
        // `steady`: a stable ~weekly rhythm for 10 releases, then a ~3-year silence
        // broken by a sudden newest publish — the takeover-shaped signal.
        let mut steady = vec![0i64];
        for _ in 0..9 {
            steady.push(steady.last().unwrap() + 7 * DAY);
        }
        steady.push(steady.last().unwrap() + 1100 * DAY); // long dormancy → newest release
                                                          // `regular`: a metronome cadence — no gap is an outlier, must NOT fire.
        let regular: Vec<i64> = (0..10).map(|i| i * 7 * DAY).collect();
        s.dep_nodes = vec![
            DepNode::new("cargo", "steady", "2.0.0").with_release_epochs(steady.clone()),
            DepNode::new("cargo", "regular", "2.0.0").with_release_epochs(regular),
            // too few timestamps to baseline against → skipped, never fires.
            DepNode::new("cargo", "young", "0.1.0").with_release_epochs([0, 7 * DAY]),
        ];

        let f = release_cadence_anomalies(&s, 2.5);
        assert_eq!(f.len(), 1, "only the dormant-then-publish package fires");
        assert_eq!(f[0].id, "MODGUNN-RELEASE-CADENCE:cargo:steady");
        assert_eq!(
            f[0].severity,
            Severity::High,
            "newest-release dormancy is the takeover signal"
        );
        assert!(f[0].summary.contains("newest release"));

        // Order-independence: shuffling the timestamps yields the same verdict.
        let mut shuffled = steady.clone();
        shuffled.reverse();
        s.dep_nodes[0] = DepNode::new("cargo", "steady", "2.0.0").with_release_epochs(shuffled);
        assert_eq!(
            release_cadence_anomalies(&s, 2.5)[0].id,
            "MODGUNN-RELEASE-CADENCE:cargo:steady"
        );

        // An empty timeline is silent (input simply not captured).
        s.dep_nodes = vec![DepNode::new("cargo", "steady", "2.0.0")];
        assert!(release_cadence_anomalies(&s, 2.5).is_empty());
    }

    #[test]
    fn change_velocity_flags_release_burst_only() {
        const DAY: i64 = 86_400;
        let mut s = WorldSnapshot::new("velocity");
        // `burst`: a slow monthly cadence (velocity ~1 / 30-day window) for 10
        // releases, then a sudden flurry of 5 releases on consecutive days — the
        // rapid-fire churn signal, and it is the *newest* activity → High.
        let mut burst: Vec<i64> = (0..10).map(|i| i * 30 * DAY).collect();
        let base = 500 * DAY;
        for i in 0..5 {
            burst.push(base + i * DAY);
        }
        // `steady`: a metronome monthly cadence — constant velocity, no burst → silent.
        let steady: Vec<i64> = (0..15).map(|i| i * 30 * DAY).collect();
        s.dep_nodes = vec![
            DepNode::new("cargo", "burst", "3.0.0").with_release_epochs(burst.clone()),
            DepNode::new("cargo", "steady", "3.0.0").with_release_epochs(steady),
            // Too few releases to baseline a velocity against → skipped, never fires.
            DepNode::new("cargo", "young", "0.2.0").with_release_epochs([0, DAY, 2 * DAY]),
            // A lone late publish after a long freeze is a #7 dormancy, NOT a
            // velocity burst (its trailing-window count is 1) → must stay silent here.
            DepNode::new("cargo", "revived", "9.0.0").with_release_epochs([
                0,
                7 * DAY,
                14 * DAY,
                21 * DAY,
                28 * DAY,
                1100 * DAY,
            ]),
        ];

        let f = change_velocity_anomalies(&s, 30.0, 2.0);
        assert_eq!(f.len(), 1, "only the release-burst package fires");
        assert_eq!(f[0].id, "MODGUNN-CHANGE-VELOCITY:cargo:burst");
        assert_eq!(
            f[0].severity,
            Severity::High,
            "the burst is the newest activity"
        );
        assert!(f[0].summary.contains("active release burst"));

        // Order-independence: shuffling the timestamps yields the same verdict.
        let mut shuffled = burst.clone();
        shuffled.reverse();
        s.dep_nodes[0] = DepNode::new("cargo", "burst", "3.0.0").with_release_epochs(shuffled);
        assert_eq!(
            change_velocity_anomalies(&s, 30.0, 2.0)[0].id,
            "MODGUNN-CHANGE-VELOCITY:cargo:burst"
        );

        // An empty timeline is silent (input simply not captured).
        s.dep_nodes = vec![DepNode::new("cargo", "burst", "3.0.0")];
        assert!(change_velocity_anomalies(&s, 30.0, 2.0).is_empty());
    }

    #[test]
    fn a17_obfuscation_fires_on_new_high_entropy_symbol() {
        let mut from = WorldSnapshot::new("o1");
        from.symbols = vec![Symbol::new("readConfigFile", "app@1")];
        let mut to = WorldSnapshot::new("o2");
        to.symbols = vec![
            Symbol::new("readConfigFile", "app@1"), // pre-existing, ignored
            {
                let mut s = Symbol::new("x1Yb3Kf9Qm2Wp7Zr4Ns8Lc6Vd0", "app@1"); // packed/random, high entropy
                s.display = "x1Yb3Kf9Qm2Wp7Zr4Ns8Lc6Vd0".into();
                s
            },
        ];
        let tw = ObfuscationTripwire::default();
        let f = tw.evaluate(&from, &to);
        assert_eq!(f.len(), 1, "only the new high-entropy symbol fires");
        assert!(f[0].id.contains("x1Yb3Kf9"));
        // a plain-English new name of the same length does NOT fire.
        let mut to2 = WorldSnapshot::new("o3");
        to2.symbols = vec![
            Symbol::new("readConfigFile", "app@1"),
            Symbol::new("initializeAllServicesAndConnections", "app@1"),
        ];
        assert!(
            tw.evaluate(&from, &to2).is_empty(),
            "low-entropy identifier is clean"
        );
    }

    #[test]
    fn shannon_entropy_ranks_random_above_repetitive() {
        assert!(shannon_entropy("aaaaaaaa") < 0.01);
        assert!(shannon_entropy("x1Yb3Kf9Qm2Wp7Zr4Ns8Lc6Vd0") > 4.0);
        assert!(shannon_entropy("initializeAllServicesAndConnections") < 4.0);
        assert_eq!(shannon_entropy(""), 0.0);
    }

    #[test]
    fn owner_cluster_flags_single_entity_cluster_only() {
        let mut s = WorldSnapshot::new("owners");
        s.dep_nodes = vec![
            // A single entity `eve` quietly owns three "independent" packages →
            // one co-ownership community, full coverage → High.
            DepNode::new("npm", "p1", "1").with_owners(["eve"]),
            DepNode::new("npm", "p2", "1").with_owners(["eve"]),
            DepNode::new("npm", "p3", "1").with_owners(["eve"]),
            // Two genuinely independent, unrelated owners → singleton communities,
            // must stay silent.
            DepNode::new("npm", "q1", "1").with_owners(["alice"]),
            DepNode::new("npm", "q2", "1").with_owners(["bob"]),
            // No ownership metadata captured → ignored entirely.
            DepNode::new("npm", "r1", "1"),
        ];
        let f = owner_cluster_findings(&s, 2);
        assert_eq!(f.len(), 1, "only the eve-controlled cluster fires");
        assert_eq!(
            f[0].severity,
            Severity::High,
            "one owner controls the whole cluster"
        );
        assert!(f[0].summary.contains("eve"));
        assert!(f[0].summary.contains("controls 3/3"));

        // Order-independence: shuffling the dep-node order yields the same verdict.
        s.dep_nodes.reverse();
        let f2 = owner_cluster_findings(&s, 2);
        assert_eq!(f2, f, "verdict is independent of dep-node order");

        // A cluster where no single owner covers every dep → Medium (a shared
        // owner links the deps transitively, but coverage is only a plurality).
        let mut m = WorldSnapshot::new("owners-partial");
        m.dep_nodes = vec![
            DepNode::new("npm", "a", "1").with_owners(["eve", "x"]),
            DepNode::new("npm", "b", "1").with_owners(["eve", "y"]),
            DepNode::new("npm", "c", "1").with_owners(["y", "z"]),
        ];
        let fm = owner_cluster_findings(&m, 2);
        assert_eq!(fm.len(), 1, "a,b,c form one community via eve/y overlap");
        assert_eq!(
            fm[0].severity,
            Severity::Medium,
            "no owner covers the whole cluster"
        );

        // min_cluster_size gate: raising the floor above the cluster size silences it.
        assert!(owner_cluster_findings(&s, 4).is_empty());
    }

    #[test]
    fn license_risk_tier_ranks_copyleft_above_permissive() {
        assert_eq!(license_risk_tier("MIT"), 0);
        assert_eq!(license_risk_tier("Apache-2.0"), 0);
        assert_eq!(license_risk_tier("BSD-3-Clause"), 0);
        assert_eq!(license_risk_tier("LGPL-3.0-only"), 1); // weak, not strong
        assert_eq!(license_risk_tier("MPL-2.0"), 1);
        assert_eq!(license_risk_tier("GPL-3.0-or-later"), 2);
        assert_eq!(license_risk_tier("AGPL-3.0-only"), 3); // AGPL is not plain GPL
        assert_eq!(license_risk_tier("SSPL-1.0"), 3);
        assert_eq!(license_risk_tier("BUSL-1.1"), 4); // source-available, non-open
        assert_eq!(license_risk_tier(""), 4);
        assert_eq!(license_risk_tier("NOASSERTION"), 4);
    }

    #[test]
    fn license_transition_flags_riskward_relicense_only() {
        let mut s = WorldSnapshot::new("license");
        s.dep_nodes = vec![
            // Rug-pull: MIT → Apache (no jump) → AGPL (permissive→network-copyleft).
            // The largest jump (Apache→AGPL) is reported → High.
            DepNode::new("cargo", "rugpull", "3.0.0").with_license_history([
                ("1.0.0", "MIT"),
                ("2.0.0", "Apache-2.0"),
                ("3.0.0", "AGPL-3.0-only"),
            ]),
            // MIT → GPL: permissive → strong copyleft → Medium.
            DepNode::new("npm", "copyleft", "2.0.0")
                .with_license_history([("1.0.0", "MIT"), ("2.0.0", "GPL-3.0-or-later")]),
            // GPL → MIT relaxation (riskier → safer) → must NOT fire.
            DepNode::new("pip", "relaxed", "2.0.0")
                .with_license_history([("1.0.0", "GPL-3.0"), ("2.0.0", "MIT")]),
            // Stable permissive across versions → silent.
            DepNode::new("cargo", "stable", "2.0.0")
                .with_license_history([("1.0.0", "MIT"), ("2.0.0", "MIT")]),
            // No captured license history → ignored entirely.
            DepNode::new("cargo", "nolic", "1.0.0"),
        ];

        let f = license_transition_findings(&s);
        assert_eq!(f.len(), 2, "only the two riskward relicenses fire");
        let rug = f
            .iter()
            .find(|x| x.id == "MODGUNN-LICENSE-TRANSITION:cargo:rugpull")
            .unwrap();
        assert_eq!(
            rug.severity,
            Severity::High,
            "→AGPL network-copyleft is High"
        );
        assert!(
            rug.summary.contains("Apache-2.0") && rug.summary.contains("AGPL-3.0-only"),
            "reports the largest jump (Apache→AGPL), not the no-op MIT→Apache step"
        );
        let cl = f
            .iter()
            .find(|x| x.id == "MODGUNN-LICENSE-TRANSITION:npm:copyleft")
            .unwrap();
        assert_eq!(
            cl.severity,
            Severity::Medium,
            "→GPL strong-copyleft is Medium"
        );
        assert!(
            !f.iter().any(|x| x.id.contains("relaxed")),
            "a relax to a safer license never fires"
        );
        assert!(
            !f.iter().any(|x| x.id.contains("stable")),
            "a stable license never fires"
        );

        // Order-independence over the dep-node set.
        s.dep_nodes.reverse();
        assert_eq!(
            license_transition_findings(&s),
            f,
            "verdict is independent of dep-node order"
        );

        // Empty / single-point history ⇒ silent.
        let mut e = WorldSnapshot::new("e");
        e.dep_nodes = vec![
            DepNode::new("cargo", "x", "1"),
            DepNode::new("cargo", "y", "1").with_license_history([("1.0.0", "MIT")]),
        ];
        assert!(license_transition_findings(&e).is_empty());
    }

    #[test]
    fn embedding_novelty_flags_far_from_ecosystem_norm_only() {
        let mut s = WorldSnapshot::new("embed");
        s.dep_nodes = vec![
            // cargo cohort: three near-identical vectors clustered around [1,0,0]…
            DepNode::new("cargo", "n1", "1").with_embedding(vec![1.0f32, 0.0, 0.0]),
            DepNode::new("cargo", "n2", "1").with_embedding(vec![0.9f32, 0.1, 0.0]),
            DepNode::new("cargo", "n3", "1").with_embedding(vec![1.0f32, 0.05, 0.0]),
            // …and one orthogonal, semantically-novel outlier → fires.
            DepNode::new("cargo", "novel", "1").with_embedding(vec![0.0f32, 1.0, 0.0]),
            // pip cohort: three tightly-clustered vectors, no outlier → silent.
            DepNode::new("pip", "p1", "1").with_embedding(vec![0.0f32, 0.0, 1.0]),
            DepNode::new("pip", "p2", "1").with_embedding(vec![0.02f32, 0.0, 1.0]),
            DepNode::new("pip", "p3", "1").with_embedding(vec![0.0f32, 0.01, 1.0]),
            // npm: only two embedded nodes → no stable norm, skipped entirely.
            DepNode::new("npm", "q1", "1").with_embedding(vec![1.0f32, 0.0]),
            DepNode::new("npm", "q2", "1").with_embedding(vec![0.0f32, 1.0]),
            // No embedding captured → ignored.
            DepNode::new("cargo", "noembed", "1"),
        ];

        let f = embedding_novelty_findings(&s, 0.4);
        assert_eq!(f.len(), 1, "only the orthogonal cargo outlier fires");
        assert_eq!(f[0].id, "MODGUNN-EMBEDDING-NOVELTY:cargo:novel@1");
        assert_eq!(f[0].severity, Severity::Medium, "far but not > 2×threshold");
        assert!(f[0].summary.contains("semantically-novel"));

        // Order-independence over the dep-node set.
        s.dep_nodes.reverse();
        assert_eq!(
            embedding_novelty_findings(&s, 0.4),
            f,
            "verdict is independent of node order"
        );

        // No embeddings captured ⇒ silent (honest empty input).
        let mut e = WorldSnapshot::new("e");
        e.dep_nodes = vec![
            DepNode::new("cargo", "x", "1"),
            DepNode::new("cargo", "y", "1"),
        ];
        assert!(embedding_novelty_findings(&e, 0.4).is_empty());
    }

    #[test]
    fn cosine_distance_basics() {
        assert_eq!(cosine_distance(&[1.0, 0.0], &[1.0, 0.0]), Some(0.0));
        assert_eq!(cosine_distance(&[1.0, 0.0], &[0.0, 1.0]), Some(1.0)); // orthogonal
        assert_eq!(cosine_distance(&[1.0], &[1.0, 0.0]), None); // dim mismatch
        assert_eq!(cosine_distance(&[0.0, 0.0], &[1.0, 0.0]), None); // zero magnitude
    }

    #[test]
    fn taint_path_flags_reachable_source_to_sink_only() {
        let mut s = WorldSnapshot::new("taint");
        s.symbols = vec![
            Symbol::new("net_in", "net@1")
                .taint_source()
                .with_capability(Capability::Network),
            Symbol::new("parse", "app@1"),
            Symbol::new("exec", "os@1").with_capability(Capability::Process),
            // A sensitive sink NOT reachable from the source → must stay silent.
            Symbol::new("orphan_write", "fs@1").with_capability(Capability::Filesystem),
        ];
        s.calls = vec![
            CallEdge::new("net_in", "parse"),
            CallEdge::new("parse", "exec"),
        ];
        let f = taint_path_findings(&s, &default_taint_sinks());
        assert_eq!(f.len(), 1, "only the reachable source→sink path fires");
        assert_eq!(f[0].id, "MODGUNN-TAINT-PATH:net_in->exec");
        assert_eq!(
            f[0].severity,
            Severity::Critical,
            "process-exec sink is critical"
        );

        // No taint source marked ⇒ the analysis doesn't fire (honest empty input).
        let mut clean = s.clone();
        for sym in clean.symbols.iter_mut() {
            sym.is_taint_source = false;
        }
        assert!(taint_path_findings(&clean, &default_taint_sinks()).is_empty());

        // Order-independence: reversing symbols + edges gives an identical verdict.
        let mut shuffled = s.clone();
        shuffled.symbols.reverse();
        shuffled.calls.reverse();
        assert_eq!(taint_path_findings(&shuffled, &default_taint_sinks()), f);
    }

    #[test]
    fn maintainer_anomaly_flags_ownership_transfer() {
        let mk = |owners: &[&str], maint: u32| {
            DepNode::new("npm", "left-pad", "1")
                .with_owners(owners.to_vec())
                .with_maintainers(maint)
        };
        let mut from = WorldSnapshot::new("m1");
        from.dep_nodes = vec![mk(&["alice", "bob"], 2)];
        // Full takeover: owners entirely replaced → High.
        let mut to = WorldSnapshot::new("m2");
        to.dep_nodes = vec![mk(&["mallory"], 1)];
        let f = MaintainerAnomalyTripwire.evaluate(&from, &to);
        assert_eq!(f.len(), 1);
        assert_eq!(f[0].id, "MODGUNN-MAINTAINER-ANOMALY:npm:left-pad");
        assert_eq!(
            f[0].severity,
            Severity::High,
            "disjoint owner set is a takeover"
        );
        assert!(f[0].summary.contains("fully replaced"));

        // Partial change (a co-owner added) → Medium.
        let mut to2 = WorldSnapshot::new("m3");
        to2.dep_nodes = vec![mk(&["alice", "bob", "carol"], 3)];
        let f2 = MaintainerAnomalyTripwire.evaluate(&from, &to2);
        assert_eq!(f2.len(), 1);
        assert_eq!(f2[0].severity, Severity::Medium);

        // Identical ownership ⇒ no anomaly.
        assert!(MaintainerAnomalyTripwire.evaluate(&from, &from).is_empty());

        // Maintainer-count collapse with unchanged owners → Low co-signal.
        let mut to3 = WorldSnapshot::new("m4");
        to3.dep_nodes = vec![mk(&["alice", "bob"], 1)];
        let f3 = MaintainerAnomalyTripwire.evaluate(&from, &to3);
        assert_eq!(f3.len(), 1);
        assert_eq!(f3[0].severity, Severity::Low);
        assert!(f3[0].id.contains("MAINTAINER-COLLAPSE"));

        // A package present in only one snapshot is not an ownership *transfer*.
        let mut only_new = WorldSnapshot::new("m5");
        only_new.dep_nodes = vec![
            mk(&["dave"], 1),
            DepNode::new("npm", "fresh", "1").with_owners(vec!["eve"]),
        ];
        let f4 = MaintainerAnomalyTripwire.evaluate(&from, &only_new);
        assert!(f4.iter().all(|x| !x.summary.contains("fresh")));
    }

    #[test]
    fn sbom_drift_reports_entered_and_left_components() {
        let mut from = WorldSnapshot::new("s1");
        from.dep_nodes = vec![
            DepNode::new("cargo", "keep", "1").verified(),
            DepNode::new("cargo", "gone", "1"),
        ];
        let mut to = WorldSnapshot::new("s2");
        to.dep_nodes = vec![
            DepNode::new("cargo", "keep", "1").verified(),
            DepNode::new("cargo", "newdep", "2"), // entered, unverified → Low
        ];
        let f = SbomDriftTripwire.evaluate(&from, &to);
        assert_eq!(f.len(), 2, "one entered + one left");
        let entered = f.iter().find(|x| x.id.contains("SBOM-ENTERED")).unwrap();
        assert_eq!(entered.id, "MODGUNN-SBOM-ENTERED:cargo:newdep@2");
        assert_eq!(
            entered.severity,
            Severity::Low,
            "unverified entrant is reviewable"
        );
        let left = f.iter().find(|x| x.id.contains("SBOM-LEFT")).unwrap();
        assert_eq!(left.id, "MODGUNN-SBOM-LEFT:cargo:gone@1");
        assert_eq!(left.severity, Severity::Info);

        // Identical snapshots ⇒ no drift.
        assert!(SbomDriftTripwire.evaluate(&from, &from).is_empty());
    }

    #[test]
    fn policy_tripwire_fires_on_declarative_rule_breaks() {
        // from: main -> mid (no dangerous cap reachable), one verified dep.
        let mut from = WorldSnapshot::new("p1");
        from.symbols = vec![
            Symbol::new("main", "app@1").entrypoint(),
            Symbol::new("mid", "lib@1"),
        ];
        from.calls = vec![CallEdge::new("main", "mid")];
        from.dep_nodes = vec![DepNode::new("cargo", "lib", "1").verified()];

        // to: mid now reaches an FFI sink AND an unverified npm dep was added.
        let mut to = from.clone();
        to.id = "p2".into();
        to.symbols
            .push(Symbol::new("ffi", "native@1").with_capability(Capability::Ffi));
        to.calls.push(CallEdge::new("mid", "ffi"));
        to.dep_nodes.push(DepNode::new("npm", "added", "2")); // unverified

        let tw = PolicyTripwire::new([
            PolicyRule::NoNewNativeCode,
            PolicyRule::NoUnverifiedDependencyAdded,
            PolicyRule::ForbidEcosystem("npm".into()),
        ]);
        let f = tw.evaluate(&from, &to);
        // native-code growth (ffi) + unverified-dep-added + forbidden-ecosystem(npm)
        assert_eq!(f.len(), 3, "all three rules fire");
        assert!(f.iter().all(|x| x.severity == Severity::High));
        assert!(f.iter().any(|x| x.id.contains("no-new-native-code")));
        assert!(f.iter().any(|x| x.id.contains("no-unverified-dep-added")));
        assert!(f.iter().any(|x| x.id.contains("forbid-ecosystem:npm")));

        // No delta ⇒ no violations (a clean gate).
        assert!(tw.evaluate(&from, &from).is_empty());

        // NoNewNativeCode ignores a merely-network capability gain (not native).
        let mut net = from.clone();
        net.symbols
            .push(Symbol::new("n", "net@1").with_capability(Capability::Network));
        net.calls.push(CallEdge::new("mid", "n"));
        let only_native = PolicyTripwire::new([PolicyRule::NoNewNativeCode]);
        assert!(
            only_native.evaluate(&from, &net).is_empty(),
            "network is not native code"
        );
        // …but the broad NoCapabilityGrowth does catch it.
        let broad = PolicyTripwire::new([PolicyRule::NoCapabilityGrowth]);
        assert_eq!(broad.evaluate(&from, &net).len(), 1);
    }

    #[test]
    fn threshold_tripwire_crosses_numeric_bounds() {
        // A dep graph where `hub` is a high-fan-in, unverified central node.
        let mut to = WorldSnapshot::new("t2");
        let k = |n: &str| format!("cargo:{n}@1");
        for n in ["a", "b", "c", "hub"] {
            let node = DepNode::new("cargo", n, "1");
            to.dep_nodes
                .push(if n == "hub" { node } else { node.verified() });
        }
        to.dep_edges = vec![
            DepEdge::new(k("a"), k("hub")),
            DepEdge::new(k("b"), k("hub")),
            DepEdge::new(k("c"), k("hub")),
        ];
        let from = WorldSnapshot::new("t1");

        // Fan-in ≥ 3 → only `hub` qualifies.
        let fanin = ThresholdTripwire::new([ThresholdRule::FanInAtLeast(3)]);
        let f = fanin.evaluate(&from, &to);
        assert_eq!(f.len(), 1);
        assert!(f[0].id.contains("cargo:hub@1"));

        // Unverified centrality: `hub` is both central and unverified → fires;
        // the verified leaves never do.
        let cen = ThresholdTripwire::new([ThresholdRule::UnverifiedCentralityOver(0.10)]);
        let cf = cen.evaluate(&from, &to);
        assert!(cf.iter().all(|x| x.id.contains("cargo:hub@1")));
        assert!(
            !cf.is_empty(),
            "the unverified hub crosses the centrality bound"
        );

        // Capability-surface growth ≥ 1.
        let mut base = WorldSnapshot::new("c1");
        base.symbols = vec![Symbol::new("m", "app@1").entrypoint()];
        let mut grown = base.clone();
        grown.id = "c2".into();
        grown
            .symbols
            .push(Symbol::new("net", "n@1").with_capability(Capability::Network));
        grown.calls.push(CallEdge::new("m", "net"));
        let capg = ThresholdTripwire::new([ThresholdRule::CapabilitySurfaceGrewBy(1)]);
        assert_eq!(capg.evaluate(&base, &grown).len(), 1);
        assert!(capg.evaluate(&base, &base).is_empty(), "no growth ⇒ silent");

        // New-symbol entropy over σ: a packed/base64-looking new name.
        let mut e1 = WorldSnapshot::new("e1");
        e1.symbols = vec![Symbol::new("plain", "app@1")];
        let mut e2 = e1.clone();
        e2.id = "e2".into();
        e2.symbols.push(Symbol {
            display: "aGVsbG8gd29ybGQ7ZXhlYw".into(),
            ..Symbol::new("packed", "x@1")
        });
        let ent = ThresholdTripwire::new([ThresholdRule::NewSymbolEntropyOver(3.5)]);
        let ef = ent.evaluate(&e1, &e2);
        assert_eq!(ef.len(), 1);
        assert!(ef[0].id.contains("packed"));
        assert_eq!(ef[0].severity, Severity::Medium);
    }

    #[test]
    fn honeytoken_fires_only_when_a_planted_marker_surfaces() {
        let mut s = WorldSnapshot::new("h1");
        s.dep_nodes = vec![
            DepNode::new("npm", "acme-internal-utils", "9.9.9"), // internal name resolved!
            DepNode::new("npm", "left-pad", "1"),
        ];
        s.symbols = vec![
            Symbol::new("net_read", "app@1"),
            Symbol {
                display: "leak CANARY-7f3a-DEADBEEF here".into(),
                ..Symbol::new("logfn", "app@1")
            },
        ];

        let planted = vec![
            Honeytoken::InternalName("acme-internal-utils".into()),
            Honeytoken::CanaryToken("CANARY-7f3a-DEADBEEF".into()),
        ];
        let f = honeytoken_findings(&s, &planted);
        assert_eq!(
            f.len(),
            2,
            "both the confusion hit and the canary leak fire"
        );
        assert!(f.iter().all(|x| x.severity == Severity::Critical));
        assert!(f.iter().any(|x| x.id.contains("HONEYTOKEN-CONFUSION")));
        assert!(f.iter().any(|x| x.id.contains("HONEYTOKEN-CANARY")));

        // Case-insensitive internal-name match.
        let ci = honeytoken_findings(
            &s,
            &[Honeytoken::InternalName("ACME-Internal-Utils".into())],
        );
        assert_eq!(ci.len(), 1);

        // A clean world (no planted markers surface) never fires.
        let mut clean = WorldSnapshot::new("h2");
        clean.dep_nodes = vec![DepNode::new("npm", "left-pad", "1")];
        clean.symbols = vec![Symbol::new("main", "app@1")];
        assert!(honeytoken_findings(&clean, &planted).is_empty());

        // Empty planted set ⇒ silent (honest empty input).
        assert!(honeytoken_findings(&s, &[]).is_empty());
    }
}