Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,10 @@ Generates mutants for the target code and optionally persists them to a SQLite d

| Flag | Short | Default | Description |
|------|-------|---------|-------------|
| `--project NAME` | | `bitcoin-core` | Project to mutate. Accepts `bitcoin-core` or `secp256k1`. When `--pr` is used, the PR is fetched from this project's repository. |
| `--sqlite [PATH]` | | `mutation.db` | Persist mutants to a SQLite database. Accepts an optional custom path. |
| `--file PATH` | `-f` | | File to mutate. Mutually exclusive with `--pr`. |
| `--pr NUMBER` | `-p` | `0` (current branch) | Bitcoin Core PR number to mutate. Mutually exclusive with `--file`. |
| `--pr NUMBER` | `-p` | `0` (current branch) | PR number to mutate (fetched from the `--project` repository). Mutually exclusive with `--file`. |
| `--range START END` | `-r` | | Restrict mutation to a line range within the target file. Cannot be combined with `--cov`. |
| `--cov PATH` | `-c` | | Path to a coverage file (`*.info` generated with `cmake -P build/Coverage.cmake`). Only lines covered by tests will be mutated. Cannot be combined with `--range`. |
| `--skip-lines PATH` | | | Path to a JSON file listing lines to skip per file (see format below). |
Expand All @@ -74,6 +75,11 @@ bcore-mutation mutate --sqlite -f src/wallet/wallet.cpp
bcore-mutation mutate --sqlite -p 12345
```

**Mutate a secp256k1 PR (fetched from `bitcoin-core/secp256k1`):**
```bash
bcore-mutation mutate --sqlite --project secp256k1 -p 1234
```

**Restrict to a line range:**
```bash
bcore-mutation mutate --sqlite -f src/wallet/wallet.cpp --range 10 50
Expand Down Expand Up @@ -127,6 +133,7 @@ When `--sqlite` is used, the `mutate` command prints a `run_id` that you pass to

| Flag | Short | Default | Description |
|------|-------|---------|-------------|
| `--project NAME` | | `bitcoin-core` | Project being analyzed. Accepts `bitcoin-core` or `secp256k1`. |
| `--sqlite [PATH]` | | `mutation.db` | SQLite database to read mutants from. Requires `--run-id`. Accepts an optional custom path. |
| `--run-id ID` | | | Run ID returned by the `mutate` command. Requires `--sqlite`. |
| `--command CMD` | `-c` | | Shell command used to test each mutant (e.g. a build + test invocation). Required when using `--run-id`. |
Expand All @@ -135,6 +142,7 @@ When `--sqlite` is used, the `mutate` command prints a `run_id` that you pass to
| `--timeout SECONDS` | `-t` | `300` | Timeout in seconds for each mutant's test run. |
| `--jobs N` | `-j` | `0` | Number of parallel jobs passed to the compiler (e.g. `make -j N`). `0` uses the system default. |
| `--survival-threshold RATE` | | `0.75` | Maximum acceptable mutant survival rate (e.g. `0.3` = 30%). The run exits with an error if the threshold is exceeded. |
| `--min-score RATE` | | | CI gate: fail with a non-zero exit code if the final mutation score (killed / total) is below this value (e.g. `0.8` = 80%). Aggregated across all analyzed folders. When unset, the score is not enforced. |
| `--surviving` | | | Only analyze mutants that survived a previous run. Requires `--run-id`. |

### Examples
Expand All @@ -159,6 +167,16 @@ bcore-mutation analyze --sqlite --run-id=1 --surviving \
-c "cmake --build build && ./build/test/functional/wallet_test.py"
```

**Fail CI when the mutation score drops below 80% (folder mode, no database):**
```bash
# 1. Generate mutants for the PR — writes muts-* folders to disk
bcore-mutation mutate --project secp256k1 --pr 1234

# 2. Analyze them and fail the job if the score is under 80%.
# With no --command, the built-in secp256k1 build/test commands are used.
bcore-mutation analyze --project secp256k1 --min-score 0.8
```

**Set a custom timeout and job count:**
```bash
bcore-mutation analyze --sqlite --run-id=1 -t 120 -j 8 \
Expand Down
187 changes: 122 additions & 65 deletions src/analyze.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use crate::commands::{self, ProjectCommands};
use crate::db::Database;
use crate::error::{MutationError, Result};
use crate::project::Project;
use crate::report::generate_report;
use std::fs;
use std::path::{Path, PathBuf};
Expand All @@ -9,17 +11,76 @@ use tokio::process::Command as TokioCommand;
use tokio::time::timeout;
use walkdir::WalkDir;

