spec-drift 0.1.0

Semantic coherence analysis between specification and implementation for Rust projects.
Documentation
use clap::Parser;
use spec_drift::analyzers::{
    CiAnalyzer, ConstraintAnalyzer, DeprecatedUsageAnalyzer, DocsAnalyzer, DriftAnalyzer,
    EnvMismatchAnalyzer, ExamplesAnalyzer, LogicGapAnalyzer, MissingCoverageAnalyzer,
    OutdatedLogicAnalyzer, TestsAnalyzer,
};
use spec_drift::baseline;
use spec_drift::config::Config;
use spec_drift::context::ProjectContext;
use spec_drift::domain::Severity;
use spec_drift::parsers::RustParser;
use spec_drift::reporters::{
    FixPromptReporter, HumanReporter, JsonReporter, Reporter, SarifReporter,
};
use spec_drift::sources::{FsWalker, GitHistory};
use spec_drift::suppress;
use spec_drift::workspace;
use std::path::PathBuf;
use std::process::ExitCode;

#[derive(Parser, Debug)]
#[command(
    name = "spec-drift",
    version,
    about = "Semantic coherence analysis between specification and implementation."
)]
struct Cli {
    /// Project root (defaults to the current directory).
    #[arg(long, default_value = ".")]
    root: PathBuf,

    /// Output format.
    #[arg(long, value_parser = ["human", "json", "sarif"], default_value = "human")]
    format: String,

    /// Emit a structured correction prompt instead of a report. Overrides --format.
    #[arg(long)]
    fix_prompt: bool,

    /// Path to `spec-drift.toml`. Defaults to walking up from `--root`.
    #[arg(long)]
    config: Option<PathBuf>,

    /// Baseline JSON file. Divergences present in the baseline are not re-reported.
    #[arg(long)]
    baseline: Option<PathBuf>,

    /// Only analyze files changed since this git ref (e.g. `HEAD`, `origin/main`).
    #[arg(long)]
    diff: Option<String>,

    /// In a cargo workspace, restrict analysis to the named member.
    #[arg(long)]
    package: Option<String>,

    /// Run only the docs pillar.
    #[arg(long, conflicts_with_all = ["examples", "tests", "ci"])]
    docs: bool,

    /// Run only the examples pillar.
    #[arg(long, conflicts_with_all = ["docs", "tests", "ci"])]
    examples: bool,

    /// Run only the tests pillar.
    #[arg(long, conflicts_with_all = ["docs", "examples", "ci"])]
    tests: bool,

    /// Run only the CI pillar.
    #[arg(long, conflicts_with_all = ["docs", "examples", "tests"])]
    ci: bool,

    /// Exit non-zero only when a divergence at or above this severity exists.
    #[arg(long, value_parser = ["notice", "warning", "critical"], default_value = "notice")]
    deny: String,

    /// Promote every non-deterministic rule by one severity level.
    #[arg(long)]
    strict: bool,

    /// Disable every LLM-backed rule, regardless of `[llm]` config.
    #[arg(long)]
    no_llm: bool,

    /// Attribute each divergence to the commit/author that wrote the source line.
    /// Spawns one `git blame` per divergence; off by default.
    #[arg(long)]
    blame: bool,
}

fn main() -> ExitCode {
    let cli = Cli::parse();
    match run(cli) {
        Ok(exit) => exit,
        Err(e) => {
            eprintln!("spec-drift: {e}");
            ExitCode::from(2)
        }
    }
}

