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

mikronova/modgunn

src/main.rs

//! `modgunn` CLI — the secure-side advisory-warehouse driver.
//!
//! Verbs (`warehouse …` need the warehouse seam; `ingest` needs `manifests`):
//! ```text
//! modgunn ingest    --path <manifest-file|dir> [--sbom] [--subject <name>] [--ecosystem <eco>]
//! modgunn scorecard --path <report.json> [--subject <name>] [--warehouse <root>]
//! modgunn behavior  --path <package.json|setup.py|pyproject.toml|setup.cfg> [--ecosystem npm|pypi|pyproject|setupcfg] [--subject <name>] [--fail-on-finding] [--warehouse <root>]
//! modgunn warehouse import --warehouse <root> --rev <db_rev> \
//!         [--osv <dir>] [--malicious <dir>] [--gitlab <dir>] [--nvd <dir>] [--epss <epss.csv>] [--kev <kev.json>] [--vulncheck-kev <file>] [--euvd-exploited <dir>] [--exploit-poc <dir>] [--metasploit <file>] [--nuclei <file>] [--exploitdb <files_exploits.csv>] \
//!         [--crates-yanks <dir>] [--npm-deprecations <dir>] [--pypi-yanks <dir>] [--go-retract <dir>] [--ecosystems <dir>] [--librariesio <dir>] [--ossindex <dir>] [--socket <dir>] [--guac <dir>]
//! modgunn warehouse import-deps --warehouse <root> --path <Cargo.lock> --snapshot <id>
//! modgunn warehouse import-iocs --warehouse <root> --snapshot <id> [--urlhaus <f>] [--feodo <f>] [--sslbl <f>] [--greynoise <f>]
//! modgunn warehouse license-lint --spdx-list <licenses.json> --license "<SPDX expr>"
//! modgunn warehouse attack-map --capec <capec.xml> [--cwe CWE-89]
//! modgunn warehouse import-vuln-symbols --warehouse <root> --snapshot <id> --go-osv <dir>
//! modgunn warehouse vuln-symbols-log --warehouse <root> --snapshot <id>
//! modgunn warehouse reachable-vuln --warehouse <root> --snapshot <id>
//! modgunn warehouse findings-log --warehouse <root> --snapshot <id>
//! modgunn warehouse license-compliance --definitions <file|dir> [--fail-on-finding]
//! modgunn warehouse rekor --entries <file|dir> [--expect-sha256 <hex>] [--trusted-signer <id>]
//! modgunn warehouse ioc-scan --warehouse <root> --snapshot <id> --observables <file>
//! modgunn warehouse ioc-log --warehouse <root> --snapshot <id> [--kind url|ipv4|domain|sha1|sha256|md5] [--threatfox <f>] [--malwarebazaar <f>] [--tor <f>] [--et-compromised <f>] [--spamhaus <f>] [--misp <f>] [--otx <f>]
//! modgunn warehouse scan   --warehouse <root> [--since <snapshot_id>] [--vex <dir>] [--persist <snapshot>] [--gate-on <world_snapshot>]
//! modgunn warehouse stats  --warehouse <root> [--ecosystem <eco>]
//! modgunn warehouse behavior-log --warehouse <root> [--ecosystem <eco>] [--subject <name>]
//! ```
//!
//! `ingest` is the OFFLINE, zero-shell read-side of `sbom` emit: it parses a
//! project's dependency manifest/lockfile (Cargo.lock, requirements.txt,
//! package-lock.json, pom.xml, go.mod, packages.lock.json, Gemfile.lock, …) into
//! modgunn's shared `sbom::SbomComponent` model and prints the resolved components
//! (default) or a CycloneDX 1.5 SBOM (`--sbom`). No warehouse, no network — a
//! consumer can run it on a laptop against a checked-out repo.
//!
//! `scorecard` is the OFFLINE, zero-shell read-side of the OSSF **Scorecard** feed:
//! it folds a `scorecard --format json` report into the shared
//! [`modgunn::provenance::ScorecardSignals`] model (via `parse_scorecard`), prints the
//! signals + the advisory findings they generate (low score / unmaintained / dangerous
//! workflow / no branch-protection), and — when `--warehouse <root>` is given —
//! PERSISTS the signals via `append_scorecard` so a later `warehouse provenance-log`
//! reads them back (the append→load path). No network — the report itself is produced
//! internet-side and carried across the airgap.
//!
//! `behavior` is the OFFLINE, zero-shell **A23 package-behaviour** read-side: it
//! reads ONE package's published metadata (an npm `package.json`, a PyPI `setup.py`,
//! a PEP 621/517 `pyproject.toml`, or a setuptools `setup.cfg`), folds it into the
//! shared [`modgunn::behavior::PackageBehavior`] feature vector (install hooks /
//! network-in-install / URLs / obfuscation entropy / sensitive-API set) via
//! `parse_npm_package` / `parse_pypi_setup` / `parse_pyproject_toml` /
//! `parse_setup_cfg`, and prints the vector + the advisory findings it generates
//! (dropper network fetch → High, sensitive-API / obfuscation → Medium, install-hook →
//! Low). The `pyproject.toml` and `setup.cfg` folds are **section-aware** — they scan
//! only the build/install-active sections (build-system / cmdclass / build-hook;
//! `[options]` cmdclass / dependency_links / build entry-points), never the declarative
//! `[project]` / `[metadata]` deps & URLs — so an honest project's `urllib3` dep or
//! `https://` homepage does not false-positive. The ecosystem is detected from the
//! basename (or forced with `--ecosystem`). No code is executed — the file is only read as text;
//! `--fail-on-finding` exits non-zero (3) when any finding fires, so a promotion gate
//! can block on a suspicious package.
//!
//! `scan` joins the WHOLE SBOM (`sbom_components`, all repos) against the advisory
//! db and prints every known-vulnerable dependency; `--since <snapshot_id>` does an
//! incremental CDC delta — only the components added since that warehouse snapshot,
//! printing the next watermark to poll from.
//!
//! `import` reads every `*.json` OSV document under `--osv` (and OpenSSF
//! Malicious-Packages docs under `--malicious`, imported Critical), normalizes +
//! dedups them across feeds (range-native — ranges are carried, not pre-expanded),
//! optionally enriches with FIRST **EPSS** + CISA **KEV**, and appends the corpus
//! as a new `db_rev` generation into the shared Iceberg warehouse. Offline by
//! construction: the network fetch that fills `<dir>` runs internet-side and hands
//! a sealed znippy across the airgap; this verb is the secure-side import.

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

use anyhow::{bail, Context, Result};
use modgunn::engine::EngineKind;
use modgunn::scan::{merge_by_alias, Advisory};
use modgunn::warehouse::{Warehouse, Zone};
use modgunn::{feeds, nvd, osv, registry, vex};

fn main() -> Result<()> {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let v: Vec<&str> = args.iter().map(String::as_str).collect();
    match v.as_slice() {
        // `--version` / `-V`: the single-source identity string (semver + git-hash),
        // handled before anything else so it always answers (build-version-githash-law).
        ["--version"] | ["-V"] | ["version"] => {
            println!("modgunn v{}", modgunn::version_with_hash());
            Ok(())
        }
        ["warehouse", "import", rest @ ..] => cmd_import(rest),
        ["warehouse", "import-deps", rest @ ..] => cmd_import_deps(rest),
        ["warehouse", "import-iocs", rest @ ..] => cmd_import_iocs(rest),
        ["warehouse", "license-lint", rest @ ..] => cmd_license_lint(rest),
        ["warehouse", "license-compliance", rest @ ..] => cmd_license_compliance(rest),
        ["warehouse", "rekor", rest @ ..] => cmd_rekor(rest),
        ["warehouse", "attack-map", rest @ ..] => cmd_attack_map(rest),
        ["warehouse", "import-vuln-symbols", rest @ ..] => cmd_import_vuln_symbols(rest),
        ["warehouse", "vuln-symbols-log", rest @ ..] => cmd_vuln_symbols_log(rest),
        ["warehouse", "reachable-vuln", rest @ ..] => cmd_reachable_vuln(rest),
        ["warehouse", "ioc-log", rest @ ..] => cmd_ioc_log(rest),
        ["warehouse", "ioc-scan", rest @ ..] => cmd_ioc_scan(rest),
        ["warehouse", "findings-log", rest @ ..] => cmd_findings_log(rest),
        ["warehouse", "stats", rest @ ..] => cmd_stats(rest),
        ["warehouse", "scan", rest @ ..] => cmd_scan(rest),
        ["warehouse", "rollup", rest @ ..] => cmd_rollup(rest),
        ["warehouse", "provenance-log", rest @ ..] => cmd_provenance_log(rest),
        ["warehouse", "behavior-log", rest @ ..] => cmd_behavior_log(rest),
        ["warehouse", "provenance-diff", rest @ ..] => cmd_provenance_diff(rest),
        ["warehouse", "verify-provenance", rest @ ..] => cmd_verify_provenance(rest),
        ["ingest", rest @ ..] => cmd_ingest(rest),
        ["scorecard", rest @ ..] => cmd_scorecard(rest),
        ["behavior", rest @ ..] => cmd_behavior(rest),
        // ── Common framework surfaces (About + Tools menu + service lifecycle) ──
        ["about", rest @ ..] => cmd_about(rest),
        #[cfg(feature = "framework")]
        ["tools", rest @ ..] => cmd_tools(rest),
        #[cfg(feature = "framework")]
        ["demo", rest @ ..] => cmd_demo(rest),
        #[cfg(feature = "framework")]
        ["service", rest @ ..] => cmd_service(rest),
        #[cfg(not(feature = "framework"))]
        ["tools", ..] | ["demo", ..] | ["service", ..] => {
            eprintln!(
                "the Tools menu / demo / service lifecycle need the shared framework crates \
                 (jera + skidbladnir): rebuild with `--features framework` (add `framework-oci` \
                 to run the demo container live). `modgunn about` works without it."
            );
            std::process::exit(2);
        }
        _ => {
            eprintln!(
                "usage:\n  \
                 modgunn about [--json]\n  \
                 modgunn tools [--json]                                          (requires --features framework)\n  \
                 modgunn demo advisory-feed --out <dir> [--json]                 (requires --features framework; live run needs framework-oci)\n  \
                 modgunn service [--unit] [--json] [--exec <p>] [--addr <a>] [--user <u>] [--user-scope]   (requires --features framework)\n  \
                 modgunn ingest --path <manifest-file|dir> [--sbom] [--subject <name>] [--ecosystem <eco>]   (requires --features manifests)\n  \
                 modgunn scorecard --path <report.json> [--subject <name>] [--warehouse <root>]\n  \
                 modgunn behavior --path <package.json|setup.py|pyproject.toml|setup.cfg> [--ecosystem npm|pypi|pyproject|setupcfg] [--subject <name>] [--fail-on-finding]\n  \
                 modgunn warehouse import  --warehouse <root> --rev <db_rev> [--osv <dir>] [--malicious <dir>] [--gitlab <dir>] [--nvd <dir>] [--nvd-backfill <dir>] [--epss <csv>] [--kev <json>] [--vulncheck-kev <file>] [--euvd-exploited <dir>] [--exploit-poc <dir>] [--metasploit <file>] [--nuclei <file>] [--exploitdb <csv>] [--crates-yanks <dir>] [--npm-deprecations <dir>] [--pypi-yanks <dir>] [--go-retract <dir>] [--ecosystems <dir>] [--librariesio <dir>] [--ossindex <dir>] [--socket <dir> (SCAFFOLD)] [--guac <dir> (SCAFFOLD)]\n  \
                 modgunn warehouse scan    --warehouse <root> [--since <snapshot_id>] [--vex <dir>] [--sql] [--engine a|b] [--persist <snapshot>] [--gate-on <world_snapshot>]\n  \
                 modgunn warehouse rollup  --warehouse <root> [--engine a|b]\n  \
                 modgunn warehouse stats   --warehouse <root> [--ecosystem <eco>]\n  \
                 modgunn warehouse provenance-log  --warehouse <root> [--repo <r>] [--id <artifact_id>]\n  \
                 modgunn warehouse behavior-log    --warehouse <root> [--ecosystem <eco>] [--subject <name>]\n  \
                 modgunn warehouse provenance-diff --warehouse <root> [--from <snapshot_id>] [--to <snapshot_id>] [--repo <r>] [--fail-on-regression]\n  \
                 modgunn warehouse verify-provenance --warehouse <root> --roots <ca-der-file|dir> (--blob <x.build-provenance.znippy> | --blob-dir <dir>) [--sha256 <hash>] [--min-slsa <n>] [--trusted-signer <id>]... [--expected-repo <r>] [--expected-commit <sha>]   (requires --features provenance-verify)\n\n  \
                 every `warehouse` verb (and `scorecard`/`behavior --warehouse`) takes [--zone demo|test|prod] (default demo):\n  \
                 the shared environment axis — scopes modgunn's data to the `modgunn_<zone>` namespace, so a `test` populate never touches `demo`/`prod`."
            );
            std::process::exit(2);
        }
    }
}

/// The value following `--name`, if present.
fn flag(args: &[&str], name: &str) -> Option<String> {
    args.iter()
        .position(|a| *a == name)
        .and_then(|i| args.get(i + 1))
        .map(|s| s.to_string())
}

/// Whether a boolean `--name` flag is present.
fn has_flag(args: &[&str], name: &str) -> bool {
    args.iter().any(|a| *a == name)
}

/// Parse the shared **`--zone demo|test|prod`** flag — the constellation-wide
/// environment axis (the ONE `nornir_zone::Zone` leaf, consumed not twinned, L5).
/// Absent → [`Zone::Demo`] (the DEFAULT, so every pre-zone invocation is unchanged);
/// a recognised key/alias → that zone; an UNKNOWN value → a clear hard error naming
/// the valid zones (never a silent fall-through to the wrong environment). The
/// selected zone scopes modgunn's warehouse data to `modgunn_<zone>`
/// (`Zone::namespace("modgunn")`), so `import`/`scan`/`stats`/… operate within one
/// isolated zone.
#[cfg(feature = "warehouse")]
fn parse_zone(args: &[&str]) -> Result<Zone> {
    match flag(args, "--zone") {
        None => Ok(Zone::Demo),
        Some(v) => Zone::from_key(&v).ok_or_else(|| {
            anyhow::anyhow!(
                "unknown --zone `{v}` (valid: {})",
                Zone::ALL
                    .iter()
                    .map(|z| z.key())
                    .collect::<Vec<_>>()
                    .join(" | "),
            )
        }),
    }
}