/// Killed/total counts produced by an analysis pass. Used to compute the
/// mutation score and apply the optional `--min-score` CI gate.
#[derive(Default, Clone, Copy)]
pub struct ScoreSummary {
pub killed: u64,
pub total: u64,
}

impl ScoreSummary {
/// Mutation score as a fraction in `[0.0, 1.0]` (killed / total).
/// Returns `0.0` when no mutants were analyzed.
pub fn score(&self) -> f64 {
if self.total == 0 {
0.0
} else {
self.killed as f64 / self.total as f64
}
}

/// Accumulate another pass's counts (used to aggregate across folders).
fn add(&mut self, other: ScoreSummary) {
self.killed += other.killed;
self.total += other.total;
}
}

/// Fail (return an error) when `min_score` is set and the achieved mutation
/// score is below it. This is the CI gate: an `Err` here propagates out of
/// `main` and exits the process with a non-zero status.
fn enforce_min_score(summary: ScoreSummary, min_score: Option<f64>) -> Result<()> {
let Some(min) = min_score else {
return Ok(());
};

let score = summary.score();
println!(
"\nOverall mutation score: {:.2}% ({}/{} killed); required minimum: {:.2}%",
score * 100.0,
summary.killed,
summary.total,
min * 100.0
);

if score < min {
return Err(MutationError::ScoreBelowThreshold(format!(
"mutation score {:.2}% is below the required minimum of {:.2}%",
score * 100.0,
min * 100.0
)));
}

println!("Mutation score meets the required minimum ✅");
Ok(())
}

