From 36f9870abde8edec64b9bc9b107f05865e6f05e3 Mon Sep 17 00:00:00 2001 From: Dennis P Date: Mon, 13 Apr 2026 21:48:52 +0800 Subject: [PATCH 1/3] feat: streaming output, positive feedback, and Apple Silicon optimizations - Stream findings to terminal as each diff chunk completes instead of waiting for all chunks to finish; deterministic rule findings appear instantly before model inference begins - Reviewer now always returns what looks good (positives) and optional suggestions alongside findings, even when no issues are detected - Add Metal + Accelerate support for Apple Silicon via candle target-specific deps; auto-detects GPU on macOS, falls back to CPU with Accelerate BLAS - Add --device flag (auto/cpu/metal) to override inference device - Fix core-engine Cargo.toml: move platform-agnostic deps out of macOS target block so they resolve correctly on all platforms - Update candle deps to 0.10.2 with objc2-metal 0.3.2 lock - Expand roadmap with daemon mode, git hooks, SARIF, auto-fix, and fine-tuned model concepts --- README.md | 27 ++++++++- apps/tui-cli/Cargo.toml | 2 +- apps/tui-cli/src/main.rs | 104 ++++++++++++++++++-------------- packages/core-engine/Cargo.toml | 4 +- packages/core-engine/src/lib.rs | 27 ++++++++- 5 files changed, 112 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index cddd164..2998d61 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Your source code never leaves your environment. Works offline. Ships as a **sing - **Quality review** — anti-patterns, dead code, API misuse - **Performance hints** — inefficient algorithms, memory overhead, unnecessary allocations - **Maintainability** — naming, readability, complexity -- **Ticket-aware review** — provide a Jira/Linear/GitHub ticket and diffmind checks if the diff actually implements the requirements (`--ticket`) +- **Ticket-aware review** — provide a Jira/Linear/GitHub ticket and Diffmind checks if the diff actually implements the requirements (`--ticket`) - **Local RAG** — indexes your project's symbols so the model understands function and type definitions referenced in the diff (`diffmind index`) - **Interactive TUI** — ratatui terminal UI with navigable findings and detail panel (`--tui`) - **CI/CD gate** — pipe any `git diff` via stdin, filter by severity, exits with code 1 on findings @@ -323,9 +323,32 @@ diffmind/ ## Roadmap +The items below are planned or under consideration. Contributions welcome — open an issue to discuss before starting anything large. + +### Near-term + - [ ] `--output ` — write Markdown or HTML report to disk - [ ] Incremental model updates — version-check HuggingFace before re-download -- [ ] Custom rule file (`.diffmind/rules.toml`) — team-specific review baselines +- [ ] `diffmind install-hooks` — one command to install a `pre-push` git hook that blocks on High severity findings +- [ ] **Watch mode** (`diffmind watch`) — re-review staged files automatically on each `git add`, no manual invocation needed +- [ ] **Custom rule file** (`.diffmind/rules.toml`) — team-defined regex patterns that run before the model, zero inference cost + +### Medium-term + +- [ ] **Daemon / server mode** (`diffmind serve`) — keep the model loaded in memory between invocations so subsequent reviews are near-instant. Uses an idle timeout (configurable, default 10 min) — the model is automatically unloaded when not in use so it does not consume RAM while you are away from your desk. Same pattern as `rust-analyzer` or `ssh-agent`. +- [x] **Streaming output** — print findings as each chunk completes rather than waiting for the full diff to finish +- [ ] **SARIF output** (`--format sarif`) — upload to GitHub Code Scanning and get inline PR annotations in the GitHub UI, no extra tooling required +- [ ] **Auto-fix patches** (`diffmind fix`) — convert `suggested_fix` fields into `.patch` files and apply them interactively with `git apply` +- [ ] **PR description generator** (`diffmind describe`) — generate a structured PR title + body from the diff using the same local model +- [ ] **Commit message suggester** (`diffmind commit`) — review staged changes and suggest a conventional commit message + +### Concepts & Future Ideas + +- [ ] **Hotspot awareness** — inject `git log` change frequency per file into the prompt so the model flags instability patterns in high-churn areas +- [ ] **Cross-file impact analysis** — extend the RAG symbol index to detect callers of deleted or renamed functions across the entire project +- [ ] **Review history & trends** (`diffmind stats`) — store findings in `.diffmind/history/` and surface patterns over time ("60% of High findings are in `auth/`") +- [ ] **VS Code / JetBrains extension** — call the daemon, display findings in the Problems panel with squiggles on diff lines +- [ ] **Fine-tuned review model** — a smaller model trained specifically on code review tasks, trading general capability for faster, more accurate review output --- diff --git a/apps/tui-cli/Cargo.toml b/apps/tui-cli/Cargo.toml index bc7ef5e..523d26d 100644 --- a/apps/tui-cli/Cargo.toml +++ b/apps/tui-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "diffmind" -version = "0.6.3" +version = "0.7.0" edition = "2024" description = "Local-first AI code review agent — powered by on-device inference" diff --git a/apps/tui-cli/src/main.rs b/apps/tui-cli/src/main.rs index 9c19e41..4e7c1dd 100644 --- a/apps/tui-cli/src/main.rs +++ b/apps/tui-cli/src/main.rs @@ -314,12 +314,38 @@ async fn run_static( } }); - let pb = spinner.clone(); + // ── Streaming: print findings as each chunk completes (text mode only) ────── + // indicatif's .println() draws above the spinner without disturbing it. + let pb_progress = spinner.clone(); + let pb_stream = spinner.clone(); + let stream_min_sev = min_severity.clone(); + let stream_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let stream_count_clone = stream_count.clone(); + let is_text = matches!(args.format, cli::OutputFormat::Text); + let (summary, skipped) = analyzer - .analyze_diff_chunked_with_progress(diff, &context, args.max_tokens, move |done, total| { - *chunk_label.lock().unwrap() = format!("chunk {}/{}", done, total); - pb.set_message(format!("Analyzing chunk {}/{}...", done, total)); - }) + .analyze_diff_chunked_with_progress( + diff, + &context, + args.max_tokens, + move |done, total| { + *chunk_label.lock().unwrap() = format!("chunk {}/{}", done, total); + pb_progress.set_message(format!("Analyzing chunk {}/{}...", done, total)); + }, + move |chunk_findings| { + if !is_text { + return; + } + for f in chunk_findings { + if meets_threshold(&f.severity, &stream_min_sev) { + let n = stream_count_clone + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + + 1; + pb_stream.println(format_finding(f, &format!("#{}", n))); + } + } + }, + ) .map_err(|e| anyhow::anyhow!(e.to_string()))?; timer_done.store(true, std::sync::atomic::Ordering::Relaxed); @@ -354,20 +380,16 @@ async fn run_static( println!("{}", json); } cli::OutputFormat::Text => { - println!(); + // Findings were already streamed above — just print the footer. if findings.is_empty() { if skipped > 0 { eprintln!( " ? No parseable findings — try `--model 3b` for better output quality." ); } else { - use crossterm::style::Stylize; eprintln!(" {} No issues found.", "✓".green().bold()); } } else { - for (i, f) in findings.iter().enumerate() { - print_finding(f, i + 1, findings.len()); - } print_summary(findings.len(), skipped); } print_positives_and_suggestions(&summary.positives, &summary.suggestions); @@ -421,61 +443,55 @@ fn wrap_text(text: &str, indent: usize, width: usize) -> String { lines.join("\n") } -fn print_finding(f: &ReviewFinding, idx: usize, total: usize) { +/// Build a fully-formatted, ANSI-coloured string for one finding. +/// `counter` is a short label shown on the header row, e.g. `"#1"` or `"2/5"`. +fn format_finding(f: &ReviewFinding, counter: &str) -> String { let sev = severity_color(f); let icon = category_icon(f); let cat = format!("{:?}", f.category).to_lowercase(); let loc = format!("{}:{}", f.file, f.line).dark_grey(); - let counter = format!("[{}/{}]", idx, total).dark_grey(); + let counter_label = format!("[{}]", counter).dark_grey(); + + let mut out = String::new(); // ── Header row ── - println!( - " {} {} {} {} {}", + out.push_str(&format!( + "\n {} {} {} {} {}\n", sev, icon, cat.dark_grey(), loc, - counter - ); + counter_label + )); // ── Separator ── - println!(" {}", "─".repeat(62).dark_grey()); + out.push_str(&format!(" {}\n", "─".repeat(62).dark_grey())); // ── Issue ── - println!( - " {} {}", + let issue_wrapped = wrap_text(&f.issue, 10, 68); + let mut issue_lines = issue_wrapped.lines(); + out.push_str(&format!( + " {} {}\n", "Issue".red().bold(), - wrap_text(&f.issue, 10, 68).trim_start() - ); - if f.issue.len() > 58 { - println!( - "{}", - wrap_text(&f.issue, 10, 68) - .lines() - .skip(1) - .collect::>() - .join("\n") - ); + issue_lines.next().unwrap_or("").trim_start() + )); + for line in issue_lines { + out.push_str(&format!("{}\n", line)); } // ── Fix ── - println!( - " {} {}", + let fix_wrapped = wrap_text(&f.suggested_fix, 10, 68); + let mut fix_lines = fix_wrapped.lines(); + out.push_str(&format!( + " {} {}\n", "Fix".green().bold(), - wrap_text(&f.suggested_fix, 10, 68).trim_start() - ); - if f.suggested_fix.len() > 58 { - println!( - "{}", - wrap_text(&f.suggested_fix, 10, 68) - .lines() - .skip(1) - .collect::>() - .join("\n") - ); + fix_lines.next().unwrap_or("").trim_start() + )); + for line in fix_lines { + out.push_str(&format!("{}\n", line)); } - println!(); + out } fn print_summary(count: usize, skipped: usize) { diff --git a/packages/core-engine/Cargo.toml b/packages/core-engine/Cargo.toml index 5b930f4..6b840bd 100644 --- a/packages/core-engine/Cargo.toml +++ b/packages/core-engine/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "core-engine" -version = "0.6.3" +version = "0.7.0" edition = "2024" -description = "diffmind shared AI engine core" +description = "Diffmind shared AI engine core" [dependencies] candle-core = { workspace = true } diff --git a/packages/core-engine/src/lib.rs b/packages/core-engine/src/lib.rs index dfdb21d..7f62d6f 100644 --- a/packages/core-engine/src/lib.rs +++ b/packages/core-engine/src/lib.rs @@ -214,15 +214,21 @@ impl ReviewAnalyzer { /// Returns `(summary, skipped)` where `skipped` is the number of chunks /// the model processed but returned unparseable JSON for — useful for /// surfacing silent failures to the user. - pub fn analyze_diff_chunked_with_progress( + /// Like [`analyze_diff_chunked`] but fires two callbacks as work progresses: + /// - `on_progress(done, total)` — called when a chunk starts (for spinner label updates) + /// - `on_chunk_result(findings)` — called immediately with each chunk's findings as + /// they complete, enabling streaming output before the full diff is processed + pub fn analyze_diff_chunked_with_progress( &mut self, diff: &str, context: &str, max_tokens_per_chunk: u32, on_progress: F, + on_chunk_result: G, ) -> Result<(ReviewSummary, usize), EngineError> where F: Fn(usize, usize), + G: Fn(&[ReviewFinding]), { // Run deterministic rules first — catches patterns the model reliably misses. let det_findings: Vec = detect_commented_out_code(diff) @@ -230,6 +236,11 @@ impl ReviewAnalyzer { .chain(detect_removed_used_variables(diff)) .collect(); + // Stream deterministic findings immediately — no need to wait for the LLM. + if !det_findings.is_empty() { + on_chunk_result(&det_findings); + } + const MAX_CHUNK_LINES: usize = 300; let chunks = chunk_diff(diff, MAX_CHUNK_LINES); // Pre-count non-empty chunks so callers can show "N/total". @@ -249,6 +260,10 @@ impl ReviewAnalyzer { on_progress(done, total); match self.analyze_diff_internal(&chunk, context, max_tokens_per_chunk as usize) { Ok(summary) => { + // Stream this chunk's findings immediately. + if !summary.findings.is_empty() { + on_chunk_result(&summary.findings); + } all_findings.extend(summary.findings); all_positives.extend(summary.positives); all_suggestions.extend(summary.suggestions); @@ -281,8 +296,14 @@ impl ReviewAnalyzer { context: &str, max_tokens_per_chunk: u32, ) -> Result { - self.analyze_diff_chunked_with_progress(diff, context, max_tokens_per_chunk, |_, _| {}) - .map(|(summary, _)| summary) + self.analyze_diff_chunked_with_progress( + diff, + context, + max_tokens_per_chunk, + |_, _| {}, + |_| {}, + ) + .map(|(summary, _)| summary) } fn analyze_diff_internal( From 643778c9ab6c06d3fe3e6376138f7f2a2d64d9ab Mon Sep 17 00:00:00 2001 From: Dennis P Date: Mon, 13 Apr 2026 21:55:19 +0800 Subject: [PATCH 2/3] feat: add PR description and commit message generators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add No changes detected. Nothing to analyze. subcommand — generates a structured PR title, summary bullets, and test plan from the branch diff using the local model; supports --branch, --last, --stdin, and --ticket for requirements context - Add No changes detected. Nothing to analyze. subcommand — suggests a conventional commit message from staged changes (git diff --cached); --apply runs git commit automatically - Add PrDescription and CommitSuggestion output types to core-engine - Add get_staged_diff() to git.rs; errors clearly when nothing is staged - Update README with usage examples and mark roadmap items complete --- README.md | 73 ++++++++++- apps/tui-cli/src/cli.rs | 40 ++++++ apps/tui-cli/src/git.rs | 38 ++++++ apps/tui-cli/src/main.rs | 221 +++++++++++++++++++++++++++++++- packages/core-engine/src/lib.rs | 135 +++++++++++++++++++ 5 files changed, 503 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2998d61..d7e84fc 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,74 @@ git diff main...HEAD | diffmind --stdin --min-severity high git diff main...HEAD | diffmind --stdin --format json | jq '.[] | select(.severity == "high")' ``` +### PR description (`diffmind describe`) + +Generate a structured PR title, summary, and test plan from your branch diff: + +```bash +# Generate PR description from current branch vs main +diffmind describe + +# Use last commit only +diffmind describe --last + +# Provide ticket context so the description reflects requirements +diffmind describe --branch staging --ticket ticket.md + +# Pipe in any diff +git diff main...HEAD | diffmind describe --stdin +``` + +Output: + +``` + diffmind PR description + ──────────────────────────────────────────────────────────────── + + Title + Add streaming output and Metal GPU support for Apple Silicon + + Summary + · Stream findings to the terminal as each diff chunk completes + · Enable Metal + Accelerate inference on Apple Silicon Macs + · Reviewer now always returns positive highlights alongside issues + + Test plan + ☐ Run diffmind --last on an M-series Mac and verify "Metal" in header + ☐ Confirm findings appear per chunk rather than all at once + ☐ Verify --format json output is unchanged +``` + +### Commit message (`diffmind commit`) + +Suggest a conventional commit message for your staged changes: + +```bash +# Stage your changes first +git add src/auth.rs + +# Get a suggested commit message +diffmind commit + +# Run git commit automatically with the suggestion +diffmind commit --apply +``` + +Output: + +``` + diffmind commit message + ──────────────────────────────────────────────────────────────── + + feat(auth): add JWT token refresh with sliding expiry window + + Replaces the fixed 1-hour expiry with a sliding window that resets + on each authenticated request, reducing unnecessary logouts. + + ─ Run: git commit -m "feat(auth): add JWT token refresh..." + ─ Or: diffmind commit --apply +``` + ### Local symbol indexing (RAG) Build a symbol index so the model understands definitions of functions and types referenced in your diff: @@ -233,6 +301,8 @@ Usage: diffmind [OPTIONS] [FILES]... [COMMAND] Commands: download Download or refresh the local AI model files index Build a symbol index for context-aware reviews + describe Generate a PR title and description from the current branch diff + commit Suggest a conventional commit message for staged changes Arguments: [FILES]... Specific files or directories to review (optional) @@ -336,11 +406,8 @@ The items below are planned or under consideration. Contributions welcome — op ### Medium-term - [ ] **Daemon / server mode** (`diffmind serve`) — keep the model loaded in memory between invocations so subsequent reviews are near-instant. Uses an idle timeout (configurable, default 10 min) — the model is automatically unloaded when not in use so it does not consume RAM while you are away from your desk. Same pattern as `rust-analyzer` or `ssh-agent`. -- [x] **Streaming output** — print findings as each chunk completes rather than waiting for the full diff to finish - [ ] **SARIF output** (`--format sarif`) — upload to GitHub Code Scanning and get inline PR annotations in the GitHub UI, no extra tooling required - [ ] **Auto-fix patches** (`diffmind fix`) — convert `suggested_fix` fields into `.patch` files and apply them interactively with `git apply` -- [ ] **PR description generator** (`diffmind describe`) — generate a structured PR title + body from the diff using the same local model -- [ ] **Commit message suggester** (`diffmind commit`) — review staged changes and suggest a conventional commit message ### Concepts & Future Ideas diff --git a/apps/tui-cli/src/cli.rs b/apps/tui-cli/src/cli.rs index 9e35662..5716bf2 100644 --- a/apps/tui-cli/src/cli.rs +++ b/apps/tui-cli/src/cli.rs @@ -89,6 +89,46 @@ pub enum Commands { }, /// Build a symbol index of the local repository for context-aware reviews Index, + /// Generate a PR title and description from the current branch diff + Describe { + /// Base branch to diff against + #[arg(short, long, default_value = "main")] + branch: String, + + /// Use only the most recent commit instead of the full branch diff + #[arg(short, long)] + last: bool, + + /// Read diff from stdin + #[arg(long)] + stdin: bool, + + /// User story / ticket for additional context (file path or inline text) + #[arg(long, value_name = "FILE_OR_TEXT")] + ticket: Option, + + /// Model size to use (1.5b, 3b, …) + #[arg(short, long, default_value = "1.5b")] + model: String, + + /// Inference device: auto, cpu, metal + #[arg(long, default_value = "auto")] + device: String, + }, + /// Suggest a conventional commit message for staged changes + Commit { + /// Model size to use (1.5b, 3b, …) + #[arg(short, long, default_value = "1.5b")] + model: String, + + /// Inference device: auto, cpu, metal + #[arg(long, default_value = "auto")] + device: String, + + /// Run `git commit` automatically with the suggested message + #[arg(long)] + apply: bool, + }, } pub fn parse() -> Cli { diff --git a/apps/tui-cli/src/git.rs b/apps/tui-cli/src/git.rs index af561aa..d86a6da 100644 --- a/apps/tui-cli/src/git.rs +++ b/apps/tui-cli/src/git.rs @@ -59,6 +59,44 @@ pub fn get_last_commit_diff(paths: &[String]) -> Result { Ok(diff) } +/// Returns the diff of currently staged changes (`git diff --cached`). +/// Returns an error if nothing is staged. +pub fn get_staged_diff() -> Result { + let output = Command::new("git") + .args([ + "diff", + "--cached", + "--", + ":!node_modules", + ":!*-lock.json", + ":!pnpm-lock.yaml", + ":!package-lock.json", + ":!yarn.lock", + ":!dist", + ":!build", + ":!.next", + ":!.cache", + ":!*.map", + ":!*.min.js", + ":!*.min.css", + ]) + .output() + .context("Failed to execute git command")?; + + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!("Git error: {}", err)); + } + + let diff = String::from_utf8_lossy(&output.stdout).to_string(); + if diff.trim().is_empty() { + return Err(anyhow::anyhow!( + "No staged changes found. Run `git add ` first." + )); + } + Ok(diff) +} + pub fn get_diff(branch: &str, paths: &[String]) -> Result { let branch_arg = format!("{}...HEAD", branch); let mut args = vec!["diff", &branch_arg, "--"]; diff --git a/apps/tui-cli/src/main.rs b/apps/tui-cli/src/main.rs index 4e7c1dd..6fb0e82 100644 --- a/apps/tui-cli/src/main.rs +++ b/apps/tui-cli/src/main.rs @@ -1,5 +1,7 @@ use anyhow::Result; -use core_engine::{DevicePreference, ReviewAnalyzer, ReviewFinding, Severity}; +use core_engine::{ + CommitSuggestion, DevicePreference, PrDescription, ReviewAnalyzer, ReviewFinding, Severity, +}; use indicatif::{ProgressBar, ProgressStyle}; use std::{ collections::HashSet, @@ -43,6 +45,41 @@ async fn main() -> Result<()> { println!("Index updated: {} symbols found", new_index.symbols.len()); return Ok(()); } + cli::Commands::Describe { + branch, + last, + stdin, + ticket, + model, + device, + } => { + let diff = if stdin { + use std::io::Read; + let mut buf = String::new(); + io::stdin().read_to_string(&mut buf).ok(); + buf + } else if last { + git::get_last_commit_diff(&[])? + } else { + git::get_diff(&branch, &[])? + }; + if diff.trim().is_empty() { + println!("No changes detected. Nothing to describe."); + return Ok(()); + } + let ticket_text = resolve_ticket(ticket.as_deref()); + run_describe(&diff, &model_dir, ticket_text.as_deref(), &model, &device).await?; + return Ok(()); + } + cli::Commands::Commit { + model, + device, + apply, + } => { + let diff = git::get_staged_diff()?; + run_commit(&diff, &model_dir, &model, &device, apply).await?; + return Ok(()); + } } } @@ -399,6 +436,188 @@ async fn run_static( Ok(!findings.is_empty()) } +// ─── PR description runner ─────────────────────────────────────────────────── + +async fn run_describe( + diff: &str, + model_dir: &Path, + ticket: Option<&str>, + model_id: &str, + device: &str, +) -> Result<()> { + let model_path = model_dir.join(format!("qwen2.5-coder-{}-instruct-q4_k_m.gguf", model_id)); + let tokenizer_path = model_dir.join("tokenizer.json"); + + if !model_path.exists() || !tokenizer_path.exists() { + return Err(anyhow::anyhow!( + "Model files not found. Run `diffmind download` first." + )); + } + + eprintln!(); + eprintln!(" diffmind PR description"); + eprintln!(" {}", "─".repeat(52)); + eprintln!( + " {:<10} {} file{}", + "Changed", + count_diff_files(diff), + if count_diff_files(diff) == 1 { "" } else { "s" } + ); + eprintln!(); + + let spinner = make_spinner("Loading model..."); + let model_bytes = std::fs::read(&model_path)?; + let tokenizer_bytes = std::fs::read(&tokenizer_path)?; + + let device_pref = parse_device(device); + let mut analyzer = ReviewAnalyzer::new_with_device(&model_bytes, &tokenizer_bytes, device_pref) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + + spinner.set_message("Generating PR description..."); + + let desc = analyzer + .generate_pr_description(diff, ticket, 768) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + + spinner.finish_and_clear(); + + print_pr_description(&desc); + Ok(()) +} + +fn print_pr_description(desc: &PrDescription) { + use crossterm::style::Stylize; + + eprintln!(); + eprintln!(" {} Title", "─".repeat(62).dark_grey()); + eprintln!(); + eprintln!(" {}", desc.title.clone().bold()); + eprintln!(); + + if !desc.summary.is_empty() { + eprintln!(" {} Summary", "─".repeat(62).dark_grey()); + eprintln!(); + for item in &desc.summary { + eprintln!(" {} {}", "·".cyan(), item); + } + eprintln!(); + } + + if !desc.test_plan.is_empty() { + eprintln!(" {} Test plan", "─".repeat(62).dark_grey()); + eprintln!(); + for item in &desc.test_plan { + eprintln!(" {} {}", "☐".dark_grey(), item); + } + eprintln!(); + } + + eprintln!(" {}", "─".repeat(62).dark_grey()); + eprintln!( + " {} Copy the above to your PR description.", + "·".dark_grey() + ); + eprintln!(); +} + +// ─── Commit message runner ──────────────────────────────────────────────────── + +async fn run_commit( + diff: &str, + model_dir: &Path, + model_id: &str, + device: &str, + apply: bool, +) -> Result<()> { + let model_path = model_dir.join(format!("qwen2.5-coder-{}-instruct-q4_k_m.gguf", model_id)); + let tokenizer_path = model_dir.join("tokenizer.json"); + + if !model_path.exists() || !tokenizer_path.exists() { + return Err(anyhow::anyhow!( + "Model files not found. Run `diffmind download` first." + )); + } + + eprintln!(); + eprintln!(" diffmind commit message"); + eprintln!(" {}", "─".repeat(52)); + eprintln!( + " {:<10} {} file{}", + "Staged", + count_diff_files(diff), + if count_diff_files(diff) == 1 { "" } else { "s" } + ); + eprintln!(); + + let spinner = make_spinner("Loading model..."); + let model_bytes = std::fs::read(&model_path)?; + let tokenizer_bytes = std::fs::read(&tokenizer_path)?; + + let device_pref = parse_device(device); + let mut analyzer = ReviewAnalyzer::new_with_device(&model_bytes, &tokenizer_bytes, device_pref) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + + spinner.set_message("Generating commit message..."); + + let suggestion = analyzer + .generate_commit_message(diff, 512) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + + spinner.finish_and_clear(); + + print_commit_suggestion(&suggestion); + + if apply { + run_git_commit(&suggestion)?; + } + + Ok(()) +} + +fn print_commit_suggestion(s: &CommitSuggestion) { + use crossterm::style::Stylize; + + eprintln!(); + eprintln!(" {}", "─".repeat(62).dark_grey()); + eprintln!(); + eprintln!(" {}", s.message.clone().bold()); + if !s.body.trim().is_empty() { + eprintln!(); + for line in s.body.lines() { + eprintln!(" {}", line.dark_grey()); + } + } + eprintln!(); + eprintln!(" {}", "─".repeat(62).dark_grey()); + eprintln!( + " {} Run: git commit -m \"{}\"", + "·".dark_grey(), + s.message + ); + eprintln!(" {} Or: diffmind commit --apply", "·".dark_grey()); + eprintln!(); +} + +fn run_git_commit(s: &CommitSuggestion) -> Result<()> { + use crossterm::style::Stylize; + use std::process::Command; + + let mut cmd = Command::new("git"); + cmd.args(["commit", "-m", &s.message]); + if !s.body.trim().is_empty() { + cmd.args(["-m", s.body.trim()]); + } + + eprintln!(" {} Running git commit...", "·".dark_grey()); + let status = cmd.status()?; + if status.success() { + eprintln!(" {} Committed.", "✓".green().bold()); + } else { + return Err(anyhow::anyhow!("git commit failed")); + } + Ok(()) +} + // ─── Coloured finding renderer ──────────────────────────────────────────────── use core_engine::Category; diff --git a/packages/core-engine/src/lib.rs b/packages/core-engine/src/lib.rs index 7f62d6f..4d3f05a 100644 --- a/packages/core-engine/src/lib.rs +++ b/packages/core-engine/src/lib.rs @@ -72,6 +72,32 @@ pub struct ReviewSummary { pub suggestions: Vec, } +/// Output of `generate_pr_description` — ready to paste into GitHub / GitLab. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PrDescription { + /// Imperative title under 72 characters. + #[serde(default)] + pub title: String, + /// Two to four bullet points summarising what changed and why. + #[serde(default)] + pub summary: Vec, + /// Checklist of steps a reviewer should take to verify the change. + #[serde(default)] + pub test_plan: Vec, +} + +/// Output of `generate_commit_message` — conventional commit format. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CommitSuggestion { + /// Single-line conventional commit message (under 72 chars). + #[serde(default)] + pub message: String, + /// Optional multi-line body explaining *why* the change was made. + /// Empty string when a one-liner is sufficient. + #[serde(default)] + pub body: String, +} + // ─── ReviewAnalyzer ────────────────────────────────────────────────────────── pub struct ReviewAnalyzer { @@ -520,6 +546,115 @@ impl ReviewAnalyzer { Ok(generated_text) } + + // ── PR description ──────────────────────────────────────────────────────── + + /// Generate a structured PR title, summary, and test plan from a diff. + /// The diff is truncated if it exceeds the context budget. + pub fn generate_pr_description( + &mut self, + diff: &str, + ticket: Option<&str>, + max_new_tokens: usize, + ) -> Result { + // ~10 KB ≈ 2 500 tokens — leaves room for the prompt and output. + const MAX_DIFF_BYTES: usize = 10_000; + let diff = truncate_to_char_boundary(diff, MAX_DIFF_BYTES); + + let ticket_section = match ticket { + Some(t) => format!( + "\n\nTicket / user story:\n{}", + truncate_to_char_boundary(t, 1500) + ), + None => String::new(), + }; + + let prompt = format!( + "<|im_start|>system\n\ + You are a senior software engineer writing a GitHub pull request description.\n\ + Given a git diff, produce a concise and informative PR description.\n\n\ + Return a JSON object ONLY with this structure:\n\ + {{\"title\": \"imperative title under 72 chars\", \ + \"summary\": [\"what changed and why — one sentence per bullet\"], \ + \"test_plan\": [\"how to verify each change\"]}}\n\n\ + Rules:\n\ + - title: imperative mood, under 72 chars, no period (e.g. \"Add JWT token refresh\")\n\ + - summary: 2–4 bullets, each one sentence, focus on what and why\n\ + - test_plan: 2–4 actionable steps a reviewer can follow to verify the change\ + <|im_end|>\n\ + <|im_start|>user\n\ + Diff:{}\n{}\ + <|im_end|>\n\ + <|im_start|>assistant\n", + diff, ticket_section + ); + + let response = self.generate(&prompt, max_new_tokens)?; + + if let Some(start) = response.find('{') + && let Some(end) = response.rfind('}') + && end > start + { + let slice = &response[start..=end]; + if let Ok(desc) = serde_json::from_str::(slice) { + return Ok(desc); + } + } + + Err(EngineError::SerializationError( + "model did not return a valid PR description JSON object".into(), + )) + } + + // ── Commit message ──────────────────────────────────────────────────────── + + /// Suggest a conventional commit message for a staged diff. + pub fn generate_commit_message( + &mut self, + diff: &str, + max_new_tokens: usize, + ) -> Result { + const MAX_DIFF_BYTES: usize = 10_000; + let diff = truncate_to_char_boundary(diff, MAX_DIFF_BYTES); + + let prompt = format!( + "<|im_start|>system\n\ + You are a senior software engineer writing a git commit message.\n\ + Given a staged diff, produce a conventional commit message.\n\n\ + Conventional commit format: (): \n\ + Types: feat, fix, docs, style, refactor, test, chore, perf\n\n\ + Return a JSON object ONLY:\n\ + {{\"message\": \"feat(scope): short description\", \ + \"body\": \"optional multi-line body explaining WHY (empty string if a one-liner is enough)\"}}\n\n\ + Rules:\n\ + - message must be under 72 characters\n\ + - Use imperative mood (\"add\" not \"added\", \"fix\" not \"fixed\")\n\ + - scope is optional — use it when it meaningfully narrows the context\n\ + - body explains motivation and context, not what the diff shows\ + <|im_end|>\n\ + <|im_start|>user\n\ + Staged diff:\n{}\ + <|im_end|>\n\ + <|im_start|>assistant\n", + diff + ); + + let response = self.generate(&prompt, max_new_tokens)?; + + if let Some(start) = response.find('{') + && let Some(end) = response.rfind('}') + && end > start + { + let slice = &response[start..=end]; + if let Ok(msg) = serde_json::from_str::(slice) { + return Ok(msg); + } + } + + Err(EngineError::SerializationError( + "model did not return a valid commit message JSON object".into(), + )) + } } /// Returns `true` once `s` contains a syntactically-complete JSON value From 8284ca708cd13df106d9d1def6ba6b67d2764587 Mon Sep 17 00:00:00 2001 From: Dennis P Date: Mon, 13 Apr 2026 22:06:12 +0800 Subject: [PATCH 3/3] feat: add custom rule file (.diffmind/rules.toml) for team coding standards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teams can now define regex rules that run before the AI model — zero inference cost, instant results, same severity/CI-gate as model findings. - Add CustomRule type to core-engine with pattern, message, severity, category, and optional file glob filter - Add detect_custom_rule_violations() — runs rules against added lines in the diff, respects file filters, pre-compiles regex patterns - Add with_custom_rules() builder on ReviewAnalyzer; rules are streamed alongside deterministic findings before LLM chunks start - Add rules.rs module to CLI with load_custom_rules() that reads and parses .diffmind/rules.toml, printing rule count in the header - Add regex dep to core-engine, toml dep to CLI and workspace - Document rules format with full coding standards example in README --- Cargo.toml | 1 + README.md | 69 +++++++++++++++- apps/tui-cli/Cargo.toml | 1 + apps/tui-cli/src/main.rs | 20 ++++- apps/tui-cli/src/rules.rs | 45 +++++++++++ packages/core-engine/Cargo.toml | 1 + packages/core-engine/src/lib.rs | 138 ++++++++++++++++++++++++++++++++ 7 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 apps/tui-cli/src/rules.rs diff --git a/Cargo.toml b/Cargo.toml index 6df8cba..dc95f0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ walkdir = "2" chrono = { version = "0.4", features = ["serde"] } lazy_static = "1" sysinfo = { version = "0.32", default-features = false, features = ["system", "disk"] } +toml = "0.8" # Release profile: Optimize for maximum execution speed. diff --git a/README.md b/README.md index d7e84fc..1ab52a6 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,74 @@ Output: ─ Or: diffmind commit --apply ``` +### Team rules (`.diffmind/rules.toml`) + +Encode your coding standards as regex patterns. Rules run **before the AI model** — instant, zero inference cost — and produce findings just like model findings (colored output, severity, CI gate). + +Create `.diffmind/rules.toml` in your project root: + +```toml +# ── Debug & logging ─────────────────────────────────────────────────────────── +[[rule]] +pattern = "console\\.log" +message = "Remove debug logging before merging to production" +severity = "medium" +category = "quality" +files = ["*.ts", "*.js", "*.tsx", "*.jsx"] + +# ── TypeScript standards ────────────────────────────────────────────────────── +[[rule]] +pattern = ":\\s*any\\b" +message = "Avoid TypeScript 'any' — use an explicit type or 'unknown'" +severity = "medium" +category = "quality" +files = ["*.ts", "*.tsx"] + +[[rule]] +pattern = "@ts-ignore" +message = "Do not suppress TypeScript errors — fix the underlying type issue" +severity = "medium" +category = "quality" +files = ["*.ts", "*.tsx"] + +# ── Security ────────────────────────────────────────────────────────────────── +[[rule]] +pattern = "password\\s*=\\s*[\"'][^\"']+[\"']" +message = "Hardcoded password — use environment variables or a secrets manager" +severity = "high" +category = "security" + +[[rule]] +pattern = "SECRET|API_KEY|PRIVATE_KEY" +message = "Possible hardcoded secret in added code — verify this is not sensitive" +severity = "high" +category = "security" + +# ── Code hygiene ────────────────────────────────────────────────────────────── +[[rule]] +pattern = "TODO|FIXME|HACK" +message = "Resolve TODO/FIXME/HACK before merging" +severity = "low" +category = "maintainability" + +[[rule]] +pattern = "debugger;" +message = "Remove debugger statement" +severity = "medium" +category = "quality" +files = ["*.ts", "*.js", "*.tsx", "*.jsx"] +``` + +Each rule supports: + +| Field | Required | Description | +| ---------- | -------- | ---------------------------------------------------------------------------- | +| `pattern` | ✓ | Regular expression matched against added lines | +| `message` | ✓ | Finding description shown in output | +| `severity` | | `high`, `medium`, or `low` (default: `medium`) | +| `category` | | `security`, `quality`, `performance`, `maintainability` (default: `quality`) | +| `files` | | File glob filter, e.g. `["*.ts", "*.tsx"]`. Omit to match all files | + ### Local symbol indexing (RAG) Build a symbol index so the model understands definitions of functions and types referenced in your diff: @@ -401,7 +469,6 @@ The items below are planned or under consideration. Contributions welcome — op - [ ] Incremental model updates — version-check HuggingFace before re-download - [ ] `diffmind install-hooks` — one command to install a `pre-push` git hook that blocks on High severity findings - [ ] **Watch mode** (`diffmind watch`) — re-review staged files automatically on each `git add`, no manual invocation needed -- [ ] **Custom rule file** (`.diffmind/rules.toml`) — team-defined regex patterns that run before the model, zero inference cost ### Medium-term diff --git a/apps/tui-cli/Cargo.toml b/apps/tui-cli/Cargo.toml index 523d26d..e987ae1 100644 --- a/apps/tui-cli/Cargo.toml +++ b/apps/tui-cli/Cargo.toml @@ -25,3 +25,4 @@ walkdir = { workspace = true } chrono = { workspace = true } lazy_static = { workspace = true } sysinfo = { workspace = true } +toml = { workspace = true } diff --git a/apps/tui-cli/src/main.rs b/apps/tui-cli/src/main.rs index 6fb0e82..0f919f9 100644 --- a/apps/tui-cli/src/main.rs +++ b/apps/tui-cli/src/main.rs @@ -17,6 +17,7 @@ mod download; mod git; mod indexer; mod rag; +mod rules; use crate::indexer::Indexer; @@ -103,13 +104,24 @@ async fn main() -> Result<()> { // 3. Resolve ticket / user story content (file path or inline text) let ticket = resolve_ticket(args.ticket.as_deref()); - // 4. Launch UI (TUI or static) + // 4. Load custom rules (zero cost — runs before the model) + let custom_rules = rules::load_custom_rules(&project_root); + + // 5. Launch UI (TUI or static) if args.tui { run_tui(diff, model_dir, project_root, args.model.clone(), ticket).await?; } else { let min_sev = parse_severity(&args.min_severity); - let has_findings = - run_static(&diff, &model_dir, &project_root, &args, min_sev, ticket).await?; + let has_findings = run_static( + &diff, + &model_dir, + &project_root, + &args, + min_sev, + ticket, + custom_rules, + ) + .await?; // Non-zero exit if any findings at or above --min-severity (CI gate). if has_findings { @@ -246,6 +258,7 @@ async fn run_static( args: &cli::Cli, min_severity: Severity, ticket: Option, + custom_rules: Vec, ) -> Result { let model_path = model_dir.join(format!("qwen2.5-coder-{}-instruct-q4_k_m.gguf", args.model)); let tokenizer_path = model_dir.join("tokenizer.json"); @@ -320,6 +333,7 @@ async fn run_static( let mut analyzer = ReviewAnalyzer::new_with_device(&model_bytes, &tokenizer_bytes, device_pref) .map_err(|e| anyhow::anyhow!(e.to_string()))? .with_languages(langs) + .with_custom_rules(custom_rules) .with_debug(args.debug); if let Some(req) = ticket { diff --git a/apps/tui-cli/src/rules.rs b/apps/tui-cli/src/rules.rs new file mode 100644 index 0000000..b3ba2a5 --- /dev/null +++ b/apps/tui-cli/src/rules.rs @@ -0,0 +1,45 @@ +use core_engine::CustomRule; +use serde::Deserialize; +use std::path::Path; + +/// Mirror of the TOML file structure — `[[rule]]` becomes a `Vec`. +#[derive(Deserialize, Default)] +struct RulesFile { + #[serde(default, rename = "rule")] + rule: Vec, +} + +/// Load custom rules from `/.diffmind/rules.toml`. +/// Returns an empty Vec (and prints a warning) if the file is missing or invalid. +pub fn load_custom_rules(project_root: &Path) -> Vec { + let path = project_root.join(".diffmind").join("rules.toml"); + if !path.exists() { + return vec![]; + } + + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + eprintln!(" ! Could not read .diffmind/rules.toml: {e}"); + return vec![]; + } + }; + + match toml::from_str::(&content) { + Ok(f) => { + if !f.rule.is_empty() { + eprintln!( + " {:<10} {} custom rule{}", + "Rules", + f.rule.len(), + if f.rule.len() == 1 { "" } else { "s" } + ); + } + f.rule + } + Err(e) => { + eprintln!(" ! Could not parse .diffmind/rules.toml: {e}"); + vec![] + } + } +} diff --git a/packages/core-engine/Cargo.toml b/packages/core-engine/Cargo.toml index 6b840bd..b24575a 100644 --- a/packages/core-engine/Cargo.toml +++ b/packages/core-engine/Cargo.toml @@ -14,6 +14,7 @@ serde_json = { workspace = true } thiserror = "2.0.18" log = "0.4" rand = { workspace = true } +regex = { workspace = true } # On macOS, enable Metal (Apple Silicon GPU) and Accelerate (CPU BLAS). # Cargo merges features — these are additive on top of the workspace dep. diff --git a/packages/core-engine/src/lib.rs b/packages/core-engine/src/lib.rs index 4d3f05a..3bef216 100644 --- a/packages/core-engine/src/lib.rs +++ b/packages/core-engine/src/lib.rs @@ -72,6 +72,34 @@ pub struct ReviewSummary { pub suggestions: Vec, } +/// A single user-defined rule loaded from `.diffmind/rules.toml`. +/// Matched against every added line in the diff before the AI model runs. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CustomRule { + /// Regex pattern matched against added lines (`+` lines) in the diff. + pub pattern: String, + /// Human-readable description shown as the finding's issue text. + pub message: String, + /// Severity: `"high"`, `"medium"`, or `"low"`. Defaults to `"medium"`. + #[serde(default = "default_rule_severity")] + pub severity: String, + /// Category: `"security"`, `"quality"`, `"performance"`, `"maintainability"`. + /// Defaults to `"quality"`. + #[serde(default = "default_rule_category")] + pub category: String, + /// Optional file glob filters (e.g. `["*.ts", "*.tsx"]`). + /// When empty the rule applies to every file in the diff. + #[serde(default)] + pub files: Vec, +} + +fn default_rule_severity() -> String { + "medium".into() +} +fn default_rule_category() -> String { + "quality".into() +} + /// Output of `generate_pr_description` — ready to paste into GitHub / GitLab. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct PrDescription { @@ -112,6 +140,8 @@ pub struct ReviewAnalyzer { requirements: Option, /// When true, print the raw model output and token counts to stderr for each chunk. debug: bool, + /// User-defined rules loaded from `.diffmind/rules.toml`. + custom_rules: Vec, } /// Hard upper bound on total tokens (prompt + generated) fed to the model. @@ -199,6 +229,7 @@ impl ReviewAnalyzer { languages: None, requirements: None, debug: false, + custom_rules: Vec::new(), }) } @@ -234,6 +265,14 @@ impl ReviewAnalyzer { self } + /// Load team-defined rules from `.diffmind/rules.toml`. + /// Rules are matched against added lines in the diff before the AI model runs — + /// zero inference cost, instant results. + pub fn with_custom_rules(mut self, rules: Vec) -> Self { + self.custom_rules = rules; + self + } + /// Like [`analyze_diff_chunked`] but calls `on_progress(done, total)` after /// each chunk completes so callers can display a live progress indicator. /// @@ -260,6 +299,7 @@ impl ReviewAnalyzer { let det_findings: Vec = detect_commented_out_code(diff) .into_iter() .chain(detect_removed_used_variables(diff)) + .chain(detect_custom_rule_violations(diff, &self.custom_rules)) .collect(); // Stream deterministic findings immediately — no need to wait for the LLM. @@ -1032,6 +1072,104 @@ fn contains_identifier(text: &str, name: &str) -> bool { false } +// ─── Deterministic rule: user-defined patterns from .diffmind/rules.toml ───── + +fn parse_severity_str(s: &str) -> Severity { + match s.to_lowercase().as_str() { + "high" => Severity::High, + "medium" | "med" => Severity::Medium, + _ => Severity::Low, + } +} + +fn parse_category_str(s: &str) -> Category { + match s.to_lowercase().as_str() { + "security" => Category::Security, + "performance" => Category::Performance, + "maintainability" => Category::Maintainability, + "compliance" => Category::Compliance, + _ => Category::Quality, + } +} + +/// Returns true when `file_path` matches the simple glob `pattern`. +/// Supported: `*` (any), `*.ext` (extension), exact filename, path suffix. +fn file_matches_glob(file_path: &str, pattern: &str) -> bool { + if pattern == "*" { + return true; + } + if let Some(ext) = pattern.strip_prefix("*.") { + return file_path.ends_with(&format!(".{ext}")); + } + file_path == pattern || file_path.ends_with(&format!("/{pattern}")) +} + +/// Run user-defined rules from `.diffmind/rules.toml` against all added lines +/// in the diff. Returns one finding per matched line per rule. +fn detect_custom_rule_violations(diff: &str, rules: &[CustomRule]) -> Vec { + use regex::Regex; + + if rules.is_empty() { + return vec![]; + } + + // Pre-compile patterns; silently skip rules with invalid regex. + let compiled: Vec<(usize, Regex)> = rules + .iter() + .enumerate() + .filter_map(|(i, r)| Regex::new(&r.pattern).ok().map(|re| (i, re))) + .collect(); + + let mut findings = Vec::new(); + let mut current_file = String::new(); + let mut line_num: u32 = 1; + + for line in diff.lines() { + if line.starts_with("diff --git ") { + if let Some(b_path) = line.split(" b/").nth(1) { + current_file = b_path.trim().to_string(); + } + } else if line.starts_with("@@ ") + && let Some(new_part) = line.split('+').nth(1) + && let Some(num_str) = new_part.split([',', ' ']).next() + { + line_num = num_str.parse().unwrap_or(1); + } else if line.starts_with('+') && !line.starts_with("+++") { + let content = &line[1..]; + for (idx, re) in &compiled { + let rule = &rules[*idx]; + // Apply file filter if present. + if !rule.files.is_empty() + && !rule + .files + .iter() + .any(|g| file_matches_glob(¤t_file, g)) + { + continue; + } + if re.is_match(content) { + findings.push(ReviewFinding { + file: current_file.clone(), + line: line_num, + severity: parse_severity_str(&rule.severity), + category: parse_category_str(&rule.category), + issue: rule.message.clone(), + suggested_fix: String::new(), + confidence: Some(1.0), + }); + } + } + line_num += 1; + } else if line.starts_with(' ') { + // Context line — exists in both old and new file. + line_num += 1; + } + // '-' removed lines do not increment the new-file line counter. + } + + findings +} + #[cfg(test)] mod tests { use super::*;