/// `modgunn about [--json]` — the COMMON FRAMEWORK **About** surface: modgunn's
/// identity card (name · Móðguðr · version · role · both engines). Always available
/// (needs no framework crate). `--json` prints the [`modgunn::about::state_json`]
/// oracle a robot asserts; otherwise a human-readable card.
fn cmd_about(args: &[&str]) -> Result<()> {
    let id = modgunn::about::identity();
    if has_flag(args, "--json") {
        println!(
            "{}",
            serde_json::to_string_pretty(&modgunn::about::state_json())?
        );
        return Ok(());
    }
    println!("{} — {}  v{}", id.display_name, id.name, id.version);
    println!("  {}", id.subtitle);
    println!("  {}", id.tagline);
    for e in &id.engines {
        println!("  · {e}");
    }
    println!("  repository: {}", id.repository);
    Ok(())
}

/// `modgunn tools [--json]` — list the COMMON FRAMEWORK **🧰 Tools menu**: the
/// jera-backed demo actions modgunn ships (start a container → generate data → scan).
/// `--json` prints the [`modgunn::tools::state_json`] roster oracle.
#[cfg(feature = "framework")]
fn cmd_tools(args: &[&str]) -> Result<()> {
    if has_flag(args, "--json") {
        println!(
            "{}",
            serde_json::to_string_pretty(&modgunn::tools::state_json(None))?
        );
        return Ok(());
    }
    println!("modgunn 🧰 Tools (jera-backed demo actions):");
    for e in modgunn::tools::entries() {
        println!(
            "  {}  [starts: {} → view: `modgunn {}`]",
            e.title,
            e.effect.needs_container(),
            e.view_verb
        );
        println!("      {}", e.hover);
    }
    Ok(())
}

/// `modgunn demo advisory-feed --out <dir> [--json]` — run the Tools-menu **demo
/// advisory feed** THROUGH jera (a tracked container job that writes a demo OSV doc
/// into `<dir>`), then point `modgunn warehouse import --osv <dir>` at it. The jera
/// `ContainerSpec` is printed either way; with `--features framework-oci` the
/// container is actually started (routed THROUGH draupnir's ONE OCI engine).
#[cfg(feature = "framework")]
fn cmd_demo(args: &[&str]) -> Result<()> {
    match args.first() {
        Some(&"advisory-feed") | None => {}
        Some(other) => bail!("unknown demo '{other}' (only 'advisory-feed')"),
    }
    let out = flag(args, "--out").unwrap_or_else(|| "./modgunn-demo-out".to_string());
    let spec = modgunn::tools::demo_feed_spec(&out);
    if has_flag(args, "--json") {
        println!(
            "{}",
            serde_json::to_string_pretty(&modgunn::tools::state_json(Some(
                modgunn::tools::ToolEffect::DemoAdvisoryFeed
            )))?
        );
    }
    println!(
        "demo advisory-feed → jera ContainerSpec: image={} name={} mount={}:/out",
        spec.image, spec.name, out
    );
    #[cfg(feature = "framework-oci")]
    {
        println!("starting THROUGH jera (draupnir OCI engine) …");
        let outcome = modgunn::tools::run_demo_feed(&out)?;
        println!(
            "done: exit={:?}. now: `modgunn warehouse import --osv {out}`",
            outcome.exit_code
        );
    }
    #[cfg(not(feature = "framework-oci"))]
    {
        println!(
            "(plan only) rebuild with `--features framework-oci` to start the container live; \
                  then `modgunn warehouse import --osv {out}`"
        );
    }
    Ok(())
}

/// `modgunn service [--unit] [--json] [--exec <p>] [--addr <a>] [--user <u>]
/// [--user-scope]` — the COMMON FRAMEWORK **lifecycle** surface: modgunn's world-delta
/// gatekeeper declared as a skidbladnir `ServiceSpec` (dedicated `modgunn` system
/// user). `--unit` renders the systemd unit through the shared ship renderer; `--json`
/// dumps the [`modgunn::service::state_json`] oracle.
#[cfg(feature = "framework")]
fn cmd_service(args: &[&str]) -> Result<()> {
    let exec = flag(args, "--exec").unwrap_or_else(|| "/usr/local/bin/modgunn".to_string());
    let addr = flag(args, "--addr").unwrap_or_else(|| modgunn::service::DEFAULT_ADDR.to_string());
    let user_scope = has_flag(args, "--user-scope");
    let user = flag(args, "--user");
    let run_as: Option<&str> = if user_scope {
        None
    } else {
        Some(user.as_deref().unwrap_or(modgunn::service::SERVICE_USER))
    };
    if has_flag(args, "--json") {
        println!(
            "{}",
            serde_json::to_string_pretty(&modgunn::service::state_json(
                &exec, &addr, run_as, user_scope
            ))?
        );
        return Ok(());
    }
    if has_flag(args, "--unit") {
        print!(
            "{}",
            modgunn::service::modgunn_service_unit(&exec, &addr, run_as, user_scope)
        );
        return Ok(());
    }
    println!(
        "modgunn service (skidbladnir lifecycle): unit=modgunn.service user={} addr={addr}",
        run_as.unwrap_or("<caller>")
    );
    println!("  `modgunn service --unit` renders the systemd unit; `--json` dumps the oracle");
    Ok(())
}

/// Every value following a repeatable `--name` (e.g. `--trusted-signer a
/// --trusted-signer b`), in order.
#[cfg(feature = "provenance-verify")]
fn flags_all(args: &[&str], name: &str) -> Vec<String> {
    args.iter()
        .enumerate()
        .filter(|(_, a)| **a == name)
        .filter_map(|(i, _)| args.get(i + 1).map(|s| s.to_string()))
        .collect()
}

/// Read every `*.json` file directly under `dir` as a string.
fn read_json_dir(dir: &Path) -> Result<Vec<String>> {
    Ok(read_json_dir_named(dir)?
        .into_iter()
        .map(|(_, s)| s)
        .collect())
}

/// As [`read_json_dir`], but keeps each file's path alongside its contents — so a
/// parse failure during import can name the offending file instead of silently
/// dropping CVE coverage.
fn read_json_dir_named(dir: &Path) -> Result<Vec<(std::path::PathBuf, String)>> {
    let mut out = Vec::new();
    for entry in std::fs::read_dir(dir).with_context(|| format!("read_dir {}", dir.display()))? {
        let path = entry?.path();
        if path.extension().and_then(|e| e.to_str()) == Some("json") {
            let body = std::fs::read_to_string(&path)
                .with_context(|| format!("read {}", path.display()))?;
            out.push((path, body));
        }
    }
    Ok(out)
}

/// Read every `*.yml` / `*.yaml` text file under `dir` — the gemnasium collector's
/// input (GitLab advisories are YAML).
fn read_yaml_dir_named(dir: &Path) -> Result<Vec<(std::path::PathBuf, String)>> {
    let mut out = Vec::new();
    for entry in std::fs::read_dir(dir).with_context(|| format!("read_dir {}", dir.display()))? {
        let path = entry?.path();
        let ext = path.extension().and_then(|e| e.to_str());
        if ext == Some("yml") || ext == Some("yaml") {
            let body = std::fs::read_to_string(&path)
                .with_context(|| format!("read {}", path.display()))?;
            out.push((path, body));
        }
    }
    Ok(out)
}

/// Read every `go.mod` / `*.mod` text file under `dir` — the go.mod collector's
/// input (go.mod is not JSON, so `read_json_dir_named` won't see it).
fn read_mod_dir_named(dir: &Path) -> Result<Vec<(std::path::PathBuf, String)>> {
    let mut out = Vec::new();
    for entry in std::fs::read_dir(dir).with_context(|| format!("read_dir {}", dir.display()))? {
        let path = entry?.path();
        let is_mod = path.extension().and_then(|e| e.to_str()) == Some("mod")
            || path.file_name().and_then(|n| n.to_str()) == Some("go.mod");
        if is_mod {
            let body = std::fs::read_to_string(&path)
                .with_context(|| format!("read {}", path.display()))?;
            out.push((path, body));
        }
    }
    Ok(out)
}

/// `modgunn ingest --path <manifest-file|dir> [--sbom] [--subject <name>]
/// [--ecosystem <eco>]` — the OFFLINE, zero-shell **manifest → SBOM ingest** verb:
/// read a project's dependency manifest/lockfile (or every recognised manifest
/// under a directory, VCS/build trees skipped) and turn it into modgunn's shared
/// [`modgunn::sbom::SbomComponent`] model via
/// [`modgunn::manifests::parse_manifest`], then print the resolved components
/// (default) or a CycloneDX 1.5 SBOM (`--sbom`, via [`modgunn::sbom::to_cyclonedx`]).
/// `--ecosystem <eco>` narrows to one ecosystem. No warehouse, no network — the
/// read-side counterpart to `sbom` emit that a consumer can drive on a laptop.
#[cfg(feature = "manifests")]
fn cmd_ingest(args: &[&str]) -> Result<()> {
    use modgunn::sbom::{to_cyclonedx, SbomInput};

    let path = flag(args, "--path").context("--path <manifest-file|dir> is required")?;
    let p = Path::new(&path);
    let eco_filter = flag(args, "--ecosystem");
    let want_sbom = args.contains(&"--sbom");

    // Gather (basename, content): one explicit file, or every recognised manifest
    // under a directory. `parse_manifest` dispatches on the basename, so the key is
    // the basename — a repo with several package-lock.json all fold in.
    let mut files: Vec<(String, String)> = Vec::new();
    let meta = std::fs::metadata(p).with_context(|| format!("stat {}", p.display()))?;
    if meta.is_dir() {
        collect_manifests(p, &mut files)?;
        if files.is_empty() {
            bail!(
                "no recognised manifest/lockfile found under {} (supported: {})",
                p.display(),
                supported_manifests(),
            );
        }
    } else {
        let base = p
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or(&path)
            .to_string();
        let content =
            std::fs::read_to_string(p).with_context(|| format!("read {}", p.display()))?;
        files.push((base, content));
    }

    // Parse each. An explicit single file no parser owns is a hard error (usage
    // signal); a malformed manifest is named on stderr and counted (never a panic).
    let mut components: Vec<modgunn::sbom::SbomComponent> = Vec::new();
    let mut parsed_files = 0usize;
    let mut n_fail = 0usize;
    for (base, content) in &files {
        match modgunn::manifests::parse_manifest(base, content) {
            Some(Ok(mut comps)) => {
                parsed_files += 1;
                components.append(&mut comps);
            }
            Some(Err(e)) => {
                n_fail += 1;
                eprintln!("warning: {base}: {e}");
            }
            None => bail!(
                "no manifest parser owns `{base}` (supported: {})",
                supported_manifests()
            ),
        }
    }

    if let Some(eco) = &eco_filter {
        components.retain(|c| &c.ecosystem == eco);
    }

    // Stable, deduped ordering so the output is reproducible across a dir walk.
    components.sort_by(|a, b| {
        (a.ecosystem.as_str(), a.name.as_str(), a.version.as_str()).cmp(&(
            b.ecosystem.as_str(),
            b.name.as_str(),
            b.version.as_str(),
        ))
    });
    components
        .dedup_by(|a, b| a.ecosystem == b.ecosystem && a.name == b.name && a.version == b.version);

    // Introspection marker — the `ingest` verb reports to the test matrix (GREEN =
    // it produced components; RED = nothing ingested / all manifests malformed).
    #[cfg(feature = "testmatrix")]
    modgunn::functional_status(
        "modgunn/cli-ingest",
        "manifest_ingested",
        !components.is_empty(),
        &format!(
            "{} component(s) from {parsed_files} manifest(s)",
            components.len()
        ),
    );

    if want_sbom {
        let subject = flag(args, "--subject")
            .or_else(|| p.file_name().and_then(|n| n.to_str()).map(String::from))
            .unwrap_or_else(|| "ingest".to_string());
        let input = SbomInput {
            subject,
            components,
            vulns: Vec::new(),
        };
        println!("{}", serde_json::to_string_pretty(&to_cyclonedx(&input))?);
        return Ok(());
    }

    for c in &components {
        println!(
            "{:<7} {} {}{}",
            c.ecosystem,
            c.name,
            c.version,
            c.license
                .as_ref()
                .map(|l| format!("  {l}"))
                .unwrap_or_default(),
        );
    }
    // Per-ecosystem tally so the footer summarises the fleet the manifest describes.
    let mut per_eco: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
    for c in &components {
        *per_eco.entry(c.ecosystem.as_str()).or_default() += 1;
    }
    let breakdown = per_eco
        .iter()
        .map(|(e, n)| format!("{e} {n}"))
        .collect::<Vec<_>>()
        .join(", ");
    println!(
        "{} component(s) from {parsed_files} manifest(s){}{}",
        components.len(),
        if breakdown.is_empty() {
            String::new()
        } else {
            format!(" [{breakdown}]")
        },
        if n_fail > 0 {
            format!("; {n_fail} manifest(s) skipped (parse errors)")
        } else {
            String::new()
        },
    );
    Ok(())
}

/// Recursively collect every recognised manifest/lockfile under `dir` as
/// `(basename, content)`, skipping VCS / build / vendored trees (they hold no
/// authoritative lockfile and can be enormous). Pure filesystem walk — zero shell.
#[cfg(feature = "manifests")]
fn collect_manifests(dir: &Path, out: &mut Vec<(String, String)>) -> Result<()> {
    let parsers = modgunn::manifests::all();
    let mut stack = vec![dir.to_path_buf()];
    while let Some(d) = stack.pop() {
        for entry in std::fs::read_dir(&d).with_context(|| format!("read_dir {}", d.display()))? {
            let path = entry?.path();
            let name = match path.file_name().and_then(|n| n.to_str()) {
                Some(n) => n.to_string(),
                None => continue,
            };
            if path.is_dir() {
                if matches!(
                    name.as_str(),
                    ".git"
                        | "target"
                        | "node_modules"
                        | ".venv"
                        | "venv"
                        | "vendor"
                        | ".cargo"
                        | ".hg"
                        | ".svn"
                ) {
                    continue;
                }
                stack.push(path);
            } else if parsers.iter().any(|p| p.handles(&name)) {
                let content = std::fs::read_to_string(&path)
                    .with_context(|| format!("read {}", path.display()))?;
                out.push((name, content));
            }
        }
    }
    out.sort_by(|a, b| a.0.cmp(&b.0));
    Ok(())
}