pub async fn run_analysis(
project: Project,
folder: Option<PathBuf>,
command: Option<String>,
jobs: u32,
timeout_secs: u64,
survival_threshold: f64,
min_score: Option<f64>,
sqlite_path: Option<PathBuf>,
run_id: Option<i64>,
file_path: Option<String>,
survivors_only: bool,
) -> Result<()> {
println!("Analyzing mutants for project: {}", project.db_name());

// DB-based analysis mode: read mutants from DB and test them.
if let (Some(ref path), Some(rid)) = (sqlite_path.as_ref(), run_id) {
let command = command.ok_or_else(|| {
Expand All @@ -30,15 +91,16 @@ pub async fn run_analysis(
let db = Database::open(path)?;
db.ensure_schema()?;
db.seed_projects()?;
return run_db_analysis(
let summary = run_db_analysis(
&db,
rid,
&command,
timeout_secs,
file_path.as_deref(),
survivors_only,
)
.await;
.await?;
return enforce_min_score(summary, min_score);
}

// Folder-based analysis mode (existing behaviour).
Expand All @@ -49,18 +111,30 @@ pub async fn run_analysis(
find_mutation_folders()?
};

let project_commands = commands::for_project(project);

// When we derive the test command ourselves (no --command), do the one-time
// clean build up front rather than once per folder. Each mutant still
// triggers an incremental rebuild inside its test command.
if command.is_none() && !folders.is_empty() {
run_build_command(project_commands.as_ref()).await?;
}

let mut overall = ScoreSummary::default();
for folder_path in folders {
analyze_folder(
let summary = analyze_folder(
&folder_path,
command.clone(),
jobs,
timeout_secs,
survival_threshold,
project_commands.as_ref(),
)
.await?;
overall.add(summary);
}

Ok(())
enforce_min_score(overall, min_score)
}

/// Test all pending mutants in `run_id` from the database, optionally filtered by `file_path`.
Expand All @@ -72,7 +146,7 @@ async fn run_db_analysis(
timeout_secs: u64,
file_path: Option<&str>,
survivors_only: bool,
) -> Result<()> {
) -> Result<ScoreSummary> {
let mutants = db.get_mutants_for_run(run_id, file_path, survivors_only)?;
let total = mutants.len();

Expand Down Expand Up @@ -155,7 +229,10 @@ async fn run_db_analysis(
);
println!("Survived: {}", num_survived);

Ok(())
Ok(ScoreSummary {
killed: num_killed,
total: total as u64,
})
}

/// Apply a unified diff patch using `git apply`.
Expand Down Expand Up @@ -208,7 +285,8 @@ pub async fn analyze_folder(
jobs: u32,
timeout_secs: u64,
survival_threshold: f64,
) -> Result<()> {
project_commands: &dyn ProjectCommands,
) -> Result<ScoreSummary> {
let mut num_killed: u64 = 0;
let mut not_killed = Vec::new();

Expand All @@ -217,12 +295,13 @@ pub async fn analyze_folder(
let target_file_path = fs::read_to_string(original_file_path)?;
let target_file_path = target_file_path.trim();

// Setup command if not provided
// Derive the test command when one isn't provided. The clean build is done
// once by the caller (run_analysis); here we only build the per-file test
// command, whose incremental `cmake --build` picks up each mutant.
let test_command = if let Some(cmd) = command {
cmd
} else {
run_build_command().await?;
get_command_to_kill(&target_file_path, jobs)?
project_commands.test_command(&target_file_path, jobs)?
};

// Get list of mutant files
Expand Down Expand Up @@ -297,7 +376,10 @@ pub async fn analyze_folder(
// Restore the original file
restore_file(&target_file_path).await?;

Ok(())
Ok(ScoreSummary {
killed: num_killed,
total: total_mutants as u64,
})
}

async fn run_command(command: &str, timeout_secs: u64) -> Result<bool> {
Expand Down Expand Up @@ -349,51 +431,17 @@ async fn run_command(command: &str, timeout_secs: u64) -> Result<bool> {
}
}

async fn run_build_command() -> Result<()> {
let build_command =
"rm -rf build && cmake -B build -DENABLE_IPC=OFF && cmake --build build -j $(nproc)";
async fn run_build_command(project_commands: &dyn ProjectCommands) -> Result<()> {
let build_command = project_commands.build_command();

let success = run_command(build_command, 3600).await?; // 1 hour timeout for build
let success = run_command(&build_command, project_commands.build_timeout_secs()).await?;
if !success {
return Err(MutationError::Command("Build command failed".to_string()));
}

Ok(())
}

fn get_command_to_kill(target_file_path: &str, jobs: u32) -> Result<String> {
let mut build_command = "cmake --build build".to_string();
if jobs > 0 {
build_command.push_str(&format!(" -j{}", jobs));
}

let command = if target_file_path.contains("functional") {
format!("./build/{}", target_file_path)
} else if target_file_path.contains("test") {
let filename_with_extension = Path::new(target_file_path)
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| MutationError::InvalidInput("Invalid file path".to_string()))?;

let test_to_run = filename_with_extension
.rsplit('.')
.nth(1)
.ok_or_else(|| MutationError::InvalidInput("Cannot extract test name".to_string()))?;

format!(
"{} && ./build/bin/test_bitcoin --run_test={}",
build_command, test_to_run
)
} else {
format!(
"{} && ctest --output-on-failure --stop-on-failure -C Release && CI_FAILFAST_TEST_LEAVE_DANGLING=1 ./build/test/functional/test_runner.py -F",
build_command
)
};

Ok(command)
}

async fn restore_file(target_file_path: &str) -> Result<()> {
let restore_command = format!("git restore {}", target_file_path);
let success = run_command(&restore_command, 30).await?;
Expand All @@ -413,23 +461,32 @@ mod tests {
use tempfile::tempdir;

#[test]
fn test_get_command_to_kill() {
// Test functional test
let cmd = get_command_to_kill("test/functional/test_example.py", 4).unwrap();
assert_eq!(cmd, "./build/test/functional/test_example.py");

// Test unit test
let cmd = get_command_to_kill("src/test/test_example.cpp", 0).unwrap();
assert_eq!(
cmd,
"cmake --build build && ./build/bin/test_bitcoin --run_test=test_example"
);

// Test general case
let cmd = get_command_to_kill("src/wallet/wallet.cpp", 2).unwrap();
assert!(cmd.contains("cmake --build build -j2"));
assert!(cmd.contains("ctest"));
assert!(cmd.contains("test_runner.py"));
fn test_enforce_min_score() {
// No gate configured: always Ok regardless of score.
assert!(enforce_min_score(ScoreSummary { killed: 0, total: 10 }, None).is_ok());

// Above threshold passes.
assert!(enforce_min_score(ScoreSummary { killed: 9, total: 10 }, Some(0.8)).is_ok());

// Exactly at threshold passes (gate uses `<`, so >= passes).
assert!(enforce_min_score(ScoreSummary { killed: 8, total: 10 }, Some(0.8)).is_ok());

// Below threshold fails with the dedicated error variant.
let result = enforce_min_score(ScoreSummary { killed: 5, total: 10 }, Some(0.8));
assert!(matches!(
result,
Err(MutationError::ScoreBelowThreshold(_))
));
}

#[test]
fn test_score_summary_aggregates() {
let mut overall = ScoreSummary::default();
overall.add(ScoreSummary { killed: 3, total: 4 });
overall.add(ScoreSummary { killed: 1, total: 6 });
assert_eq!(overall.killed, 4);
assert_eq!(overall.total, 10);
assert!((overall.score() - 0.4).abs() < f64::EPSILON);
}

#[tokio::test]
Expand Down
Loading
Loading