hotspots-cli 2.0.0

CLI that finds the riskiest code in your codebase by combining complexity with git change history (Local Risk Score) across TypeScript, JavaScript, Go, Python, Rust, and Java
//! `hotspots coordinate` — ownership risk for a caller-specified file set,
//! before work starts.
//!
//! v1 minimal baseline (`hotspots-research/docs/promotion-briefs/coordinate-v1-minimal.md`):
//! given an explicit `--files` list, reports per-file ownership signals.
//! Deliberately does not depend on any snapshot or AST/CFG analysis — this
//! command only needs git log.
//!
//! `knowledge_mode` (`hotspots-research/docs/promotion-briefs/coordinate-knowledge-mode.md`):
//! `FileOwnership.knowledge_mode` fires `"concentrated"` for narrow-ownership
//! files, derived purely from already-shipped `author_entropy`/`author_count`.
//!
//! `--diff`/`--staged` (`hotspots-research/docs/promotion-briefs/coordinate-diff-mode.md`):
//! alternate ways to derive the file set instead of `--files` — a unified
//! diff on stdin, or the current git staging area.
//!
//! hotspots 2.0 (`docs/promotion-briefs/coordinate-remove-coupling-recommendation.md`,
//! folded into `docs/master-schema-spec.md`): the coupling-triggered
//! `recommendation` field was removed (no validated finding supports its sole
//! trigger — see the brief), and the output is now the `coordinate` section
//! of the unified master-schema envelope instead of a standalone
//! `schema_version: 1` struct.
//!
//! hotspots 2.0, "only fields proven to work": `within_set`/`hidden_dependencies`
//! (raw co-change coupling facts, with no recommendation derived from them)
//! were removed entirely once this stricter bar was adopted — they shipped on
//! *negative* evidence: F150 found `coupling_ratio >= 0.7` pairs collide
//! *less* than churn-matched baselines, and F156 found coupling adds nothing
//! once ownership is controlled for. `ownership`/`knowledge_mode` stays — that
//! one has real positive backing (F146 → F155 → F157).

use anyhow::{bail, Context, Result};
use hotspots_core::history_signals::compute_history_signals_for_repo;
use hotspots_core::snapshot::SNAPSHOT_SCHEMA_VERSION;
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::process::Command;

/// `knowledge_mode` fires "concentrated" when a file's ownership is this
/// narrow. Values from `coordinate-knowledge-mode.md` (F146) — 100%
/// cross-check match on every repo tested, not re-derived here.
const CONCENTRATED_MAX_AUTHOR_COUNT: u32 = 3;
const CONCENTRATED_MAX_NORMALIZED_ENTROPY: f64 = 0.5;

pub(crate) struct CoordinateArgs {
    pub files: Option<String>,
    /// Pre-read stdin contents from `--diff`; `None` if `--diff` wasn't passed.
    /// Read by `main.rs`, not here — keeps this module free of stdin I/O.
    pub diff: Option<String>,
    pub staged: bool,
    pub path: PathBuf,
}

#[derive(Debug, Serialize)]
struct FileOwnership {
    file: String,
    author_count: u32,
    author_entropy: f64,
    newcomer_rate: Option<f64>,
    knowledge_mode: Option<&'static str>,
}

#[derive(Debug, Serialize)]
struct CoordinateSection {
    input_files: Vec<String>,
    ownership: Vec<FileOwnership>,
}

#[derive(Debug, Serialize)]
struct CoordinateEnvelope {
    schema_version: u32,
    coordinate: CoordinateSection,
}

/// `knowledge_mode` classifier (F146) — fires "concentrated" only. "diffuse"
/// is deliberately unimplemented: F146 found it unreliable and repo-dependent
/// (5.9%-80% cross-check match), blocked on an author-identity-canonicalization
/// gap this brief does not attempt to fix.
fn classify_knowledge_mode(author_count: u32, author_entropy: f64) -> Option<&'static str> {
    if author_count <= 1 {
        return None;
    }
    let normalized_entropy = author_entropy / (author_count as f64).log2();
    if author_count <= CONCENTRATED_MAX_AUTHOR_COUNT
        && normalized_entropy < CONCENTRATED_MAX_NORMALIZED_ENTROPY
    {
        Some("concentrated")
    } else {
        None
    }
}

/// Extracts modified file paths from a unified diff via two rules: `+++ b/`
/// (additions, modifications, content-changing renames — new path only) and
/// `rename to` (pure renames with no content change, which emit no `+++`
/// line at all). Deduplicated, first-occurrence order preserved. Binary
/// files and deletions are intentionally excluded — verified against real
/// diffs in `coordinate-diff-input-pr-preflight.md`.
fn extract_files_from_diff(diff_text: &str) -> Vec<String> {
    let mut seen = std::collections::HashSet::new();
    let mut files = Vec::new();
    for line in diff_text.lines() {
        let path = if let Some(rest) = line.strip_prefix("+++ b/") {
            Some(rest)
        } else {
            line.strip_prefix("rename to ")
        };
        if let Some(path) = path {
            if !path.is_empty() && seen.insert(path.to_string()) {
                files.push(path.to_string());
            }
        }
    }
    files
}