/// The manifest basenames the ingest verb recognises, for the usage/error hint.
#[cfg(feature = "manifests")]
fn supported_manifests() -> String {
    let mut names: Vec<&'static str> = modgunn::manifests::all()
        .iter()
        .flat_map(|p| p.manifests().iter().copied())
        .collect();
    names.push("*.csproj");
    names.join(", ")
}

/// Without the `manifests` feature the ecosystem parsers are not compiled in — tell
/// the operator how to enable it rather than silently doing nothing (mirrors the
/// `verify-provenance` gate).
#[cfg(not(feature = "manifests"))]
fn cmd_ingest(_args: &[&str]) -> Result<()> {
    bail!(
        "ingest needs the `manifests` feature — rebuild with \
         `cargo run --features warehouse,manifests -- ingest --path <manifest-file|dir>`"
    )
}

/// `modgunn scorecard --path <report.json> [--subject <name>] [--warehouse <root>]`
/// — the OFFLINE, zero-shell **OSSF Scorecard ingest** verb: read a
/// `scorecard --format json` report, fold it into the shared
/// [`modgunn::provenance::ScorecardSignals`] via
/// [`modgunn::provenance::parse_scorecard`] (L5 reuse — the SAME struct the warehouse
/// round-trips and `warehouse provenance-log` reads, no twin), print the signals + the
/// advisory findings they generate ([`ScorecardSignals::findings`] — low score /
/// unmaintained / dangerous workflow / no branch-protection), and — when
/// `--warehouse <root>` is given — PERSIST them via
/// [`modgunn::warehouse::Warehouse::append_scorecard`] so a later
/// `warehouse provenance-log` reads them back (the append→load path). `--subject`
/// overrides the report's `repo.name` (which may be a full URL, or absent for a
/// locally-run report) so the persisted/printed key matches the fleet. Without
/// `--warehouse` it is a pure parse+print (like `ingest`) — a report can be inspected
/// on a laptop with no catalog.
fn cmd_scorecard(args: &[&str]) -> Result<()> {
    use modgunn::core::Severity;
    use modgunn::provenance::parse_scorecard;

    let path = flag(args, "--path").context("--path <report.json> is required")?;
    let p = Path::new(&path);
    let body = std::fs::read_to_string(p).with_context(|| format!("read {}", p.display()))?;
    let mut signals = parse_scorecard(&body)
        .with_context(|| format!("parse scorecard report {}", p.display()))?;
    if let Some(subject) = flag(args, "--subject") {
        signals.subject = subject;
    }

    // The advisory findings the signals fold into (the SAME fold `provenance-log` prints).
    let findings = signals.findings();
    println!(
        "scorecard {:<24} {:>4.1}/10  maintained={}  branch_protection={}  signed_releases={}  dangerous_workflow={}",
        signals.subject,
        signals.overall,
        signals.maintained,
        signals.branch_protection,
        signals.signed_releases,
        signals.dangerous_workflow,
    );
    let mut worst: Option<Severity> = None;
    for f in &findings {
        worst = Some(worst.map_or(f.severity, |w| w.max(f.severity)));
        println!("  {:<8} {} — {}", f.severity.as_str(), f.id, f.summary);
    }

    // Optional persistence into the shared warehouse (append-only; latest row per
    // subject wins on read) via the EXISTING append_scorecard/load_scorecard path.
    let persisted = if let Some(root) = flag(args, "--warehouse") {
        let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
        wh.ensure_modgunn_tables()?;
        wh.append_scorecard(std::slice::from_ref(&signals))?;
        true
    } else {
        false
    };

    // Introspection marker — the `scorecard` verb reports to the test matrix (GREEN =
    // it folded a report into signals; the finding count + persist flag ride the detail).
    #[cfg(feature = "testmatrix")]
    modgunn::functional_status(
        "modgunn/cli-scorecard",
        "scorecard_ingested",
        true,
        &format!(
            "{}: {:.1}/10, {} finding(s){}",
            signals.subject,
            signals.overall,
            findings.len(),
            if persisted { ", persisted" } else { "" },
        ),
    );

    println!(
        "{} scorecard finding(s) for `{}`{}{}",
        findings.len(),
        signals.subject,
        worst.map(|w| format!("; worst {w}")).unwrap_or_default(),
        if persisted {
            " — persisted into scorecard_signals"
        } else {
            ""
        },
    );
    Ok(())
}

/// `modgunn behavior --path <package.json|setup.py|pyproject.toml|setup.cfg>
/// [--ecosystem npm|pypi|pyproject|setupcfg] [--subject <name>] [--fail-on-finding]` —
/// the OFFLINE, zero-shell **A23 package-behaviour** verb: read ONE package's published
/// metadata and fold it into the shared [`modgunn::behavior::PackageBehavior`] feature
/// vector (via [`modgunn::behavior::parse_npm_package`] for npm `package.json`,
/// [`modgunn::behavior::parse_pypi_setup`] for a PyPI `setup.py`,
/// [`modgunn::behavior::parse_pyproject_toml`] for a PEP 621/517 `pyproject.toml`, or
/// [`modgunn::behavior::parse_setup_cfg`] for a setuptools `setup.cfg`), then print the
/// vector + the advisory findings it generates
/// ([`modgunn::behavior::PackageBehavior::findings`] — dropper network fetch → High,
/// sensitive-API / obfuscation → Medium, install-hook → Low). The `pyproject.toml` and
/// `setup.cfg` folds are section-aware (only build/install-active sections scanned), so
/// a benign project's `urllib3` dep / `https://` homepage never false-positives.
///
/// The ecosystem is chosen from `--ecosystem npm|pypi|pyproject|setupcfg` when given,
/// else inferred from the basename (`package.json` → npm, `setup.py` → pypi,
/// `pyproject.toml` → pyproject, `setup.cfg` → setupcfg). `--subject` overrides the
/// name parsed from the file (useful when the metadata omits it). No network, **no
/// code executed** — the file is read only as text, so a consumer can vet a package
/// on a laptop. `--fail-on-finding` exits non-zero (3) when any finding fires
/// (distinct from a usage error, exit 2), so a promotion gate can block on it.
///
/// When `--warehouse <root>` is given the analysis is PERSISTED (the ecosystem-tagged
/// feature vector + its derived verdict) via
/// [`modgunn::warehouse::Warehouse::append_package_behavior`] — the SAME warehouse
/// path `scorecard --warehouse` uses (no second seam) — so `warehouse behavior-log`
/// reads it back (the append→load round-trip). Without `--warehouse` it is a pure
/// parse+print, so a package can be vetted with no warehouse build touched.
fn cmd_behavior(args: &[&str]) -> Result<()> {
    use modgunn::behavior::{
        parse_npm_package, parse_pypi_setup, parse_pyproject_toml, parse_setup_cfg, PackageBehavior,
    };
    use modgunn::core::Severity;

    let path = flag(args, "--path")
        .context("--path <package.json|setup.py|pyproject.toml|setup.cfg> is required")?;
    let p = Path::new(&path);
    let body = std::fs::read_to_string(p).with_context(|| format!("read {}", p.display()))?;
    let base = p.file_name().and_then(|n| n.to_str()).unwrap_or(&path);

    // Ecosystem: an explicit --ecosystem wins; otherwise infer from the basename. An
    // unrecognised file with no --ecosystem is a usage error (never a silent guess).
    let eco = match flag(args, "--ecosystem") {
        Some(e) => e,
        None if base.eq_ignore_ascii_case("package.json") => "npm".to_string(),
        None if base.eq_ignore_ascii_case("setup.py") => "pypi".to_string(),
        None if base.eq_ignore_ascii_case("pyproject.toml") => "pyproject".to_string(),
        None if base.eq_ignore_ascii_case("setup.cfg") => "setupcfg".to_string(),
        None => bail!(
            "cannot infer ecosystem from `{base}` — pass --ecosystem npm|pypi|pyproject|setupcfg \
             (npm reads a package.json; pypi reads a setup.py; pyproject reads a pyproject.toml; \
             setupcfg reads a setup.cfg)"
        ),
    };

    // npm is structured JSON (a malformed file is a hard error); a setup.py is
    // arbitrary Python folded as text; a pyproject.toml / setup.cfg is folded
    // section-aware (only build/install-active sections scanned). All produce the SAME
    // vector. `canon_eco` is the canonical ecosystem tag persisted with the vector
    // (aliases like `pip`/`toml`/`cfg` fold to their canonical name).
    let (canon_eco, mut behavior): (&str, PackageBehavior) = match eco.as_str() {
        "npm" => (
            "npm",
            parse_npm_package(&body)
                .with_context(|| format!("parse npm package.json {}", p.display()))?,
        ),
        "pypi" | "pip" | "python" => ("pypi", parse_pypi_setup(&body)),
        "pyproject" | "toml" => ("pyproject", parse_pyproject_toml(&body)),
        "setupcfg" | "setup.cfg" | "cfg" => ("setupcfg", parse_setup_cfg(&body)),
        other => bail!("unknown --ecosystem `{other}` (expected npm|pypi|pyproject|setupcfg)"),
    };
    if let Some(subject) = flag(args, "--subject") {
        behavior.subject = subject;
    }

    let findings = behavior.findings();
    let apis = behavior
        .sensitive_apis
        .iter()
        .cloned()
        .collect::<Vec<_>>()
        .join(",");
    println!(
        "behavior {:<24} hooks={:?} network_in_install={} urls={} entropy={:.2} apis=[{}]",
        behavior.subject,
        behavior.install_hooks,
        behavior.network_in_install,
        behavior.url_count,
        behavior.max_token_entropy,
        apis,
    );
    let mut worst: Option<Severity> = None;
    for f in &findings {
        worst = Some(worst.map_or(f.severity, |w| w.max(f.severity)));
        println!("  {:<8} {} — {}", f.severity.as_str(), f.id, f.summary);
    }

    // Optional persistence into the shared warehouse (append-only; latest row per
    // (ecosystem, subject) wins on read), via the EXISTING warehouse-persist path
    // `append_package_behavior` / `load_package_behaviors` — the SAME plumbing
    // `scorecard --warehouse` uses (no second warehouse seam, L5). Read back with
    // `warehouse behavior-log`.
    let persisted = if let Some(root) = flag(args, "--warehouse") {
        let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
        wh.ensure_modgunn_tables()?;
        wh.append_package_behavior(canon_eco, &behavior)?;
        true
    } else {
        false
    };

    // Introspection marker — the `behavior` verb reports to the test matrix. A package
    // with NO finding is the healthy verdict (ok=true); any finding flips it red so
    // `nornir test` sees a suspicious package surfaced through the CLI flow.
    #[cfg(feature = "testmatrix")]
    modgunn::functional_status(
        "modgunn/cli-behavior",
        "package_behavior_analyzed",
        findings.is_empty(),
        &format!(
            "{} [{}]: {} finding(s){}{}",
            behavior.subject,
            canon_eco,
            findings.len(),
            worst.map(|w| format!(", worst {w}")).unwrap_or_default(),
            if persisted { ", persisted" } else { "" },
        ),
    );

    println!(
        "{} behavior finding(s) for `{}`{}{}",
        findings.len(),
        behavior.subject,
        worst.map(|w| format!("; worst {w}")).unwrap_or_default(),
        if persisted {
            " — persisted into behavior_features"
        } else {
            ""
        },
    );

    // A suspicious package is a gate failure when asked — exit non-zero so a promotion
    // check can block on it, distinct from a usage error (exit 2).
    if args.contains(&"--fail-on-finding") && !findings.is_empty() {
        std::process::exit(3);
    }
    Ok(())
}

/// `warehouse import-deps --warehouse <root> --path <lockfile> --snapshot <id>` —
/// extract a dependency graph from a lockfile (Cargo.lock → `cargo:` nodes,
/// poetry.lock → `pip:` nodes) and land it in the `dep_edges` table, the
/// multi-ecosystem dep-graph the baddie-detection joins walk. Offline; no SCIP index.
#[cfg(all(feature = "warehouse", feature = "manifests"))]
fn cmd_import_deps(args: &[&str]) -> Result<()> {
    let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
    let snapshot = flag(args, "--snapshot").context("--snapshot <id> is required")?;
    // Two input shapes: a resolved-graph LOCKFILE (--path, dispatched by basename) or
    // a deps.dev `:dependencies` JSON (--deps-dev, the cross-ecosystem registry graph).
    let (edges, label) = if let Some(dd) = flag(args, "--deps-dev") {
        let content = std::fs::read_to_string(&dd).with_context(|| format!("read {dd}"))?;
        (
            modgunn::manifests::depsdev_edges(&content).map_err(|e| anyhow::anyhow!("{e:?}"))?,
            format!("deps.dev {dd}"),
        )
    } else {
        let path =
            flag(args, "--path").context("--path <lockfile> or --deps-dev <file> is required")?;
        let content = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?;
        let base = Path::new(&path)
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or("")
            .to_string();
        let e = match base.as_str() {
            "Cargo.lock" => modgunn::manifests::cargo_lock_edges(&content),
            "poetry.lock" => modgunn::manifests::poetry_lock_edges(&content),
            "package-lock.json" => modgunn::manifests::npm_lock_edges(&content),
            _ => bail!("import-deps --path supports Cargo.lock / poetry.lock / package-lock.json; got `{base}` (or use --deps-dev)"),
        }
        .map_err(|e| anyhow::anyhow!("{e:?}"))?;
        (e, base)
    };
    if edges.is_empty() {
        bail!("no dependency edges parsed — expected a resolved dependency graph");
    }
    let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
    wh.ensure_modgunn_tables()?;
    wh.append_dep_edges(&snapshot, &edges)?;
    println!(
        "imported {} dependency edges from {label} into snapshot '{snapshot}' at {root}",
        edges.len()
    );
    Ok(())
}

