fp-conformance 0.4.0

Differential conformance harness for frankenpandas against a live pandas oracle — packet fixtures + fuzz seed tests + live-oracle parity gates.
#![forbid(unsafe_code)]

use std::{fs, path::PathBuf, process::ExitCode};

use fp_conformance::{
    CiForensicsReport, CiGate, CiPipelineConfig, HarnessConfig, build_ci_forensics_report,
    run_ci_pipeline,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PipelineKind {
    Commit,
    Full,
}

#[derive(Debug)]
struct CliArgs {
    pipeline: PipelineKind,
    gate: Option<CiGate>,
    fail_fast: bool,
    verify_sidecars: bool,
    json_out: Option<PathBuf>,
    python_bin: Option<String>,
    allow_system_pandas_fallback: bool,
    allow_fixture_fallback: bool,
}

fn main() -> ExitCode {
    match run() {
        Ok(true) => ExitCode::SUCCESS,
        Ok(false) => ExitCode::from(1),
        Err(error) => {
            eprintln!("fp-ci-gates error: {error}");
            ExitCode::from(2)
        }
    }
}

fn run() -> Result<bool, Box<dyn std::error::Error>> {
    let args = parse_args()?;

    let mut harness = HarnessConfig::default_paths();
    harness.allow_system_pandas_fallback = args.allow_system_pandas_fallback;
    harness.allow_fixture_fallback = args.allow_fixture_fallback;
    if let Some(python_bin) = args.python_bin {
        harness.python_bin = python_bin;
    }

    let gates = if let Some(gate) = args.gate {
        vec![gate]
    } else {
        match args.pipeline {
            PipelineKind::Commit => CiGate::commit_pipeline(),
            PipelineKind::Full => CiGate::pipeline(),
        }
    };

    let config = CiPipelineConfig {
        gates,
        fail_fast: args.fail_fast,
        harness_config: harness,
        verify_sidecars: args.verify_sidecars,
    };

    let result = run_ci_pipeline(&config);
    let forensics = build_ci_forensics_report(&result);

    println!("{result}");
    print_violation_summary(&forensics);

    if let Some(path) = args.json_out {
        write_json_report(&path, &forensics)?;
    }

    Ok(result.all_passed)
}

fn print_violation_summary(report: &CiForensicsReport) {
    if report.violations.is_empty() {
        return;
    }

    eprintln!("CI forensic violations:");
    for violation in &report.violations {
        eprintln!(
            "  {} ({}) failed: {}",
            violation.rule_id, violation.label, violation.summary
        );
        if !violation.errors.is_empty() {
            for error in &violation.errors {
                eprintln!("    - {error}");
            }
        }
        eprintln!("    repro_cmd: {}", violation.repro_cmd);
    }
}

fn write_json_report(
    path: &PathBuf,
    report: &CiForensicsReport,
) -> Result<(), Box<dyn std::error::Error>> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    let artifact = serde_json::json!({
        "bead_id": "bd-2gi.10",
        "artifact_path": path.display().to_string(),
        "report": report,
    });
    fs::write(path, serde_json::to_string_pretty(&artifact)?)?;
    println!("wrote ci_gate_forensics={}", path.display());
    Ok(())
}

fn parse_args() -> Result<CliArgs, Box<dyn std::error::Error>> {
    parse_args_from(std::env::args().skip(1))
}