fn run(cli: Cli) -> anyhow::Result<ExitCode> {
    let root = cli.root.canonicalize()?;

    let config_path = cli
        .config
        .clone()
        .or_else(|| Config::discover(&root))
        .unwrap_or_else(|| root.join("spec-drift.toml"));
    let config = Config::load(&config_path)?;

    let mut files = FsWalker::walk(&root)?;
    if let Some(reference) = cli.diff.as_deref() {
        match GitHistory::changed_files(&root, reference) {
            Some(changed) => {
                files = GitHistory::narrow(files, &changed);
            }
            None => {
                eprintln!(
                    "spec-drift: --diff {reference}: git unavailable or ref unknown; \
                     scanning full tree."
                );
            }
        }
    }

    if let Some(name) = cli.package.as_deref() {
        let packages = workspace::load(&root);
        let pkg = workspace::find(&packages, name).map_err(anyhow::Error::msg)?;
        files.rust = workspace::narrow_paths(files.rust, pkg);
        files.markdown = workspace::narrow_paths(files.markdown, pkg);
        files.yaml = workspace::narrow_paths(files.yaml, pkg);
        files.makefiles = workspace::narrow_paths(files.makefiles, pkg);
    }

    let mut ctx = ProjectContext::new(&root);
    ctx.rust_files = files.rust;
    ctx.markdown_files = files.markdown;
    ctx.yaml_files = files.yaml;
    ctx.makefile_files = files.makefiles;

    for rs in &ctx.rust_files {
        match RustParser::parse(rs) {
            Ok(facts) => ctx.code_facts.extend(facts),
            Err(e) => eprintln!("spec-drift: skipping {}: {e}", rs.display()),
        }
    }

    let analyzers = select_analyzers(&cli, &config);
    let mut divergences = spec_drift::run(&ctx, &analyzers);
    divergences = spec_drift::apply_config(divergences, &config, &root);
    divergences = suppress::apply_inline_ignores(divergences);

    if let Some(baseline_path) = cli.baseline.as_ref() {
        let baseline = baseline::load(baseline_path)?;
        divergences = baseline::subtract(divergences, &baseline);
    }

    if cli.strict {
        spec_drift::apply_strict(&mut divergences);
    }

    if cli.blame {
        spec_drift::blame::apply(
            &mut divergences,
            &root,
            &spec_drift::blame::GitBlameEngine,
        );
    }

    let rendered = if cli.fix_prompt {
        FixPromptReporter.render(&divergences)
    } else {
        match cli.format.as_str() {
            "json" => JsonReporter.render(&divergences),
            "sarif" => SarifReporter.render(&divergences),
            _ => HumanReporter.render(&divergences),
        }
    };
    print!("{rendered}");

    let threshold = parse_severity(&cli.deny);
    let has_blocking = divergences.iter().any(|d| d.severity >= threshold);
    if has_blocking {
        Ok(ExitCode::from(1))
    } else {
        Ok(ExitCode::SUCCESS)
    }
}

fn parse_severity(s: &str) -> Severity {
    match s {
        "critical" => Severity::Critical,
        "warning" => Severity::Warning,
        _ => Severity::Notice,
    }
}

fn select_analyzers(cli: &Cli, config: &Config) -> Vec<Box<dyn DriftAnalyzer>> {
    let any_specific = cli.docs || cli.examples || cli.tests || cli.ci;

    // Build the LLM client once and share it. `--no-llm` wins over config.
    let llm_client = spec_drift::llm::build_client(&config.llm, cli.no_llm);

    let mut v: Vec<Box<dyn DriftAnalyzer>> = Vec::new();
    if !any_specific || cli.docs {
        v.push(Box::new(DocsAnalyzer::default()));
        v.push(Box::new(MissingCoverageAnalyzer));
        if !config.constraint_rules.is_empty() {
            v.push(Box::new(ConstraintAnalyzer::new(
                config.constraint_rules.clone(),
            )));
        }
        v.push(Box::new(OutdatedLogicAnalyzer::new(llm_client.clone())));
    }
    if !any_specific || cli.examples {
        v.push(Box::new(ExamplesAnalyzer::default()));
        v.push(Box::new(DeprecatedUsageAnalyzer::default()));
        v.push(Box::new(LogicGapAnalyzer::new(llm_client.clone())));
    }
    if !any_specific || cli.tests {
        v.push(Box::new(TestsAnalyzer));
    }
    if !any_specific || cli.ci {
        v.push(Box::new(CiAnalyzer::default()));
        v.push(Box::new(EnvMismatchAnalyzer));
    }
    v
}