#[cfg(not(all(feature = "warehouse", feature = "manifests")))]
fn cmd_import_deps(_args: &[&str]) -> Result<()> {
    bail!("`warehouse import-deps` needs the `warehouse` + `manifests` features");
}

/// `warehouse import-iocs --warehouse <root> --snapshot <id> [--urlhaus <f>]
/// [--feodo <f>] [--sslbl <f>] [--threatfox <f>] [--malwarebazaar <f>] [--tor <f>] [--et-compromised <f>] [--spamhaus <f>] [--misp <f>] [--otx <f>] [--greynoise <f>]` — import threat-intel IOCs into the shared
/// `ioc_indicators` table (the garm-side signal). Anti-silent-empty: a feed provided
/// but parsing 0 indicators is a MISSING-FEED error, never a fake pass.
/// `warehouse findings-log --warehouse <root> --snapshot <id>` — read back a
/// materialized `vuln_findings` snapshot (what `scan --persist` wrote), so smid /
/// an operator can verify the purl-keyed verdict the Mímir lens will join.
fn cmd_findings_log(args: &[&str]) -> Result<()> {
    let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
    let snapshot = flag(args, "--snapshot").context("--snapshot <id> is required")?;
    let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
    let rows = wh.read_vuln_findings(&snapshot)?;
    // modgunn DECIDED the verdict at persist time; this verb prints it rather than
    // re-deriving one, exactly as the Mímir lens will (baddie-detection b5).
    let refuse = rows.iter().filter(|r| r.label() == "refuse").count();
    let hold = rows.iter().filter(|r| r.label() == "hold").count();
    println!(
        "{} materialized finding(s) in snapshot '{snapshot}': {refuse} refuse 🔴 / {hold} hold 🟠",
        rows.len()
    );
    for r in &rows {
        let mark = match r.label() {
            "refuse" => "🔴",
            "hold" => "🟠",
            "act" => "🟢",
            _ => "❔",
        };
        println!(
            "  {mark} {:<6} {:<15} r{:<2} {:<12} {}  {}  {}",
            r.label(),
            r.axis,
            r.rank,
            r.repo,
            r.purl,
            r.advisory_id,
            r.severity
        );
    }
    Ok(())
}

/// `warehouse reachable-vuln --warehouse <root> --snapshot <id>` — run the
/// baddie-detection **b1 reachable-vuln join** over a captured snapshot: reconstruct
/// its symbol/call graph + vulnerable sinks from the warehouse and gate each CVE'd
/// symbol on call-reachability from an entrypoint. Prints the RED (reachable) set —
/// a CVE'd symbol transitively CALLED, not merely present — and the count the gate
/// suppressed as present-but-unreachable (the 80–90%-FP kill).
///
/// **Engine B.** [`modgunn::warehouse::Warehouse::reachable_vuln_findings`] is
/// `#[cfg(feature = "scip")]` (src/warehouse.rs:1635), so this verb is too. Measured
/// 2026-08-01: without this gate, `cargo check -p modgunn --features warehouse` — the
/// recipe Cargo.toml:238 itself documents, and a combination that satisfies the bin's
/// `required-features = ["warehouse", "scan"]` — failed with
/// `error[E0599]: no method named 'reachable_vuln_findings' found for struct 'Warehouse'`.
/// The declared build space never caught it because the only atom that builds this bin
/// enables `scip` too; `warehouse-sql` and `test-support` (both ⇒ `warehouse`, neither
/// ⇒ `scip`) are declared-but-never-built and were the two that surfaced it.
#[cfg(feature = "scip")]
fn cmd_reachable_vuln(args: &[&str]) -> Result<()> {
    let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
    let snapshot = flag(args, "--snapshot").context("--snapshot <id> is required")?;
    let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
    let (reachable, suppressed) = wh.reachable_vuln_findings(&snapshot)?;
    println!(
        "reachable-vuln (b1) for snapshot '{snapshot}': {} RED / {} suppressed (present-but-unreachable)",
        reachable.len(),
        suppressed.len()
    );
    for f in &reachable {
        println!("  🔴 {:<8} {}  — {}", f.severity.as_str(), f.id, f.summary);
    }
    Ok(())
}

/// Engine B is not compiled in: say so instead of failing to build. Mirrors the
/// `cmd_import_deps` / `cmd_ingest` fallbacks above.
#[cfg(not(feature = "scip"))]
fn cmd_reachable_vuln(_args: &[&str]) -> Result<()> {
    bail!(
        "`warehouse reachable-vuln` is the Engine-B b1 join and needs the `scip` feature: \
         rebuild with `--features scip` (the shipped `modgunn` atom has it on)"
    );
}

/// `warehouse ioc-log --warehouse <root> --snapshot <id> [--kind url|ipv4|…]` — read
/// back the imported IOC corpus for a snapshot (operator introspection), optionally
/// filtered by indicator kind.
fn cmd_ioc_log(args: &[&str]) -> Result<()> {
    let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
    let snapshot = flag(args, "--snapshot").context("--snapshot <id> is required")?;
    let kind_filter = flag(args, "--kind");
    let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
    let rows = wh.read_iocs(&snapshot)?;
    let shown: Vec<_> = rows
        .iter()
        .filter(|(kind, ..)| kind_filter.as_deref().map(|k| k == kind).unwrap_or(true))
        .collect();
    println!(
        "{} IOC indicator(s) in snapshot '{snapshot}'{}:",
        shown.len(),
        kind_filter
            .as_deref()
            .map(|k| format!(" (kind={k})"))
            .unwrap_or_default()
    );
    for (kind, value, threat, source) in shown {
        println!("  [{kind:<6}] {value}  ({threat}, {source})");
    }
    Ok(())
}

/// `warehouse ioc-scan --warehouse <root> --snapshot <id> --observables <file>` —
/// the baddie-detection **b6 join**: score a workspace's observables (one per line:
/// a dependency download URL, a released artifact hash, an egress IP/domain; `#`
/// comments and blanks skipped) against the persisted active-malware corpus. Each
/// hit prints modgunn's decided verdict — an unconditional 🔴 refuse, because a match
/// IS the compromise, not a latent vulnerability. Exits non-zero on any hit so a gate
/// can act on it. A clean sweep prints 0 hits and exits 0.
fn cmd_ioc_scan(args: &[&str]) -> Result<()> {
    let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
    let snapshot = flag(args, "--snapshot").context("--snapshot <id> is required")?;
    let obs_path = flag(args, "--observables").context("--observables <file> is required")?;
    let body = std::fs::read_to_string(&obs_path).with_context(|| format!("read {obs_path}"))?;
    let observables: Vec<String> = body
        .lines()
        .map(str::trim)
        .filter(|l| !l.is_empty() && !l.starts_with('#'))
        .map(str::to_string)
        .collect();
    if observables.is_empty() {
        bail!("--observables {obs_path} has no observables — an empty sweep is not a clean sweep");
    }
    let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
    // A corpus row whose kind label we cannot read is reported, never silently
    // dropped — matching against a short list would be a fake clean sweep.
    let (_, skipped) = wh.ioc_index(&snapshot)?;
    if skipped > 0 {
        eprintln!("warning: {skipped} ioc_indicators row(s) in '{snapshot}' had an unrecognized kind label and were NOT indexed");
    }
    let hits = wh.ioc_scan(&snapshot, &observables)?;
    println!(
        "b6 ioc-scan: {} observable(s) against snapshot '{snapshot}' — {} hit(s)",
        observables.len(),
        hits.len()
    );
    for h in &hits {
        println!(
            "  🔴 {:<6} {}  [{}] matched {} ({}, {})",
            h.verdict.label(),
            h.observable,
            h.kind,
            h.indicator,
            h.threat,
            h.source
        );
    }
    if hits.is_empty() {
        println!("  🟢 act — no observable is on the active-malware bad-list");
        return Ok(());
    }
    bail!(
        "{} observable(s) matched the active-malware bad-list — refuse",
        hits.len()
    );
}

/// `warehouse rekor --entries <file|dir> [--expect-sha256 <hex>] [--trusted-signer <id>]`
/// — read Sigstore **Rekor** transparency-log entries offline and report what each
/// one actually establishes: the artifact digest logged, by which Fulcio/OIDC
/// identity, at which log index and time, and whether the entry carries an inclusion
/// proof (real evidence) or is merely self-asserted. `--expect-sha256` asserts a
/// specific artifact IS in the log; `--trusted-signer` asserts who logged it. Both
/// exit non-zero when unsatisfied. Anti-silent-empty: a corpus yielding 0 decodable
/// entries is a MISSING-FEED error, never "the artifact is fine".
fn cmd_rekor(args: &[&str]) -> Result<()> {
    let path = flag(args, "--entries").context("--entries <file|dir> is required")?;
    let p = Path::new(&path);
    let docs: Vec<(std::path::PathBuf, String)> = if p.is_dir() {
        read_json_dir_named(p)?
    } else {
        vec![(
            p.to_path_buf(),
            std::fs::read_to_string(p).with_context(|| format!("read {path}"))?,
        )]
    };
    let (mut entries, mut n_fail) = (Vec::new(), 0usize);
    for (file, body) in &docs {
        match modgunn::provenance::parse_rekor_entries(body) {
            Ok(list) => entries.extend(list),
            Err(e) => {
                n_fail += 1;
                eprintln!(
                    "warning: skipping unparseable Rekor response {}: {e}",
                    file.display()
                );
            }
        }
    }
    if entries.is_empty() {
        bail!("--entries {path} yielded 0 decodable Rekor entries ({} doc(s), {n_fail} unparseable) — a truncated/wrong log response is a MISSING-FEED, never a pass", docs.len());
    }
    entries.sort_by_key(|e| e.log_index);
    println!("{} Rekor entry/entries:", entries.len());
    for e in &entries {
        let proof = match (e.has_inclusion_proof, e.has_signed_timestamp) {
            (true, true) => "proof+timestamp",
            (true, false) => "proof only",
            (false, true) => "timestamp only",
            (false, false) => "SELF-ASSERTED",
        };
        println!(
            "  #{:<10} {:<14} {}  signer={}  [{proof}]",
            e.log_index,
            e.kind,
            if e.artifact_sha256.is_empty() {
                "(no digest)"
            } else {
                &e.artifact_sha256
            },
            e.signer_identity.as_deref().unwrap_or("(none)")
        );
        for f in e.findings() {
            println!("    {:<8} {} — {}", f.severity.as_str(), f.id, f.summary);
        }
    }
    // --expect-sha256: is THIS artifact in the log at all, and with real evidence?
    if let Some(want) = flag(args, "--expect-sha256") {
        let want = want.trim().to_ascii_lowercase();
        let hit = entries.iter().find(|e| e.artifact_sha256 == want);
        match hit {
            None => bail!("no Rekor entry logs artifact sha256 {want} — the artifact is NOT in the transparency log"),
            Some(e) if !e.has_inclusion_proof => {
                bail!("artifact sha256 {want} appears at log index {} but the entry has NO inclusion proof — self-asserted, not evidence", e.log_index)
            }
            Some(e) => println!("✔ artifact {want} logged at index {} with an inclusion proof", e.log_index),
        }
    }
    // --trusted-signer: was it logged by an identity we accept?
    if let Some(want) = flag(args, "--trusted-signer") {
        if !entries
            .iter()
            .any(|e| e.signer_identity.as_deref() == Some(want.as_str()))
        {
            bail!("no Rekor entry was logged by trusted signer `{want}`");
        }
        println!("✔ signed by trusted signer `{want}`");
    }
    if n_fail > 0 {
        eprintln!("warning: {n_fail} Rekor response doc(s) skipped due to parse failures");
    }
    Ok(())
}

/// `warehouse license-compliance --definitions <file|dir> [--fail-on-finding]` —
/// the **ClearlyDefined** compliance leg: for each curated definition, compare the
/// license a package DECLARES against the licenses its files actually contain, and
/// report the gap. The classic supply-chain trap (an npm package declaring MIT while
/// shipping a GPL-3.0 file) is a `High` finding no vulnerability feed carries.
/// Offline, pure parse. Anti-silent-empty: a directory with no usable definition is
/// a MISSING-FEED error, never a fake clean bill.
fn cmd_license_compliance(args: &[&str]) -> Result<()> {
    let path = flag(args, "--definitions").context("--definitions <file|dir> is required")?;
    let p = Path::new(&path);
    let docs: Vec<(std::path::PathBuf, String)> = if p.is_dir() {
        read_json_dir_named(p)?
    } else {
        vec![(
            p.to_path_buf(),
            std::fs::read_to_string(p).with_context(|| format!("read {path}"))?,
        )]
    };
    let (mut defs, mut n_fail) = (Vec::new(), 0usize);
    for (file, body) in &docs {
        match modgunn::spdx::parse_clearlydefined_batch(body) {
            Ok(list) => defs.extend(list),
            Err(e) => {
                n_fail += 1;
                eprintln!(
                    "warning: skipping unparseable ClearlyDefined definition {}: {e}",
                    file.display()
                );
            }
        }
    }
    if defs.is_empty() {
        bail!("--definitions {path} yielded 0 usable ClearlyDefined definitions ({} doc(s), {n_fail} unparseable) — a truncated/wrong corpus is a MISSING-FEED, not a clean bill of health", docs.len());
    }
    let mut worst: Option<modgunn::core::Severity> = None;
    let mut n_findings = 0usize;
    for d in &defs {
        let findings = d.findings();
        if findings.is_empty() {
            println!(
                "  🟢 {}/{}@{}  declared `{}` — consistent",
                d.ecosystem, d.name, d.version, d.declared
            );
            continue;
        }
        println!("  {}/{}@{}:", d.ecosystem, d.name, d.version);
        for f in &findings {
            n_findings += 1;
            worst = Some(worst.map_or(f.severity, |w| w.max(f.severity)));
            println!("    {:<8} {} — {}", f.severity.as_str(), f.id, f.summary);
        }
    }
    println!(
        "license-compliance: {} definition(s), {n_findings} finding(s){}",
        defs.len(),
        worst
            .map(|w| format!(", worst {}", w.as_str()))
            .unwrap_or_default()
    );
    if n_fail > 0 {
        eprintln!("warning: {n_fail} definition doc(s) skipped due to parse failures");
    }
    if n_findings > 0 && args.contains(&"--fail-on-finding") {
        bail!("{n_findings} license-compliance finding(s)");
    }
    Ok(())
}