fn parse_args_from<I>(args: I) -> Result<CliArgs, Box<dyn std::error::Error>>
where
    I: IntoIterator<Item = String>,
{
    let mut pipeline = PipelineKind::Full;
    let mut gate = None;
    let mut fail_fast = true;
    let mut verify_sidecars = true;
    let mut json_out = None;
    let mut python_bin = None;
    let mut allow_system_pandas_fallback = false;
    let mut allow_fixture_fallback = false;

    let mut args = args.into_iter().peekable();
    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--pipeline" => {
                let value = args.next().ok_or("--pipeline requires commit|full")?;
                pipeline = match value.as_str() {
                    "commit" => PipelineKind::Commit,
                    "full" => PipelineKind::Full,
                    _ => return Err(format!("unsupported pipeline: {value}").into()),
                };
            }
            "--gate" => {
                let value = args.next().ok_or("--gate requires a gate id")?;
                gate = Some(parse_gate(&value)?);
            }
            "--json-out" => {
                let value = args.next().ok_or("--json-out requires a file path")?;
                json_out = Some(PathBuf::from(value));
            }
            "--python-bin" => {
                let value = args.next().ok_or("--python-bin requires a path")?;
                python_bin = Some(value);
            }
            "--no-fail-fast" => {
                fail_fast = false;
            }
            "--no-verify-sidecars" => {
                verify_sidecars = false;
            }
            "--allow-system-pandas-fallback" => {
                allow_system_pandas_fallback = true;
            }
            "--allow-fixture-fallback" => {
                allow_fixture_fallback = true;
            }
            "--help" | "-h" => {
                print_help();
                std::process::exit(0);
            }
            other => return Err(format!("unknown argument: {other}").into()),
        }
    }

    Ok(CliArgs {
        pipeline,
        gate,
        fail_fast,
        verify_sidecars,
        json_out,
        python_bin,
        allow_system_pandas_fallback,
        allow_fixture_fallback,
    })
}

fn parse_gate(value: &str) -> Result<CiGate, Box<dyn std::error::Error>> {
    let gate = match value {
        "G1" | "G1Compile" => CiGate::G1Compile,
        "G2" | "G2Lint" => CiGate::G2Lint,
        "G3" | "G3Unit" => CiGate::G3Unit,
        "G4" | "G4Property" => CiGate::G4Property,
        "G4.5" | "G4_5Fuzz" => CiGate::G4_5Fuzz,
        "G5" | "G5Integration" => CiGate::G5Integration,
        "G6" | "G6Conformance" => CiGate::G6Conformance,
        "G7" | "G7Coverage" => CiGate::G7Coverage,
        "G8" | "G8E2e" => CiGate::G8E2e,
        _ => return Err(format!("unsupported gate: {value}").into()),
    };
    Ok(gate)
}

fn print_help() {
    println!(
        "fp-ci-gates\n\
         Usage:\n\
         \tfp-ci-gates [--pipeline commit|full] [--gate G6] [--json-out artifacts/ci/gate_forensics.json]\n\
         Options:\n\
         \t--pipeline <kind>   commit or full (default: full)\n\
         \t--gate <id>         run a single gate (G1..G8, e.g. G6 or G8E2e)\n\
         \t--json-out <path>   write machine-readable forensic report (JSON)\n\
         \t--python-bin <path> Python executable for live oracle runs (default: FP_PYTHON_BIN or python3)\n\
         \t--no-fail-fast      continue evaluating all configured gates after failures\n\
         \t--no-verify-sidecars  skip sidecar integrity check after gate success\n\
         \t--allow-system-pandas-fallback  allow non-legacy pandas import in live mode\n\
         \t--allow-fixture-fallback  permit fixture expectations after a live oracle outage\n\
         \t-h, --help          show this help"
    );
}

#[cfg(test)]
mod tests {
    use super::{PipelineKind, parse_args_from};

    #[test]
    fn parse_args_accepts_python_bin_override() {
        let args = vec![
            "--pipeline".to_owned(),
            "commit".to_owned(),
            "--python-bin".to_owned(),
            "python3.12".to_owned(),
        ];

        let parsed = parse_args_from(args).expect("expected parse success");
        assert!(matches!(parsed.pipeline, PipelineKind::Commit));
        assert_eq!(parsed.python_bin.as_deref(), Some("python3.12"));
    }

    #[test]
    fn parse_args_rejects_missing_python_bin_value() {
        let args = vec!["--python-bin".to_owned()];
        let err = parse_args_from(args).expect_err("expected parse failure");
        assert!(
            err.to_string().contains("--python-bin requires a path"),
            "unexpected error: {err}"
        );
    }
}