/// `--staged`: `git diff --cached --name-only` already lists a pure rename's
/// new path natively — no regex parsing needed on this path (verified
/// directly, separate simpler code path from `--diff`'s text parsing).
fn staged_files(repo_root: &Path) -> Result<Vec<String>> {
    let output = Command::new("git")
        .arg("diff")
        .arg("--cached")
        .arg("--name-only")
        .current_dir(repo_root)
        .output()
        .context("run git diff --cached --name-only")?;
    if !output.status.success() {
        bail!(
            "git diff --cached --name-only failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }
    Ok(String::from_utf8_lossy(&output.stdout)
        .lines()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .collect())
}

/// Resolves the input file set from whichever of `--files`/`--diff`/`--staged`
/// was provided, enforcing mutual exclusion. Pure dispatch logic split out
/// from `handle_coordinate` so it's unit-testable without a real repo or git
/// history — `staged_files` is the only branch that still needs `repo_root`
/// for its own `git` invocation.
fn resolve_input_files(args: &CoordinateArgs, repo_root: &Path) -> Result<Vec<String>> {
    let provided = [args.files.is_some(), args.diff.is_some(), args.staged]
        .iter()
        .filter(|&&p| p)
        .count();
    if provided > 1 {
        bail!("--files, --diff, and --staged are mutually exclusive");
    }

    if let Some(files) = &args.files {
        Ok(files
            .split(',')
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
            .collect())
    } else if let Some(diff_text) = &args.diff {
        Ok(extract_files_from_diff(diff_text))
    } else if args.staged {
        staged_files(repo_root)
    } else {
        bail!("one of --files, --diff, or --staged is required");
    }
}

pub(crate) fn handle_coordinate(args: CoordinateArgs) -> Result<()> {
    let repo_root = args.path.canonicalize().context("resolve repo path")?;
    let input_files = resolve_input_files(&args, &repo_root)?;

    let ownership_signals = compute_history_signals_for_repo(&repo_root);

    let ownership: Vec<FileOwnership> = input_files
        .iter()
        .map(|file| {
            let signals = ownership_signals.get(file);
            let author_count = signals.map(|s| s.author_count).unwrap_or(0);
            let author_entropy = signals.map(|s| s.author_entropy).unwrap_or(0.0);
            FileOwnership {
                file: file.clone(),
                author_count,
                author_entropy,
                newcomer_rate: signals.and_then(|s| s.newcomer_rate),
                knowledge_mode: classify_knowledge_mode(author_count, author_entropy),
            }
        })
        .collect();

    let output = CoordinateEnvelope {
        schema_version: SNAPSHOT_SCHEMA_VERSION,
        coordinate: CoordinateSection {
            input_files,
            ownership,
        },
    };

    println!("{}", serde_json::to_string_pretty(&output)?);
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    // -- classify_knowledge_mode (coordinate-knowledge-mode.md) --

    #[test]
    fn knowledge_mode_fires_concentrated() {
        // 2 authors, entropy 1.0 (50/50 split) -> normalized_entropy = 1.0,
        // NOT concentrated (fails the < 0.5 bound) despite low author_count.
        assert_eq!(classify_knowledge_mode(2, 1.0), None);
        // 2 authors, entropy near 0 (one author dominates almost entirely).
        assert_eq!(classify_knowledge_mode(2, 0.1), Some("concentrated"));
        // 3 authors at the author_count boundary, low entropy.
        assert_eq!(classify_knowledge_mode(3, 0.2), Some("concentrated"));
    }

    #[test]
    fn knowledge_mode_does_not_fire_above_author_count_bound() {
        // 5 authors, even with low raw entropy, exceeds CONCENTRATED_MAX_AUTHOR_COUNT.
        assert_eq!(classify_knowledge_mode(5, 0.2), None);
    }

    #[test]
    fn knowledge_mode_degenerate_single_author() {
        // author_count <= 1 must not panic on log2(1) == 0.0 division.
        assert_eq!(classify_knowledge_mode(1, 0.0), None);
        assert_eq!(classify_knowledge_mode(0, 0.0), None);
    }

    // -- extract_files_from_diff (coordinate-diff-mode.md) --

    #[test]
    fn extract_files_addition_or_modification() {
        let diff = "diff --git a/foo.rs b/foo.rs\n\
                     index 111..222 100644\n\
                     --- a/foo.rs\n\
                     +++ b/foo.rs\n\
                     @@ -1 +1 @@\n\
                     -old\n\
                     +new\n";
        assert_eq!(extract_files_from_diff(diff), vec!["foo.rs"]);
    }

    #[test]
    fn extract_files_excludes_deletion() {
        let diff = "diff --git a/gone.rs b/gone.rs\n\
                     deleted file mode 100644\n\
                     index 111..000\n\
                     --- a/gone.rs\n\
                     +++ /dev/null\n\
                     @@ -1 +0,0 @@\n\
                     -bye\n";
        assert!(extract_files_from_diff(diff).is_empty());
    }

    #[test]
    fn extract_files_content_changing_rename_uses_new_path_only() {
        let diff = "diff --git a/old_path.rs b/new_path.rs\n\
                     similarity index 90%\n\
                     rename from old_path.rs\n\
                     rename to new_path.rs\n\
                     index 111..222 100644\n\
                     --- a/old_path.rs\n\
                     +++ b/new_path.rs\n\
                     @@ -1 +1 @@\n\
                     -old\n\
                     +new\n";
        assert_eq!(extract_files_from_diff(diff), vec!["new_path.rs"]);
    }

    #[test]
    fn extract_files_pure_rename_has_no_plus_plus_plus_line() {
        // 100% similarity: git emits only rename from/to, no +++/--- at all.
        let diff = "diff --git a/old.rs b/new.rs\n\
                     similarity index 100%\n\
                     rename from old.rs\n\
                     rename to new.rs\n";
        assert_eq!(extract_files_from_diff(diff), vec!["new.rs"]);
    }

    #[test]
    fn extract_files_binary_addition_excluded() {
        let diff = "diff --git a/img.png b/img.png\n\
                     new file mode 100644\n\
                     index 000..111\n\
                     Binary files /dev/null and b/img.png differ\n";
        assert!(extract_files_from_diff(diff).is_empty());
    }

    #[test]
    fn extract_files_deduplicates_preserving_first_occurrence() {
        let diff = "+++ b/a.rs\n+++ b/a.rs\n+++ b/b.rs\n";
        assert_eq!(extract_files_from_diff(diff), vec!["a.rs", "b.rs"]);
    }

    #[test]
    fn extract_files_ignores_malformed_empty_path_line() {
        // A "+++ b/" line with nothing after the prefix must not produce an
        // empty-string entry in the file list.
        let diff = "+++ b/\n+++ b/real.rs\n";
        assert_eq!(extract_files_from_diff(diff), vec!["real.rs"]);
    }

    // -- resolve_input_files (mutual exclusion / dispatch, previously only
    // covered by CLI-level smoke testing, not a Rust unit test) --

    fn args_with(files: Option<&str>, diff: Option<&str>, staged: bool) -> CoordinateArgs {
        CoordinateArgs {
            files: files.map(str::to_string),
            diff: diff.map(str::to_string),
            staged,
            path: PathBuf::from("."),
        }
    }

    #[test]
    fn resolve_input_files_from_files() {
        let args = args_with(Some("a.rs, b.rs ,,c.rs"), None, false);
        let files = resolve_input_files(&args, Path::new(".")).unwrap();
        assert_eq!(files, vec!["a.rs", "b.rs", "c.rs"]);
    }

    #[test]
    fn resolve_input_files_from_diff() {
        let args = args_with(None, Some("+++ b/x.rs\n"), false);
        let files = resolve_input_files(&args, Path::new(".")).unwrap();
        assert_eq!(files, vec!["x.rs"]);
    }

    #[test]
    fn resolve_input_files_rejects_more_than_one_source() {
        let args = args_with(Some("a.rs"), Some("+++ b/x.rs\n"), false);
        let err = resolve_input_files(&args, Path::new(".")).unwrap_err();
        assert!(err.to_string().contains("mutually exclusive"));
    }

    #[test]
    fn resolve_input_files_rejects_none_provided() {
        let args = args_with(None, None, false);
        let err = resolve_input_files(&args, Path::new(".")).unwrap_err();
        assert!(err.to_string().contains("required"));
    }

    // -- staged_files (coordinate-diff-mode.md, acceptance criterion 3 —
    // integration-style against a real git repo with a staged rename) --

    fn run_git(repo: &Path, args: &[&str]) {
        let status = Command::new("git")
            .args(args)
            .current_dir(repo)
            .status()
            .expect("run git");
        assert!(status.success(), "git {args:?} failed");
    }

    #[test]
    fn staged_files_lists_pure_rename_new_path_natively() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = tmp.path();
        run_git(repo, &["init", "-q"]);
        run_git(repo, &["config", "user.email", "test@example.com"]);
        run_git(repo, &["config", "user.name", "Test"]);
        std::fs::write(repo.join("old.rs"), "fn main() {}\n").unwrap();
        run_git(repo, &["add", "old.rs"]);
        run_git(repo, &["commit", "-q", "-m", "init"]);
        run_git(repo, &["mv", "old.rs", "new.rs"]);

        let files = staged_files(repo).unwrap();
        assert_eq!(files, vec!["new.rs".to_string()]);
    }
}