/// `warehouse license-lint --spdx-list <licenses.json> --license "<SPDX expr>"` —
/// validate a declared SPDX license expression against the official SPDX license
/// list: report each id that is DEPRECATED (superseded by a non-deprecated form) or
/// UNKNOWN (not an SPDX id — a typo/made-up license the gate can't evaluate). Exit
/// non-zero when any id is not current-and-known, so CI can gate on it. Anti-silent-
/// empty: a `--spdx-list` that parses 0 ids is a MISSING-FEED error.
fn cmd_license_lint(args: &[&str]) -> Result<()> {
    let list_path = flag(args, "--spdx-list").context("--spdx-list <licenses.json> is required")?;
    let expr = flag(args, "--license").context("--license \"<SPDX expression>\" is required")?;
    let body = std::fs::read_to_string(&list_path).with_context(|| format!("read {list_path}"))?;
    let list = modgunn::spdx::parse_spdx_license_list(&body)
        .with_context(|| format!("parse SPDX list {list_path}"))?;
    if list.is_empty() {
        bail!("--spdx-list {list_path} parsed 0 license ids — a truncated/wrong SPDX list is a MISSING-FEED, not a fake pass");
    }
    let issues = list.lint_expression(&expr);
    if issues.is_empty() {
        println!(
            "license `{expr}` — all ids current & known ({} SPDX ids loaded)",
            list.len()
        );
        return Ok(());
    }
    println!("license `{expr}` — {} id(s) to fix:", issues.len());
    for (id, status) in &issues {
        println!("  {id}: {}", status.label());
    }
    bail!("{} non-current license id(s)", issues.len());
}

/// `warehouse attack-map --capec <capec.xml> [--cwe CWE-89]` — bridge MITRE CAPEC's
/// CWE↔ATT&CK edges into a CWE → technique map. With `--cwe` it prints that CWE's
/// ATT&CK techniques (the finding-enrichment lookup); without it, a summary. Anti-
/// silent-empty: a CAPEC file yielding 0 CWE→technique edges is a MISSING-FEED error.
#[cfg(feature = "manifests")]
fn cmd_attack_map(args: &[&str]) -> Result<()> {
    let capec_path = flag(args, "--capec").context("--capec <capec.xml> is required")?;
    let body =
        std::fs::read_to_string(&capec_path).with_context(|| format!("read {capec_path}"))?;
    let map = modgunn::capec::parse_capec_xml(&body)
        .with_context(|| format!("parse CAPEC XML {capec_path}"))?;
    if map.is_empty() {
        bail!("--capec {capec_path} yielded 0 CWE→ATT&CK edges — a truncated/wrong CAPEC catalog is a MISSING-FEED, not a fake pass");
    }
    match flag(args, "--cwe") {
        Some(cwe) => {
            let techs = map.techniques_for_cwe(&cwe);
            if techs.is_empty() {
                println!(
                    "{cwe}: no ATT&CK techniques mapped ({} CWEs in catalog)",
                    map.len()
                );
            } else {
                println!("{cwe} → {} ATT&CK technique(s):", techs.len());
                for t in &techs {
                    println!("  {} {}", t.id, t.name);
                }
            }
        }
        None => println!(
            "CAPEC→ATT&CK map: {} CWE(s) with ≥1 technique loaded from {capec_path}",
            map.len()
        ),
    }
    Ok(())
}

#[cfg(not(feature = "manifests"))]
fn cmd_attack_map(_args: &[&str]) -> Result<()> {
    bail!("`warehouse attack-map` needs the `manifests` feature (roxmltree)");
}

fn cmd_import_iocs(args: &[&str]) -> Result<()> {
    let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
    let snapshot = flag(args, "--snapshot").context("--snapshot <id> is required")?;
    let mut iocs: Vec<modgunn::ioc::Ioc> = Vec::new();
    let feeds: [(&str, fn(&str) -> Vec<modgunn::ioc::Ioc>); 8] = [
        ("--urlhaus", modgunn::ioc::parse_urlhaus_csv),
        ("--feodo", modgunn::ioc::parse_feodo_csv),
        ("--sslbl", modgunn::ioc::parse_sslbl_csv),
        ("--threatfox", modgunn::ioc::parse_threatfox_csv),
        ("--malwarebazaar", modgunn::ioc::parse_malwarebazaar_csv),
        ("--tor", modgunn::ioc::parse_tor_exit_list),
        ("--et-compromised", modgunn::ioc::parse_emergingthreats_ips),
        ("--spamhaus", modgunn::ioc::parse_spamhaus_drop),
    ];
    let mut any = false;
    for (name, parse) in feeds {
        if let Some(path) = flag(args, name) {
            any = true;
            let body =
                std::fs::read_to_string(&path).with_context(|| format!("read {name} {path}"))?;
            let rows = parse(&body);
            if rows.is_empty() {
                bail!("{name} {path} parsed 0 indicators — an empty/truncated IOC feed is a MISSING-FEED, not a fake pass");
            }
            iocs.extend(rows);
        }
    }
    // --misp <file>: a MISP event is JSON (a fallible parse), unlike the CSV feeds.
    if let Some(path) = flag(args, "--misp") {
        any = true;
        let body = std::fs::read_to_string(&path).with_context(|| format!("read --misp {path}"))?;
        let rows = modgunn::ioc::parse_misp_event(&body)
            .with_context(|| format!("parse MISP event {path}"))?;
        if rows.is_empty() {
            bail!("--misp {path} parsed 0 indicators — an empty/malformed MISP event is a MISSING-FEED, not a fake pass");
        }
        iocs.extend(rows);
    }
    // --otx <file>: an AlienVault OTX pulse export (JSON, fallible parse).
    if let Some(path) = flag(args, "--otx") {
        any = true;
        let body = std::fs::read_to_string(&path).with_context(|| format!("read --otx {path}"))?;
        let rows = modgunn::ioc::parse_otx_pulse(&body)
            .with_context(|| format!("parse OTX pulse {path}"))?;
        if rows.is_empty() {
            bail!("--otx {path} parsed 0 indicators — an empty/malformed OTX pulse is a MISSING-FEED, not a fake pass");
        }
        iocs.extend(rows);
    }
    // --greynoise <file>: a GreyNoise GNQL/IP-context result (JSON, fallible parse).
    // Only `malicious`/`suspicious` records become indicators — benign + RIOT
    // (known-good services) are dropped by the parser, because a b6 IOC hit is an
    // unconditional refuse and importing GreyNoise's benign half would blocklist
    // public DNS. The uniform importer law still applies: a file that contributes 0
    // indicators is a MISSING-FEED (wrong/truncated document, or an all-benign page
    // that has nothing to add), never a fake pass — don't pass the flag in that case.
    if let Some(path) = flag(args, "--greynoise") {
        any = true;
        let body =
            std::fs::read_to_string(&path).with_context(|| format!("read --greynoise {path}"))?;
        let rows = modgunn::ioc::parse_greynoise(&body)
            .with_context(|| format!("parse GreyNoise result {path}"))?;
        if rows.is_empty() {
            bail!("--greynoise {path} parsed 0 malicious/suspicious indicators — an empty/all-benign/truncated GreyNoise result is a MISSING-FEED, not a fake pass");
        }
        iocs.extend(rows);
    }
    if !any {
        bail!("nothing to import — provide --urlhaus/--feodo/--sslbl/--threatfox/--malwarebazaar/--tor/--et-compromised/--spamhaus/--misp/--otx/--greynoise <file>");
    }
    let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
    wh.ensure_modgunn_tables()?;
    wh.append_iocs(&snapshot, &iocs)?;
    println!(
        "imported {} IOC indicators into snapshot '{snapshot}' at {root}",
        iocs.len()
    );
    Ok(())
}

/// `warehouse import-vuln-symbols --warehouse <root> --snapshot <id> --go-osv <dir>`
/// — parse the Go vuln DB's OSV docs (`ecosystem_specific.imports`) into the
/// `vuln_symbols` table: the `(advisory, import-path, symbol)` tuples the A8
/// reachability gate loads as `scip::VulnSink`s (baddie-detection b1). Anti-silent-
/// empty: a Go OSV dir that yields 0 symbols is a MISSING-FEED error — the Go DB
/// always carries symbol-level `imports`, so 0 means a truncated/wrong corpus.
fn cmd_import_vuln_symbols(args: &[&str]) -> Result<()> {
    let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
    let snapshot = flag(args, "--snapshot").context("--snapshot <id> is required")?;
    let dir =
        flag(args, "--go-osv").context("--go-osv <dir> (the Go vuln DB OSV docs) is required")?;
    let mut symbols: Vec<modgunn::osv::VulnSymbol> = Vec::new();
    let mut n_fail = 0usize;
    let docs = read_json_dir_named(Path::new(&dir))?;
    if docs.is_empty() {
        bail!(
            "--go-osv {dir} has no JSON docs — nothing to import (a MISSING-FEED, not a fake pass)"
        );
    }
    for (path, doc) in &docs {
        match modgunn::osv::parse_osv_vuln_symbols(doc) {
            Ok(mut s) => symbols.append(&mut s),
            Err(e) => {
                n_fail += 1;
                eprintln!(
                    "warning: skipping unparseable OSV file {}: {e}",
                    path.display()
                );
            }
        }
    }
    if symbols.is_empty() {
        bail!("--go-osv {dir} ({} docs) parsed 0 symbol-level records — the Go vuln DB always ships `ecosystem_specific.imports`, so 0 is a truncated/wrong corpus (MISSING-FEED), not a fake pass", docs.len());
    }
    let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
    wh.ensure_modgunn_tables()?;
    wh.append_vuln_symbols(&snapshot, &symbols)?;
    let n_named = symbols.iter().filter(|s| !s.symbol.is_empty()).count();
    println!(
        "imported {} vuln-symbol record(s) ({n_named} function-level, {} whole-package) from {} docs into snapshot '{snapshot}' at {root}{}",
        symbols.len(),
        symbols.len() - n_named,
        docs.len(),
        if n_fail > 0 { format!(" ({n_fail} unparseable doc(s) skipped)") } else { String::new() },
    );
    Ok(())
}

/// `warehouse vuln-symbols-log --warehouse <root> --snapshot <id>` — read back the
/// imported symbol-level vuln records (operator/smid verification that the rows the
/// reachability gate needs actually landed).
fn cmd_vuln_symbols_log(args: &[&str]) -> Result<()> {
    let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
    let snapshot = flag(args, "--snapshot").context("--snapshot <id> is required")?;
    let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
    let rows = wh.read_vuln_symbols(&snapshot)?;
    println!(
        "{} vuln-symbol record(s) in snapshot '{snapshot}':",
        rows.len()
    );
    for (advisory, ecosystem, package, symbol, severity) in &rows {
        let sym = if symbol.is_empty() {
            "(whole package)"
        } else {
            symbol.as_str()
        };
        println!("  {advisory:<16} [{ecosystem}] {package}  →  {sym}  ({severity})");
    }
    Ok(())
}

