diff --git a/.github/workflows/build-tools.yml b/.github/workflows/build-tools.yml index 8f4048e3..773390c8 100644 --- a/.github/workflows/build-tools.yml +++ b/.github/workflows/build-tools.yml @@ -34,6 +34,12 @@ jobs: path: crates/synapse-cli ext: .exe + # Atomicity Checker (Linux) + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + tool: atomicity-checker + path: tools/atomicity-checker + steps: - uses: actions/checkout@v4 @@ -75,6 +81,7 @@ jobs: # Linux cp downloaded-artifacts/synapse-x86_64-unknown-linux-gnu/synapse bin/synapse-linux + cp downloaded-artifacts/atomicity-checker-x86_64-unknown-linux-gnu/atomicity-checker bin/atomicity-checker-linux # Windows cp downloaded-artifacts/synapse-x86_64-pc-windows-msvc/synapse.exe bin/synapse.exe diff --git a/.github/workflows/commit-atomicity.yml b/.github/workflows/commit-atomicity.yml new file mode 100644 index 00000000..521053b6 --- /dev/null +++ b/.github/workflows/commit-atomicity.yml @@ -0,0 +1,36 @@ +# 🔍 Commit Atomicity - Ensures commits follow the atomic commit principle +# Part of Git-Core Protocol + +name: 🔍 Commit Atomicity + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + check-atomicity: + name: Check Atomicity + runs-on: ubuntu-latest + + steps: + - name: 📋 Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history needed for commit analysis + + - name: 🦀 Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: 🏗️ Build Atomicity Checker + run: cargo build --release -p atomicity-checker + + - name: 🔍 Run Atomicity Check + run: | + ./target/release/atomicity-checker \ + --range "origin/${{ github.base_ref }}..HEAD" \ + --config ".github/atomicity-config.yml" diff --git a/Cargo.toml b/Cargo.toml index 2eaec342..a27d046d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ "crates/synapse-immune", "apps/desktop/src-tauri", "crates/synapse-cognition", - ".github/actions/structure-validator", + ".github/actions/structure-validator", "tools/atomicity-checker", ] # Ensure Cargo doesn't treat this crate as an implicit workspace member. diff --git a/tools/atomicity-checker/Cargo.toml b/tools/atomicity-checker/Cargo.toml new file mode 100644 index 00000000..10b8b77e --- /dev/null +++ b/tools/atomicity-checker/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "atomicity-checker" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description.workspace = true + +[dependencies] +clap = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = "0.9" +regex = { workspace = true } +anyhow = { workspace = true } +colored = "2" +git2 = "0.19" +globset = "0.4" +chrono = { workspace = true } diff --git a/tools/atomicity-checker/src/checker.rs b/tools/atomicity-checker/src/checker.rs new file mode 100644 index 00000000..f399a945 --- /dev/null +++ b/tools/atomicity-checker/src/checker.rs @@ -0,0 +1,141 @@ +use crate::config::Config; +use crate::models::{CommitInfo, FileInfo}; +use globset::{Glob, GlobSet, GlobSetBuilder}; +use anyhow::Result; +use std::collections::HashSet; +use regex::Regex; + +pub struct Checker { + config: Config, + concern_globs: Vec<(String, GlobSet)>, + exclude_glob: GlobSet, + bot_regexes: Vec, +} + +impl Checker { + pub fn new(config: Config) -> Result { + let mut concern_globs = Vec::new(); + for (concern, patterns) in &config.concern_patterns { + let mut builder = GlobSetBuilder::new(); + for pattern in patterns { + builder.add(Glob::new(pattern)?); + } + concern_globs.push((concern.clone(), builder.build()?)); + } + + let mut exclude_builder = GlobSetBuilder::new(); + for pattern in &config.exclude_patterns { + exclude_builder.add(Glob::new(pattern)?); + } + let exclude_glob = exclude_builder.build()?; + + let mut bot_regexes = Vec::new(); + for pattern in &config.bot_patterns { + bot_regexes.push(Regex::new(pattern)?); + } + + Ok(Checker { + config, + concern_globs, + exclude_glob, + bot_regexes, + }) + } + + pub fn is_bot(&self, author: &str) -> bool { + if !self.config.ignore_bots { + return false; + } + for re in &self.bot_regexes { + if re.is_match(author) { + return true; + } + } + false + } + + pub fn categorize_file(&self, path: &str) -> String { + for (concern, globset) in &self.concern_globs { + if globset.is_match(path) { + return concern.clone(); + } + } + + // Fallback categorization logic if not matched by config + if path.contains("test") || path.contains("spec") { + return "tests".to_string(); + } + if path.ends_with(".md") { + return "docs".to_string(); + } + + "other".to_string() + } + + pub fn is_excluded(&self, path: &str) -> bool { + self.exclude_glob.is_match(path) + } + + pub fn analyze_commit(&self, sha: String, message: String, author: String, files: Vec) -> CommitInfo { + let mut concerns = HashSet::new(); + let mut file_infos = Vec::new(); + + for file in files { + if self.is_excluded(&file) { + continue; + } + + let concern = self.categorize_file(&file); + concerns.insert(concern.clone()); + file_infos.push(FileInfo { + path: file, + concern, + }); + } + + let count = concerns.len(); + let is_atomic = count <= self.config.max_concerns; + + CommitInfo { + sha, + message, + author, + concerns: concerns.into_iter().collect(), + count, + is_atomic, + files: file_infos, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use std::collections::HashMap; + + #[test] + fn test_categorization() { + let mut concern_patterns = HashMap::new(); + concern_patterns.insert("source".to_string(), vec!["src/**/*.rs".to_string()]); + concern_patterns.insert("docs".to_string(), vec!["*.md".to_string()]); + + let config = Config { + enabled: true, + mode: "warning".to_string(), + ignore_bots: true, + bot_patterns: vec![], + max_concerns: 1, + concern_patterns, + exclude_patterns: vec!["Cargo.lock".to_string()], + }; + + let checker = Checker::new(config).unwrap(); + + assert_eq!(checker.categorize_file("src/main.rs"), "source"); + assert_eq!(checker.categorize_file("README.md"), "docs"); + assert_eq!(checker.categorize_file("other.txt"), "other"); + assert!(checker.is_excluded("Cargo.lock")); + assert!(!checker.is_excluded("src/main.rs")); + } +} diff --git a/tools/atomicity-checker/src/config.rs b/tools/atomicity-checker/src/config.rs new file mode 100644 index 00000000..a96973f2 --- /dev/null +++ b/tools/atomicity-checker/src/config.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use anyhow::Result; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Config { + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde(default = "default_mode")] + pub mode: String, + #[serde(default = "default_ignore_bots")] + pub ignore_bots: bool, + #[serde(default)] + pub bot_patterns: Vec, + #[serde(default = "default_max_concerns")] + pub max_concerns: usize, + #[serde(default)] + pub concern_patterns: HashMap>, + #[serde(default)] + pub exclude_patterns: Vec, +} + +fn default_enabled() -> bool { true } +fn default_mode() -> String { "warning".to_string() } +fn default_ignore_bots() -> bool { true } +fn default_max_concerns() -> usize { 1 } + +impl Config { + pub fn load>(path: P) -> Result { + let content = fs::read_to_string(path)?; + let config: Config = serde_yaml::from_str(&content)?; + Ok(config) + } + + pub fn default() -> Self { + Config { + enabled: true, + mode: "warning".to_string(), + ignore_bots: true, + bot_patterns: vec![], + max_concerns: 1, + concern_patterns: HashMap::new(), + exclude_patterns: vec![], + } + } +} diff --git a/tools/atomicity-checker/src/git.rs b/tools/atomicity-checker/src/git.rs new file mode 100644 index 00000000..a0392ccd --- /dev/null +++ b/tools/atomicity-checker/src/git.rs @@ -0,0 +1,61 @@ +use git2::{Repository, DiffOptions, Oid}; +use anyhow::Result; +use std::path::Path; + +pub struct GitContext { + repo: Repository, +} + +impl GitContext { + pub fn open>(path: P) -> Result { + let repo = Repository::discover(path)?; + Ok(GitContext { repo }) + } + + pub fn get_commits_in_range(&self, range: &str) -> Result> { + let mut revwalk = self.repo.revwalk()?; + revwalk.push_range(range)?; + + let mut oids = Vec::new(); + for oid in revwalk { + oids.push(oid?); + } + Ok(oids) + } + + pub fn get_changed_files(&self, commit_oid: Oid) -> Result> { + let commit = self.repo.find_commit(commit_oid)?; + let tree = commit.tree()?; + + let parent_tree = if commit.parent_count() > 0 { + Some(commit.parent(0)?.tree()?) + } else { + None + }; + + let mut opts = DiffOptions::new(); + let diff = self.repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut opts))?; + + let mut files = Vec::new(); + diff.foreach( + &mut |delta, _| { + if let Some(path) = delta.new_file().path() { + files.push(path.to_string_lossy().into_owned()); + } + true + }, + None, + None, + None, + )?; + + Ok(files) + } + + pub fn get_commit_details(&self, commit_oid: Oid) -> Result<(String, String)> { + let commit = self.repo.find_commit(commit_oid)?; + let message = commit.message().unwrap_or("").trim().to_string(); + let author = commit.author().name().unwrap_or("").to_string(); + Ok((message, author)) + } +} diff --git a/tools/atomicity-checker/src/main.rs b/tools/atomicity-checker/src/main.rs new file mode 100644 index 00000000..ce1b8fd3 --- /dev/null +++ b/tools/atomicity-checker/src/main.rs @@ -0,0 +1,218 @@ +mod config; +mod git; +mod checker; +mod models; +mod report; + +use clap::{Parser, Subcommand}; +use colored::*; +use anyhow::Result; +use crate::config::Config; +use crate::git::GitContext; +use crate::checker::Checker; +use crate::models::AnalysisResult; +use git2::Oid; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + #[command(subcommand)] + command: Option, + + /// Output as JSON + #[arg(long, global = true)] + json: bool, + + /// Output as Markdown + #[arg(long, global = true)] + markdown: bool, + + /// Path to the configuration file + #[arg(long, default_value = ".github/atomicity-config.yml", global = true)] + config: String, + + /// Git range (e.g. origin/main..HEAD) + #[arg(long, default_value = "origin/main..HEAD", global = true)] + range: String, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Check commit atomicity (default) + Check { + /// Analyze a specific commit (SHA) + #[arg(long)] + commit: Option, + + /// Mode: warning or error + #[arg(long)] + mode: Option, + + /// Maximum concerns allowed per commit + #[arg(long)] + max_concerns: Option, + }, + /// Generate a report of commit atomicity + Report { + /// Format of the report (terminal, markdown, json) + #[arg(long, default_value = "terminal")] + format: String, + }, + /// Validate the atomicity configuration file + Validate, +} + +fn main() -> Result<()> { + let args = Args::parse(); + + // Default to Check command if none provided + let command = args.command.as_ref().unwrap_or(&Commands::Check { + commit: None, + mode: None, + max_concerns: None, + }); + + match command { + Commands::Check { commit, mode, max_concerns } => { + run_check(&args, commit.clone(), mode.clone(), *max_concerns) + } + Commands::Report { format } => { + run_report(&args, format.clone()) + } + Commands::Validate => { + run_validate(&args) + } + } +} + +fn run_check(args: &Args, commit: Option, mode: Option, max_concerns: Option) -> Result<()> { + let mut config = if std::path::Path::new(&args.config).exists() { + Config::load(&args.config)? + } else { + Config::default() + }; + + if let Some(m) = mode { + config.mode = m; + } + if let Some(max) = max_concerns { + config.max_concerns = max; + } + + if !config.enabled { + if !args.json && !args.markdown { + println!("{}", "Commit atomicity check is disabled.".yellow()); + } + return Ok(()); + } + + let git = GitContext::open(".")?; + let checker = Checker::new(config.clone())?; + + let mut commits_to_analyze = Vec::new(); + + if let Some(sha) = commit { + commits_to_analyze.push(Oid::from_str(&sha)?); + } else { + match git.get_commits_in_range(&args.range) { + Ok(oids) => commits_to_analyze = oids, + Err(_) => { + if !args.json && !args.markdown { + println!("{}", "No commits found in range or range invalid. Skipping analysis.".yellow()); + } + return Ok(()); + } + } + } + + let mut analyzed_commits = Vec::new(); + let mut total_commits = 0; + let mut skipped_bots = 0; + let mut atomic_count = 0; + let mut non_atomic_count = 0; + + for oid in commits_to_analyze { + total_commits += 1; + let sha = oid.to_string(); + let (message, author) = git.get_commit_details(oid)?; + + if checker.is_bot(&author) { + skipped_bots += 1; + continue; + } + + let files = git.get_changed_files(oid)?; + let analysis = checker.analyze_commit(sha, message, author, files); + + if analysis.is_atomic { + atomic_count += 1; + } else { + non_atomic_count += 1; + } + + analyzed_commits.push(analysis); + } + + let has_issues = non_atomic_count > 0; + let status = if has_issues { "warning" } else { "ok" }; + + let result = AnalysisResult { + status: status.to_string(), + total: total_commits, + atomic: atomic_count, + non_atomic: non_atomic_count, + skipped_bots, + commits: analyzed_commits, + }; + + if args.json { + println!("{}", serde_json::to_string_pretty(&result)?); + } else if args.markdown { + println!("{}", report::generate_markdown(&result)); + } else { + report::print_terminal(&result, &config); + } + + if has_issues && config.mode == "error" { + std::process::exit(1); + } + + Ok(()) +} + +fn run_report(args: &Args, format: String) -> Result<()> { + let mut args_mod = Args { + command: None, + json: args.json, + markdown: args.markdown, + config: args.config.clone(), + range: args.range.clone(), + }; + + match format.as_str() { + "json" => { + args_mod.json = true; + args_mod.markdown = false; + } + "markdown" => { + args_mod.json = false; + args_mod.markdown = true; + } + _ => { + args_mod.json = false; + args_mod.markdown = false; + } + } + run_check(&args_mod, None, None, None) +} + +fn run_validate(args: &Args) -> Result<()> { + if std::path::Path::new(&args.config).exists() { + Config::load(&args.config)?; + println!("{} Configuration file '{}' is valid.", "✅".green(), args.config); + } else { + println!("{} Configuration file '{}' not found.", "❌".red(), args.config); + std::process::exit(1); + } + Ok(()) +} diff --git a/tools/atomicity-checker/src/models.rs b/tools/atomicity-checker/src/models.rs new file mode 100644 index 00000000..106a2f70 --- /dev/null +++ b/tools/atomicity-checker/src/models.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct FileInfo { + pub path: String, + pub concern: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CommitInfo { + pub sha: String, + pub message: String, + pub author: String, + pub concerns: Vec, + pub count: usize, + pub is_atomic: bool, + pub files: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AnalysisResult { + pub status: String, + pub total: usize, + pub atomic: usize, + pub non_atomic: usize, + pub skipped_bots: usize, + pub commits: Vec, +} diff --git a/tools/atomicity-checker/src/report.rs b/tools/atomicity-checker/src/report.rs new file mode 100644 index 00000000..adf102c2 --- /dev/null +++ b/tools/atomicity-checker/src/report.rs @@ -0,0 +1,83 @@ +use crate::models::AnalysisResult; +use crate::config::Config; +use colored::*; + +pub fn print_terminal(result: &AnalysisResult, _config: &Config) { + println!("\n{}", "🔍 Analyzing commit atomicity...".cyan().bold()); + println!("{}", "=".repeat(50)); + + for commit in &result.commits { + let short_sha = &commit.sha[..8]; + if commit.is_atomic { + println!( + "{} {}: {} ({} concern)", + "✓".green(), + short_sha.bright_black(), + commit.message, + commit.count + ); + } else { + println!( + "{} {}: {} ({} concerns: {})", + "⚠".yellow(), + short_sha.bright_black(), + commit.message, + commit.count, + commit.concerns.join(", ").yellow() + ); + } + } + + println!("\n{}", "📊 Summary".cyan().bold()); + println!("{}", "-".repeat(20)); + println!(" Total commits: {}", result.total); + println!(" {} Atomic: {}", "✓".green(), result.atomic); + println!(" {} Non-atomic: {}", "⚠".yellow(), result.non_atomic); + println!(" {} Skipped (bots): {}", "○".cyan(), result.skipped_bots); + println!(); + + if result.non_atomic > 0 { + println!("{}", "⚠️ Some commits mix multiple concerns.".yellow().bold()); + println!(" Consider using atomic commits for better history."); + } else { + println!("{}", "✅ All commits are atomic!".green().bold()); + } + println!(); +} + +pub fn generate_markdown(result: &AnalysisResult) -> String { + let mut md = String::new(); + md.push_str("# 🔍 Commit Atomicity Report\n\n"); + + md.push_str("## 📊 Summary\n\n"); + md.push_str("| Metric | Value |\n"); + md.push_str("|--------|-------|\n"); + md.push_str(&format!("| Total Commits | {} |\n", result.total)); + md.push_str(&format!("| ✅ Atomic | {} |\n", result.atomic)); + md.push_str(&format!("| ⚠️ Non-atomic | {} |\n", result.non_atomic)); + md.push_str(&format!("| ○ Skipped (bots) | {} |\n\n", result.skipped_bots)); + + if result.non_atomic > 0 { + md.push_str("> ⚠️ **Warning**: Some commits mix multiple concerns. Consider using atomic commits for better history.\n\n"); + } else { + md.push_str("> ✅ **Success**: All commits are atomic!\n\n"); + } + + md.push_str("## 📋 Commit Details\n\n"); + md.push_str("| Status | Commit | Message | Concerns |\n"); + md.push_str("|--------|--------|---------|----------|\n"); + + for commit in &result.commits { + let status = if commit.is_atomic { "✅" } else { "⚠️" }; + let short_sha = &commit.sha[..8]; + md.push_str(&format!( + "| {} | `{}` | {} | {} |\n", + status, + short_sha, + commit.message.replace('|', "\\|"), + commit.concerns.join(", ") + )); + } + + md +}