fn cmd_import(args: &[&str]) -> Result<()> {
    let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
    let rev = flag(args, "--rev").context("--rev <db_rev> is required")?;

    // 1 · gather advisories from the OSV dir and/or the OpenSSF Malicious-Packages
    //     dir. At least one source is required. A malformed/truncated doc is never
    //     silently dropped: it is named on stderr and counted (`n_fail`), and the
    //     tally is surfaced in the import summary so reduced coverage is visible.
    let (mut n_osv, mut n_mal) = (0usize, 0usize);
    let mut n_fail = 0usize;
    let mut advisories: Vec<Advisory> = Vec::new();
    if let Some(osv_dir) = flag(args, "--osv") {
        let docs = read_json_dir_named(Path::new(&osv_dir))?;
        n_osv = docs.len();
        let mut parsed = Vec::new();
        for (path, doc) in &docs {
            match osv::parse_doc(doc) {
                Ok(mut a) => parsed.append(&mut a),
                Err(e) => {
                    n_fail += 1;
                    eprintln!(
                        "warning: skipping unparseable OSV file {}: {e}",
                        path.display()
                    );
                }
            }
        }
        // normalize + dedup across feeds (range-native).
        advisories = merge_by_alias(parsed);
    }
    if let Some(mal_dir) = flag(args, "--malicious") {
        let docs = read_json_dir_named(Path::new(&mal_dir))?;
        n_mal = docs.len();
        for (path, d) in &docs {
            match feeds::parse_malicious(d) {
                Ok(list) => {
                    for mut a in list {
                        a.source = "openssf".into(); // malicious-packages provenance
                        advisories.push(a);
                    }
                }
                Err(e) => {
                    n_fail += 1;
                    eprintln!(
                        "warning: skipping unparseable malicious-packages file {}: {e}",
                        path.display()
                    );
                }
            }
        }
        // Re-unify so a MAL row and any aliasing CVE fold together.
        advisories = merge_by_alias(advisories);
    }

    // 1b · GitLab gemnasium advisories (YAML, one file per advisory) → Advisory rows.
    //      A file with no usable coords/id yields None (skipped, not an error).
    let mut n_gitlab = 0usize;
    if let Some(dir) = flag(args, "--gitlab") {
        for (path, y) in &read_yaml_dir_named(Path::new(&dir))? {
            match modgunn::gitlab::parse_gemnasium_yaml(y) {
                Some(a) => {
                    advisories.push(a);
                    n_gitlab += 1;
                }
                None => {
                    // No usable coords/id, or an unparseable affected_range (the
                    // all-versions-FP guard) — never silently dropped.
                    n_fail += 1;
                    eprintln!(
                        "warning: skipping gemnasium advisory (no coords / unparseable range): {}",
                        path.display()
                    );
                }
            }
        }
        advisories = merge_by_alias(advisories);
    }

    // 1c · ecosyste.ms package-status (cross-ecosystem removed/unpublished/deprecated)
    //      → an all-versions Low advisory per pulled package. A doc with active/null
    //      status yields None (a real zero, NOT counted as a failure); a doc that
    //      won't parse is counted + named. Package-level "stop depending on this".
    let mut n_ecosystems = 0usize;
    if let Some(dir) = flag(args, "--ecosystems") {
        for (path, d) in &read_json_dir_named(Path::new(&dir))? {
            match registry::parse_ecosystems_package(d) {
                Ok(Some(a)) => {
                    advisories.push(a);
                    n_ecosystems += 1;
                }
                Ok(None) => {} // active/unknown status ⇒ nothing to flag (real zero)
                Err(e) => {
                    n_fail += 1;
                    eprintln!(
                        "warning: skipping unparseable ecosyste.ms doc {}: {e}",
                        path.display()
                    );
                }
            }
        }
        advisories = merge_by_alias(advisories);
    }

    // 1d · libraries.io project-status (the elder cross-ecosystem index; ecosyste.ms
    //      is its open successor) → an all-versions Low advisory per pulled/deprecated/
    //      unmaintained project. Same shape/handling as --ecosystems: active/null ⇒ a
    //      real None (not a failure); an unparseable doc is counted + named.
    //      NOTE: the live libraries.io API needs a free registered api_key; this reads
    //      the already-downloaded project JSON (obtaining it is out of scope).
    let mut n_librariesio = 0usize;
    if let Some(dir) = flag(args, "--librariesio") {
        for (path, d) in &read_json_dir_named(Path::new(&dir))? {
            match registry::parse_librariesio_project(d) {
                Ok(Some(a)) => {
                    advisories.push(a);
                    n_librariesio += 1;
                }
                Ok(None) => {} // active/unknown status ⇒ nothing to flag (real zero)
                Err(e) => {
                    n_fail += 1;
                    eprintln!(
                        "warning: skipping unparseable libraries.io doc {}: {e}",
                        path.display()
                    );
                }
            }
        }
        advisories = merge_by_alias(advisories);
    }

    // 2 · registry yank / deprecation signals (crates.io yanks, npm deprecations) —
    //     CVE-less supply-chain "stop depending on this version" rows that land in
    //     the same `cve_advisories` table. A malformed doc (no `versions`) is a hard
    //     parse error (counted in `n_fail`), never a silent empty; a well-formed doc
    //     with nothing yanked/deprecated is a real zero.
    let (mut n_yank, mut n_dep) = (0usize, 0usize);
    if let Some(dir) = flag(args, "--crates-yanks") {
        for (path, d) in &read_json_dir_named(Path::new(&dir))? {
            match registry::parse_crates_yanks(d) {
                Ok(list) => {
                    n_yank += list.len();
                    advisories.extend(list);
                }
                Err(e) => {
                    n_fail += 1;
                    eprintln!(
                        "warning: skipping unparseable crates.io doc {}: {e}",
                        path.display()
                    );
                }
            }
        }
    }
    if let Some(dir) = flag(args, "--npm-deprecations") {
        for (path, d) in &read_json_dir_named(Path::new(&dir))? {
            match registry::parse_npm_deprecations(d) {
                Ok(list) => {
                    n_dep += list.len();
                    advisories.extend(list);
                }
                Err(e) => {
                    n_fail += 1;
                    eprintln!(
                        "warning: skipping unparseable npm packument {}: {e}",
                        path.display()
                    );
                }
            }
        }
    }
    if let Some(dir) = flag(args, "--pypi-yanks") {
        for (path, d) in &read_json_dir_named(Path::new(&dir))? {
            match registry::parse_pypi_yanks(d) {
                Ok(list) => {
                    n_yank += list.len();
                    advisories.extend(list);
                }
                Err(e) => {
                    n_fail += 1;
                    eprintln!(
                        "warning: skipping unparseable PyPI project doc {}: {e}",
                        path.display()
                    );
                }
            }
        }
    }
    if let Some(dir) = flag(args, "--go-retract") {
        // go.mod retractions are the only Go yank signal (no central registry API).
        // The parser is tolerant (returns rows, never errors); a go.mod with no
        // `module` line yields none.
        for (_path, d) in &read_mod_dir_named(Path::new(&dir))? {
            let list = registry::parse_go_retractions(d);
            n_yank += list.len();
            advisories.extend(list);
        }
    }
    if n_yank > 0 || n_dep > 0 {
        // Fold a yank/deprecation into any aliasing CVE row for the same coords.
        advisories = merge_by_alias(advisories);
    }

    // 1e · Sonatype OSS-Index component reports (purl-keyed, cross-ecosystem).
    //      A CVE-carrying finding is keyed BY its CVE and re-merged, so it folds into
    //      the row the OSV/NVD import already produced instead of double-reporting one
    //      vulnerability; only a Sonatype-only finding lands a MODGUNN-OSSINDEX-* id.
    let mut n_ossindex = 0usize;
    if let Some(dir) = flag(args, "--ossindex") {
        for (path, d) in &read_json_dir_named(Path::new(&dir))? {
            match registry::parse_ossindex_report(d) {
                Ok(list) => {
                    n_ossindex += list.len();
                    advisories.extend(list);
                }
                Err(e) => {
                    n_fail += 1;
                    eprintln!(
                        "warning: skipping unparseable OSS-Index report {}: {e}",
                        path.display()
                    );
                }
            }
        }
        if n_ossindex > 0 {
            advisories = merge_by_alias(advisories);
        }
    }

    // 1f · socket.dev supply-chain alerts (per-package-version behaviour findings —
    //      malware/install-scripts/obfuscation/…). ⚠ SCAFFOLD: parses Socket's public
    //      alert schema, but the LIVE API is PAID so the envelope is unverified against
    //      a real feed (see registry.rs / feeds-design.md §D). A doc with no alerts is
    //      a real zero; an unparseable doc is counted + named.
    let mut n_socket = 0usize;
    if let Some(dir) = flag(args, "--socket") {
        for (path, d) in &read_json_dir_named(Path::new(&dir))? {
            match registry::parse_socket_alerts(d) {
                Ok(list) => {
                    n_socket += list.len();
                    advisories.extend(list);
                }
                Err(e) => {
                    n_fail += 1;
                    eprintln!(
                        "warning: skipping unparseable socket.dev doc {}: {e}",
                        path.display()
                    );
                }
            }
        }
        if n_socket > 0 {
            advisories = merge_by_alias(advisories);
        }
    }

    // 1g · GUAC CertifyVuln ingest (package↔vuln certifications from GUAC's graph).
    //      ⚠ SCAFFOLD: parses GUAC's documented CertifyVuln GraphQL shape, but needs a
    //      RUNNING GUAC instance (POST /query) — unverified against a live endpoint
    //      here (see registry.rs / feeds-design.md §D). CVE/GHSA ids are keyed so they
    //      fold into the OSV/NVD row; an unparseable doc is counted + named.
    let mut n_guac = 0usize;
    if let Some(dir) = flag(args, "--guac") {
        for (path, d) in &read_json_dir_named(Path::new(&dir))? {
            match registry::parse_guac_certify_vuln(d) {
                Ok(list) => {
                    n_guac += list.len();
                    advisories.extend(list);
                }
                Err(e) => {
                    n_fail += 1;
                    eprintln!(
                        "warning: skipping unparseable GUAC CertifyVuln doc {}: {e}",
                        path.display()
                    );
                }
            }
        }
        if n_guac > 0 {
            advisories = merge_by_alias(advisories);
        }
    }

    // At least one source flag must have been provided. A source that was provided
    // but is legitimately all-clean (0 yanks) is NOT an error here — that zero is
    // real; it is a MISSING `versions` doc that errors, up in the per-doc parse.
    let any_source = [
        "--osv",
        "--malicious",
        "--gitlab",
        "--ecosystems",
        "--librariesio",
        "--ossindex",
        "--socket",
        "--guac",
        "--crates-yanks",
        "--npm-deprecations",
        "--pypi-yanks",
        "--go-retract",
    ]
    .iter()
    .any(|f| flag(args, f).is_some());
    if !any_source {
        bail!("nothing to import — provide --osv/--malicious/--gitlab/--ecosystems/--librariesio/--ossindex/--socket/--guac/--crates-yanks/--npm-deprecations/--pypi-yanks/--go-retract <dir> with *.json files");
    }
    if advisories.is_empty() {
        println!(
            "no advisories to import — every source parsed clean (0 rows); no generation written"
        );
        return Ok(());
    }

    // 2b · optional NVD enrichment (CVSS vector + CWE + severity lift, by CVE id).
    if let Some(nvd_dir) = flag(args, "--nvd") {
        let mut entries = Vec::new();
        for (path, d) in &read_json_dir_named(Path::new(&nvd_dir))? {
            match nvd::parse_nvd(d) {
                Ok(mut es) => entries.append(&mut es),
                Err(e) => {
                    n_fail += 1;
                    eprintln!(
                        "warning: skipping unparseable NVD file {}: {e}",
                        path.display()
                    );
                }
            }
        }
        if !entries.is_empty() {
            nvd::enrich_with_nvd(&mut advisories, &entries);
        }
    }
    // 2c · optional CPE-keyed backfill: standalone advisories for CVEs OSV misses
    // (ecosystem "cpe"; lossy — opt-in).
    if let Some(dir) = flag(args, "--nvd-backfill") {
        for (path, d) in &read_json_dir_named(Path::new(&dir))? {
            match nvd::parse_nvd_advisories(d) {
                Ok(mut es) => advisories.append(&mut es),
                Err(e) => {
                    n_fail += 1;
                    eprintln!(
                        "warning: skipping unparseable NVD-backfill file {}: {e}",
                        path.display()
                    );
                }
            }
        }
        advisories = merge_by_alias(advisories);
    }

    // 3 · optional exploit-intelligence enrichment (EPSS probability + KEV flag).
    //     Anti-silent-empty: a feed that was PROVIDED but parsed to 0 rows is a
    //     truncated/empty catalog, not a legit no-op — silently skipping it is a fake
    //     pass where the KEV→Critical / EPSS→High escalation never fires. So a
    //     provided-but-empty --epss/--kev is a hard error, not a shrug.
    let epss = match flag(args, "--epss") {
        Some(p) => {
            let m = feeds::parse_epss_csv(
                &std::fs::read_to_string(&p).with_context(|| format!("read EPSS {p}"))?,
            );
            if m.is_empty() {
                bail!("--epss {p} parsed 0 scores — an empty/truncated EPSS feed would silently disable EPSS escalation; refusing to import a fake pass");
            }
            m
        }
        None => HashMap::new(),
    };
    let mut kev = match flag(args, "--kev") {
        Some(p) => {
            let s = feeds::parse_kev_json(
                &std::fs::read_to_string(&p).with_context(|| format!("read KEV {p}"))?,
            )
            .context("parse KEV json")?;
            if s.is_empty() {
                bail!("--kev {p} parsed 0 CVEs — an empty/truncated KEV catalog would silently disable KEV escalation; refusing to import a fake pass");
            }
            s
        }
        None => HashSet::new(),
    };
    // ENISA EUVD `exploitedSince` — a KEV-grade "exploited in the wild" signal from a
    // second (EU) authority, folded into the SAME kev escalation set (independent
    // corroboration + coverage CISA KEV may lack). Additive to --kev, so no separate
    // bail; a malformed doc is counted.
    if let Some(dir) = flag(args, "--euvd-exploited") {
        for (path, d) in &read_json_dir_named(Path::new(&dir))? {
            match feeds::parse_euvd_exploited(d) {
                Ok(set) => kev.extend(set),
                Err(e) => {
                    n_fail += 1;
                    eprintln!(
                        "warning: skipping unparseable EUVD doc {}: {e}",
                        path.display()
                    );
                }
            }
        }
    }
    // VulnCheck KEV — a superset of CISA KEV (adds exploited CVEs CISA has not yet
    // catalogued), a KEV-grade "exploited in the wild" signal from a third authority.
    // Folded into the SAME kev escalation set. Its own empty-is-RED: if the flag was
    // given yet 0 CVEs parsed (empty/truncated catalog), that would silently add
    // nothing — a hard error, not a shrug.
    if let Some(p) = flag(args, "--vulncheck-kev") {
        let s = feeds::parse_vulncheck_kev(
            &std::fs::read_to_string(&p).with_context(|| format!("read VulnCheck KEV {p}"))?,
        )
        .context("parse VulnCheck KEV json")?;
        if s.is_empty() {
            bail!("--vulncheck-kev {p} parsed 0 CVEs — an empty/truncated VulnCheck KEV catalog would silently disable the KEV escalation it feeds; refusing to import a fake pass");
        }
        kev.extend(s);
    }
    if !epss.is_empty() || !kev.is_empty() {
        feeds::enrich(&mut advisories, &epss, &kev);
    }

    // 3b · optional public-exploit enrichment: a CVE with a public exploit — a
    //      PoC-in-GitHub proof-of-concept (`--exploit-poc <dir>`) OR a weaponized
    //      Metasploit module (`--metasploit <file>`), a Nuclei template
    //      (`--nuclei <file>`) OR a published **Exploit-DB** entry
    //      (`--exploitdb <files_exploits.csv>`) — is lifted to at least High +
    //      summary-tagged. Both sources union into one exploit-CVE set. Same
    //      anti-silent-empty law: if an exploit source was provided yet the union is
    //      0 CVEs (every doc empty/truncated) it would silently disable the lift, so
    //      that is a hard error, not a shrug.
    let mut n_exploited = 0usize;
    let exploit_poc = flag(args, "--exploit-poc");
    let metasploit = flag(args, "--metasploit");
    let nuclei = flag(args, "--nuclei");
    let exploitdb = flag(args, "--exploitdb");
    if exploit_poc.is_some() || metasploit.is_some() || nuclei.is_some() || exploitdb.is_some() {
        let mut exploited: HashSet<String> = HashSet::new();
        if let Some(dir) = &exploit_poc {
            for (path, d) in &read_json_dir_named(Path::new(dir))? {
                match feeds::parse_poc_in_github(d) {
                    Ok(set) => exploited.extend(set),
                    Err(e) => {
                        n_fail += 1;
                        eprintln!(
                            "warning: skipping unparseable PoC-in-GitHub doc {}: {e}",
                            path.display()
                        );
                    }
                }
            }
        }
        if let Some(path) = &metasploit {
            let doc =
                std::fs::read_to_string(path).with_context(|| format!("read Metasploit {path}"))?;
            match feeds::parse_metasploit_cves(&doc) {
                Ok(set) => exploited.extend(set),
                Err(e) => {
                    n_fail += 1;
                    eprintln!("warning: skipping unparseable Metasploit index {path}: {e}");
                }
            }
        }
        if let Some(path) = &nuclei {
            let doc =
                std::fs::read_to_string(path).with_context(|| format!("read Nuclei {path}"))?;
            match feeds::parse_nuclei_cves(&doc) {
                Ok(set) => exploited.extend(set),
                Err(e) => {
                    n_fail += 1;
                    eprintln!("warning: skipping unparseable Nuclei cves.json {path}: {e}");
                }
            }
        }
        if let Some(path) = &exploitdb {
            let doc =
                std::fs::read_to_string(path).with_context(|| format!("read Exploit-DB {path}"))?;
            match feeds::parse_exploitdb_csv(&doc) {
                Ok(set) => exploited.extend(set),
                Err(e) => {
                    n_fail += 1;
                    eprintln!(
                        "warning: skipping unparseable Exploit-DB files_exploits.csv {path}: {e}"
                    );
                }
            }
        }
        if exploited.is_empty() {
            bail!("exploit source(s) provided but parsed 0 exploit CVEs — an empty/truncated exploit index would silently disable the public-exploit lift; refusing to import a fake pass");
        }
        feeds::enrich_exploited(&mut advisories, &exploited);
        n_exploited = advisories
            .iter()
            .filter(|a| a.summary.contains(feeds::PUBLIC_EXPLOIT_MARKER))
            .count();
    }

    // 4 · append as a new generation; latest db_rev wins on read.
    let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
    wh.ensure_modgunn_tables()?;
    wh.append_advisories(&rev, &advisories)?;

    // Introspection marker — the `warehouse import` verb reports to the test matrix.
    #[cfg(feature = "testmatrix")]
    modgunn::functional_status(
        "modgunn/cli-warehouse-import",
        "advisories_imported",
        true,
        &format!("imported {} advisories as rev '{}'", advisories.len(), rev),
    );

    println!(
        "imported {} advisories (from {} OSV + {} malicious + {} GitLab docs, {} registry yanks [crates.io/pypi/go], {} npm deprecations, {} ecosyste.ms package-status, {} libraries.io package-status, {} OSS-Index findings, {} socket.dev alerts, {} GUAC CertifyVuln) as rev '{}' into {} [{} KEV, {} EPSS-scored, {} public-exploit, {} CVSS-vectored, {} source files skipped]",
        advisories.len(),
        n_osv,
        n_mal,
        n_gitlab,
        n_yank,
        n_dep,
        n_ecosystems,
        n_librariesio,
        n_ossindex,
        n_socket,
        n_guac,
        rev,
        root,
        advisories.iter().filter(|a| a.kev).count(),
        advisories.iter().filter(|a| a.epss.is_some()).count(),
        n_exploited,
        advisories.iter().filter(|a| !a.cvss_vector.is_empty()).count(),
        n_fail,
    );
    if n_fail > 0 {
        eprintln!("warning: {n_fail} source file(s) skipped due to parse failures — CVE coverage may be reduced");
    }
    Ok(())
}

/// Full fleet scan, dispatching to the DataFusion SQL pushdown when `--sql` is
/// asked AND the `warehouse-sql` feature is compiled in; otherwise the in-memory
/// scan (so `--sql` on a lean build degrades, it doesn't fail).
#[cfg(feature = "warehouse-sql")]
fn fleet(
    wh: &Warehouse,
    sql: bool,
    engine: EngineKind,
) -> Result<Vec<modgunn::warehouse::FleetFinding>> {
    if sql {
        // The SQL pushdown is its own candidate-narrowing strategy (the equi-join
        // runs IN the warehouse), orthogonal to the in-RAM A/B engines.
        wh.scan_fleet_sql()
    } else {
        wh.scan_fleet_with(engine)
    }
}
#[cfg(not(feature = "warehouse-sql"))]
fn fleet(
    wh: &Warehouse,
    _sql: bool,
    engine: EngineKind,
) -> Result<Vec<modgunn::warehouse::FleetFinding>> {
    wh.scan_fleet_with(engine)
}

fn cmd_scan(args: &[&str]) -> Result<()> {
    let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
    let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
    wh.ensure_modgunn_tables()?;

    // --engine a|b selects the matching strategy (A = linear, B = inverted index);
    // an unrecognised value falls back to the default (A) with a warning rather than
    // failing the scan. Env `MODGUNN_ENGINE` is the config fallback when no flag.
    let engine = match flag(args, "--engine") {
        Some(s) => EngineKind::parse(&s).unwrap_or_else(|| {
            eprintln!(
                "warning: unknown --engine `{s}` (expected a|b|linear|indexed); using default"
            );
            EngineKind::default()
        }),
        None => EngineKind::from_env(),
    };

    // --since <snapshot> → incremental CDC delta (only newly-added components);
    // otherwise a full fleet scan over the whole SBOM.
    let (mut findings, watermark) = match flag(args, "--since") {
        Some(s) => {
            let from: i64 = s
                .parse()
                .context("--since <snapshot_id> must be an integer")?;
            wh.scan_sbom_delta_with(Some(from), engine)?
        }
        // current_snapshot_id errors if nornir hasn't created the table yet — a
        // missing SBOM just means "no watermark", not a failure.
        None => (
            fleet(&wh, has_flag(args, "--sql"), engine)?,
            wh.current_snapshot_id("sbom_components").unwrap_or(None),
        ),
    };

    // VEX suppression: drop findings whose advisory the consumer asserted
    // not-affected (CSAF `known_not_affected`).
    let mut suppressed = 0usize;
    if let Some(vex_dir) = flag(args, "--vex") {
        let set = vex::collect_suppressions(&read_json_dir(Path::new(&vex_dir))?);
        let before = findings.len();
        findings.retain(|f| !set.contains(&f.advisory_id));
        suppressed = before - findings.len();
    }

    // --persist <snapshot>: materialize the (VEX-suppressed) findings into the
    // `vuln_findings` table so the Mímir lens reads modgunn's DECIDED verdict
    // (baddie-detection b5: refuse/hold/act + the axis + the evidence rank) instead
    // of recomputing the fleet scan or re-deriving the verdict from id prefixes.
    //
    // `--gate-on <world_snapshot>` additionally grades each finding through the b1
    // call closure of a captured world snapshot: proven-called ⇒ 🔴 refuse,
    // proven-dead ⇒ 🟠 hold-demoted, no symbol-level sink ⇒ Unknown (a plain
    // present-vuln hold — never demoted on no evidence).
    if let Some(snap) = flag(args, "--persist") {
        match flag(args, "--gate-on") {
            // b1 reachability grading is Engine B (scip). Compiled in only with the
            // `scip` feature; without it, fail loudly instead of silently ignoring
            // --gate-on (and instead of the whole warehouse module failing to build
            // for lean consumers like holger's Tools#3, which take scan+warehouse only).
            #[cfg(feature = "scip")]
            Some(world) => {
                let (refused, held) = wh.append_vuln_findings_gated(&snap, &findings, &world)?;
                println!(
                    "persisted {} finding(s) to vuln_findings snapshot '{snap}', b1-gated on world snapshot '{world}': {refused} refuse 🔴 / {held} hold 🟠",
                    findings.len()
                );
            }
            #[cfg(not(feature = "scip"))]
            Some(_) => {
                anyhow::bail!(
                    "--gate-on (b1 reachability demotion) is Engine B and needs the `scip` \
                     feature; rebuild modgunn with --features scip, or drop --gate-on to persist \
                     ungraded present-vuln findings"
                );
            }
            None => {
                wh.append_vuln_findings(&snap, &findings)?;
                let refuse = findings
                    .iter()
                    .filter(|f| {
                        f.verdict(modgunn::baddie::Reachability::Unknown).decision
                            == modgunn::core::Decision::Block
                    })
                    .count();
                println!(
                    "persisted {} finding(s) to vuln_findings snapshot '{snap}' [{refuse} refuse 🔴; reachability not computed — add --gate-on <world_snapshot> for the b1 demotion]",
                    findings.len()
                );
            }
        }
    }

    for f in &findings {
        println!(
            "{:<10} {} {}  {}  {}",
            f.repo, f.name, f.version, f.advisory_id, f.severity
        );
    }
    let crit = findings
        .iter()
        .filter(|f| f.severity >= modgunn::core::Severity::High)
        .count();
    // Introspection marker — the `warehouse scan` verb reports fleet health to the
    // test matrix. A clean fleet (no high/critical finding) is the healthy verdict.
    #[cfg(feature = "testmatrix")]
    modgunn::functional_status(
        "modgunn/cli-warehouse-scan",
        "fleet_scan",
        crit == 0,
        &format!("{} finding(s), {} high/critical", findings.len(), crit),
    );
    // Per-repo health / severity-histogram rollup — the fleet AT ALTITUDE, folded
    // from the same finding rows, so an operator sees which repos are worst instead
    // of only a raw list + a global crit count.
    let rollup = modgunn::rollup::rollup_fleet_findings(&findings);
    println!("{}", modgunn::rollup::render_fleet_summary(&rollup));
    println!(
        "{} finding(s) ({} high/critical{}){}",
        findings.len(),
        crit,
        if suppressed > 0 {
            format!(", {suppressed} VEX-suppressed")
        } else {
            String::new()
        },
        watermark
            .map(|w| format!("; next --since {w}"))
            .unwrap_or_default(),
    );
    Ok(())
}

/// `warehouse rollup` — the fleet security posture at altitude: load the whole-SBOM
/// fleet findings and print the per-repo health / severity-histogram summary
/// ([`rollup::rollup_fleet_findings`] + [`rollup::render_fleet_summary`]) without the
/// raw per-finding rows `scan` prints. The org-overview verb.
fn cmd_rollup(args: &[&str]) -> Result<()> {
    let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
    let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
    wh.ensure_modgunn_tables()?;
    let engine = match flag(args, "--engine") {
        Some(s) => EngineKind::parse(&s).unwrap_or_else(|| {
            eprintln!(
                "warning: unknown --engine `{s}` (expected a|b|linear|indexed); using default"
            );
            EngineKind::default()
        }),
        None => EngineKind::from_env(),
    };
    let findings = fleet(&wh, has_flag(args, "--sql"), engine)?;
    let rollup = modgunn::rollup::rollup_fleet_findings(&findings);

    #[cfg(feature = "testmatrix")]
    modgunn::functional_status(
        "modgunn/cli-warehouse-rollup",
        "fleet_rollup",
        rollup.severity.critical == 0 && rollup.severity.high == 0,
        &format!(
            "{} repo(s) affected, {} finding(s)",
            rollup.affected_repos, rollup.severity.total
        ),
    );

    println!("{}", modgunn::rollup::render_fleet_summary(&rollup));
    Ok(())
}

fn cmd_stats(args: &[&str]) -> Result<()> {
    let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
    let eco = flag(args, "--ecosystem");
    let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
    wh.ensure_modgunn_tables()?;
    let db = wh.load_advisory_db(eco.as_deref())?;
    // Introspection marker — the `warehouse stats` verb reports to the test matrix.
    #[cfg(feature = "testmatrix")]
    modgunn::functional_status(
        "modgunn/cli-warehouse-stats",
        "advisory_db_loaded",
        !db.advisories.is_empty(),
        &format!("rev '{}' — {} advisories", db.rev, db.advisories.len()),
    );
    println!(
        "advisory db rev '{}' — {} advisories ({})",
        db.rev,
        db.advisories.len(),
        eco.as_deref().unwrap_or("all ecosystems"),
    );
    Ok(())
}

/// `warehouse provenance-log` — read back the build-provenance VERIFY ledger
/// ([`Warehouse::query_build_provenance`], newest-first) that `verify-provenance`
/// writes, optionally scoped by `--id <artifact_id>` and/or `--repo <r>`, and print
/// each verdict row (verified flag + signer + reason). Also surfaces the stored OSSF
/// **Scorecard** signals ([`Warehouse::load_scorecard`]) AND the advisory findings
/// they generate ([`ScorecardSignals::findings`] — low score / unmaintained /
/// dangerous workflow), so scorecard risk is visible instead of inert in the table.
fn cmd_provenance_log(args: &[&str]) -> Result<()> {
    use modgunn::core::Severity;

    let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
    let repo = flag(args, "--repo");
    let id = flag(args, "--id");
    let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
    wh.ensure_modgunn_tables()?;

    // Build-provenance VERIFY verdicts, newest-first (query already scopes by --id).
    let mut rows = wh.query_build_provenance(id.as_deref())?;
    if let Some(r) = &repo {
        rows.retain(|v| &v.repo == r);
    }
    for v in &rows {
        println!(
            "{:<9} {:<12} {:<38} signer={:<20} {}",
            if v.verified { "VERIFIED" } else { "REJECTED" },
            v.repo,
            v.artifact_id,
            if v.signer.is_empty() {
                "-"
            } else {
                v.signer.as_str()
            },
            v.reason,
        );
    }
    let verified = rows.iter().filter(|v| v.verified).count();

    // OSSF Scorecard signals + the advisory findings they fold into. Sorted worst
    // (lowest overall) first so the riskiest subject reads at the top.
    let mut cards = wh.load_scorecard()?;
    cards.sort_by(|a, b| {
        a.overall
            .partial_cmp(&b.overall)
            .unwrap_or(std::cmp::Ordering::Equal)
            .then_with(|| a.subject.cmp(&b.subject))
    });
    let mut scorecard_findings = 0usize;
    let mut worst_scorecard: Option<Severity> = None;
    for c in &cards {
        println!(
            "scorecard {:<24} {:>4.1}/10  maintained={}  signed_releases={}",
            c.subject, c.overall, c.maintained, c.signed_releases,
        );
        for f in c.findings() {
            scorecard_findings += 1;
            worst_scorecard = Some(worst_scorecard.map_or(f.severity, |w| w.max(f.severity)));
            println!("  {:<8} {} — {}", f.severity.as_str(), f.id, f.summary);
        }
    }

    #[cfg(feature = "testmatrix")]
    modgunn::functional_status(
        "modgunn/cli-warehouse-provenance-log",
        "provenance_ledger_read",
        rows.iter().all(|v| v.verified) && scorecard_findings == 0,
        &format!(
            "{} verdict(s) ({verified} verified), {} scorecard subject(s), {scorecard_findings} scorecard finding(s)",
            rows.len(),
            cards.len(),
        ),
    );

    println!(
        "{} provenance verdict(s) ({verified} verified, {} rejected); {} scorecard subject(s), {scorecard_findings} scorecard finding(s){}",
        rows.len(),
        rows.len() - verified,
        cards.len(),
        worst_scorecard.map(|w| format!("; worst scorecard {w}")).unwrap_or_default(),
    );
    Ok(())
}

/// `warehouse behavior-log` — read back the A23 package-behaviour VERDICT ledger
/// ([`Warehouse::load_package_behaviors`], the latest analysis per
/// `(ecosystem, subject)`) that `behavior --warehouse` persists, optionally scoped by
/// `--ecosystem <eco>` and/or `--subject <name>`, and print each stored feature
/// vector + its derived verdict (worst severity + finding ids). The read-back leg of
/// the `behavior --warehouse` append→load round-trip. Sorted worst-severity first so
/// the riskiest package reads at the top.
fn cmd_behavior_log(args: &[&str]) -> Result<()> {
    let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
    let eco = flag(args, "--ecosystem");
    let subject = flag(args, "--subject");
    let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
    wh.ensure_modgunn_tables()?;

    let mut rows = wh.load_package_behaviors()?;
    if let Some(e) = &eco {
        rows.retain(|r| &r.ecosystem == e);
    }
    if let Some(s) = &subject {
        rows.retain(|r| &r.behavior.subject == s);
    }
    // Worst-severity first (High before clean); then by ecosystem+subject for stability.
    rows.sort_by(|a, b| {
        b.worst_severity
            .cmp(&a.worst_severity)
            .then_with(|| a.ecosystem.cmp(&b.ecosystem))
            .then_with(|| a.behavior.subject.cmp(&b.behavior.subject))
    });

    let mut flagged = 0usize;
    for r in &rows {
        let b = &r.behavior;
        let apis = b
            .sensitive_apis
            .iter()
            .cloned()
            .collect::<Vec<_>>()
            .join(",");
        println!(
            "behavior [{:<9}] {:<24} verdict={:<8} hooks={:?} network_in_install={} urls={} entropy={:.2} apis=[{}]{}",
            r.ecosystem,
            b.subject,
            r.worst_severity.map(|w| w.as_str()).unwrap_or("clean"),
            b.install_hooks,
            b.network_in_install,
            b.url_count,
            b.max_token_entropy,
            apis,
            if r.finding_ids.is_empty() {
                String::new()
            } else {
                format!(" findings=[{}]", r.finding_ids.join(","))
            },
        );
        if r.worst_severity.is_some() {
            flagged += 1;
        }
    }

    #[cfg(feature = "testmatrix")]
    modgunn::functional_status(
        "modgunn/cli-warehouse-behavior-log",
        "behavior_ledger_read",
        flagged == 0,
        &format!(
            "{} behaviour analysis(es) read, {flagged} flagged",
            rows.len()
        ),
    );

    println!("{} behaviour analysis(es) ({flagged} flagged)", rows.len());
    Ok(())
}

/// `warehouse provenance-diff` — compare the build-provenance VERIFY ledger at two
/// Iceberg snapshots ([`Warehouse::provenance_diff`]) and print the supply-chain
/// delta: artifacts that gained an attestation (`+`), lost one (`-`), or had their
/// verdict **transition** (`~`, e.g. VERIFIED → REJECTED or a signer swap). `--from`
/// defaults to the empty beginning, `--to` to the current snapshot (so
/// `provenance-diff --to <s>` shows everything attested up to `s`). A verdict
/// **regression** (was verified, now is not) is the gate-worthy signal:
/// `--fail-on-regression` exits non-zero (3) when any fires, so a promotion check can
/// block on it.
fn cmd_provenance_diff(args: &[&str]) -> Result<()> {
    let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
    let repo = flag(args, "--repo");
    let from = match flag(args, "--from") {
        Some(s) => Some(
            s.parse::<i64>()
                .context("--from <snapshot_id> must be an integer")?,
        ),
        None => None,
    };
    let to = match flag(args, "--to") {
        Some(s) => Some(
            s.parse::<i64>()
                .context("--to <snapshot_id> must be an integer")?,
        ),
        None => None,
    };
    let fail_on_regression = args.contains(&"--fail-on-regression");

    let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
    wh.ensure_modgunn_tables()?;
    let mut delta = wh.provenance_diff(from, to)?;

    // Optional repo scoping — drop rows/transitions for other repos.
    if let Some(r) = &repo {
        delta.added.retain(|v| &v.repo == r);
        delta.removed.retain(|v| &v.repo == r);
        delta
            .changed
            .retain(|t| &t.after.repo == r || &t.before.repo == r);
    }

    for v in &delta.added {
        println!(
            "+ {:<12} {:<38} {}  signer={}",
            v.repo,
            v.artifact_id,
            if v.verified { "VERIFIED" } else { "REJECTED" },
            if v.signer.is_empty() {
                "-"
            } else {
                v.signer.as_str()
            },
        );
    }
    for v in &delta.removed {
        println!("- {:<12} {:<38} attestation dropped", v.repo, v.artifact_id);
    }
    for t in &delta.changed {
        let tag = if t.is_regression() {
            "REGRESSION"
        } else if t.is_recovery() {
            "recovered"
        } else if t.signer_changed() {
            "signer-changed"
        } else {
            "changed"
        };
        println!(
            "~ {:<12} {:<38} {} → {}  [{tag}]  {}",
            t.after.repo,
            t.artifact_id,
            if t.before.verified {
                "VERIFIED"
            } else {
                "REJECTED"
            },
            if t.after.verified {
                "VERIFIED"
            } else {
                "REJECTED"
            },
            t.after.reason,
        );
    }

    let regressions = delta.regressions().len();
    #[cfg(feature = "testmatrix")]
    modgunn::functional_status(
        "modgunn/cli-warehouse-provenance-diff",
        "provenance_ledger_diff",
        regressions == 0,
        &format!(
            "{} added, {} removed, {} changed ({regressions} regression(s))",
            delta.added.len(),
            delta.removed.len(),
            delta.changed.len(),
        ),
    );

    println!(
        "{} added, {} removed, {} changed ({regressions} regression(s)){}",
        delta.added.len(),
        delta.removed.len(),
        delta.changed.len(),
        if delta.is_unchanged() {
            " — provenance unchanged"
        } else {
            ""
        },
    );

    if fail_on_regression && regressions > 0 {
        std::process::exit(3);
    }
    Ok(())
}

/// The secure-side **provenance carry import**: open the carried
/// `<artifact>.build-provenance.znippy` blob(s), cryptographically VERIFY each seal
/// against the trusted root(s), and record the verdicts into the `build_provenance`
/// warehouse ledger — the last hop of the chain nornir's seal starts (build →
/// provenance → znippy-sealed → carry across the airgap → **modgunn verify** →
/// warehouse ledger). Verifying requires the `provenance-verify` feature.
#[cfg(feature = "provenance-verify")]
fn cmd_verify_provenance(args: &[&str]) -> Result<()> {
    use modgunn::provenance::{
        verify_sealed_provenance, ProvenanceRequirement, SEALED_PROVENANCE_SUFFIX,
    };
    use modgunn::warehouse::BuildProvenanceVerdict;

    let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
    let roots_path = flag(args, "--roots").context("--roots <ca-der-file|dir> is required")?;
    let expected_sha = flag(args, "--sha256");

    // Optional A3 provenance POLICY: --min-slsa / --trusted-signer (repeatable) /
    // --expected-repo. When ANY is given, each verified attestation is scored against
    // the requirement (`evaluate_sealed`) and the run exits non-zero if any policy
    // finding fires; with no policy flag we fall back to plain cryptographic verify.
    let min_slsa = match flag(args, "--min-slsa") {
        Some(s) => Some(s.parse::<u8>().context("--min-slsa <n> must be 0..=3")?),
        None => None,
    };
    let trusted_signers = flags_all(args, "--trusted-signer");
    let expected_repo = flag(args, "--expected-repo");
    let expected_commit = flag(args, "--expected-commit");
    let policy = if min_slsa.is_some()
        || !trusted_signers.is_empty()
        || expected_repo.is_some()
        || expected_commit.is_some()
    {
        Some(ProvenanceRequirement {
            require_min_slsa: min_slsa.unwrap_or(0),
            trusted_signers: trusted_signers.clone(),
            expected_source_repo: expected_repo.clone(),
            expected_source_sha: expected_commit.clone(),
        })
    } else {
        None
    };

    // Trusted roots: read a single DER file, or every file directly under a dir, as
    // DER-encoded CA certs the signer must chain to.
    let roots = read_der_roots(Path::new(&roots_path))?;
    if roots.is_empty() {
        bail!("--roots {roots_path} yielded no certificate files");
    }

    // The carried blob(s): a single `--blob <file>`, or every
    // `*.build-provenance.znippy` under `--blob-dir <dir>` (the carried batch).
    let blobs: Vec<std::path::PathBuf> = if let Some(b) = flag(args, "--blob") {
        vec![std::path::PathBuf::from(b)]
    } else if let Some(dir) = flag(args, "--blob-dir") {
        let mut out = Vec::new();
        for entry in std::fs::read_dir(&dir).with_context(|| format!("read_dir {dir}"))? {
            let p = entry?.path();
            if p.file_name()
                .and_then(|n| n.to_str())
                .is_some_and(|n| n.ends_with(SEALED_PROVENANCE_SUFFIX))
            {
                out.push(p);
            }
        }
        out.sort();
        out
    } else {
        bail!("provide --blob <x.build-provenance.znippy> or --blob-dir <dir>");
    };
    if blobs.is_empty() {
        bail!("no `{SEALED_PROVENANCE_SUFFIX}` blobs found to verify");
    }
    // A pinned content hash only makes sense for a single blob.
    if expected_sha.is_some() && blobs.len() > 1 {
        bail!("--sha256 pins one artifact; use it with a single --blob, not --blob-dir");
    }

    let mut verdicts = Vec::with_capacity(blobs.len());
    let mut n_ok = 0usize;
    let mut policy_findings = 0usize;
    for blob in &blobs {
        // In policy mode, `evaluate_sealed` verifies AND scores; otherwise plain verify.
        let v = match &policy {
            Some(req) => {
                let (_prov, findings, v) =
                    req.evaluate_sealed(blob, &roots, expected_sha.as_deref());
                for f in &findings {
                    policy_findings += 1;
                    println!(
                        "  policy {:<8} {} — {}",
                        f.severity.as_str(),
                        f.id,
                        f.summary
                    );
                }
                v
            }
            None => verify_sealed_provenance(blob, &roots, expected_sha.as_deref()),
        };
        if v.verified {
            n_ok += 1;
        }
        println!(
            "{:<9} {}  {}",
            if v.verified { "VERIFIED" } else { "REJECTED" },
            blob.display(),
            v.reason,
        );
        verdicts.push(BuildProvenanceVerdict::from_verification(&v));
    }

    // Record every verdict (verified or not) into the append-only ledger.
    let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
    wh.ensure_modgunn_tables()?;
    wh.append_build_provenance(&verdicts)?;

    let policy_clean = policy_findings == 0;
    #[cfg(feature = "testmatrix")]
    modgunn::functional_status(
        "modgunn/cli-warehouse-verify-provenance",
        "provenance_verified",
        n_ok == verdicts.len() && policy_clean,
        &format!(
            "{n_ok}/{} blob(s) verified, {policy_findings} policy finding(s)",
            verdicts.len()
        ),
    );

    if policy.is_some() {
        println!(
            "{n_ok}/{} blob(s) verified; {policy_findings} policy finding(s); {} verdict(s) recorded into build_provenance",
            verdicts.len(),
            verdicts.len(),
        );
        // A provenance-policy violation is a gate failure — exit non-zero so a caller
        // (CI / promotion) can block on it, distinct from a usage error (exit 2).
        if !policy_clean {
            std::process::exit(3);
        }
    } else {
        println!(
            "{n_ok}/{} blob(s) verified; {} verdict(s) recorded into build_provenance",
            verdicts.len(),
            verdicts.len(),
        );
    }
    Ok(())
}

/// Read DER-encoded trusted CA cert(s): a single file → one root; a directory →
/// every file directly under it. Bytes are handed straight to the verifier
/// (`CertStore::from_der_certs`), so each file must be a DER cert.
#[cfg(feature = "provenance-verify")]
fn read_der_roots(path: &Path) -> Result<Vec<Vec<u8>>> {
    let meta =
        std::fs::metadata(path).with_context(|| format!("stat --roots {}", path.display()))?;
    let mut out = Vec::new();
    if meta.is_dir() {
        let mut files: Vec<std::path::PathBuf> = std::fs::read_dir(path)?
            .filter_map(|e| e.ok().map(|e| e.path()))
            .filter(|p| p.is_file())
            .collect();
        files.sort();
        for f in files {
            out.push(std::fs::read(&f).with_context(|| format!("read root {}", f.display()))?);
        }
    } else {
        out.push(std::fs::read(path).with_context(|| format!("read root {}", path.display()))?);
    }
    Ok(out)
}

/// Without the `provenance-verify` feature the seal reader/verifier is not compiled
/// in — tell the operator how to enable it rather than silently accepting.
#[cfg(not(feature = "provenance-verify"))]
fn cmd_verify_provenance(_args: &[&str]) -> Result<()> {
    bail!(
        "verify-provenance needs the `provenance-verify` feature — rebuild with \
         `cargo run --features warehouse,provenance-verify -- warehouse verify-provenance ...`"
    );
}