diff --git a/.agent.md b/.agent.md index 1251ce8a..1537a48b 100644 --- a/.agent.md +++ b/.agent.md @@ -8,7 +8,7 @@ Before creating any release PR, verify ALL of the following are updated: - [ ] `Cargo.toml` — version bump - [ ] All target issues merged to `develop` -- [ ] `cargo test` — all 485+ tests pass +- [ ] `cargo test` — all 495+ tests pass - [ ] `cargo clippy --all-targets -- -D warnings` — clean - [ ] `cargo fmt --all -- --check` — clean - [ ] `cargo build --release` — no errors diff --git a/AGENT.md b/AGENT.md index 72566137..b4b246bc 100644 --- a/AGENT.md +++ b/AGENT.md @@ -17,7 +17,7 @@ no managed API, no cloud service. Runs locally against diffs, scans, or branches ```bash cargo build # Build (debug) cargo build --release # Build (release) -cargo test # Run all 485 tests +cargo test # Run all 495 tests cargo clippy --all-targets -- -D warnings # Lint (strict) cargo fmt --all -- --check # Format check ``` @@ -33,6 +33,7 @@ src/ │ ├── mod.rs │ ├── review.rs # cora review (diff-based review) │ ├── scan.rs # cora scan (full-file scan) +│ ├── debt.rs # cora debt (tech debt report) │ ├── config_cmd.rs # cora config (show/set/validate) │ ├── auth.rs # cora auth (API key management) │ ├── hook_cmd.rs # cora hook (pre-commit hook install/uninstall) @@ -56,6 +57,7 @@ src/ │ ├── chunker.rs # Auto-chunking large diffs │ ├── profiles.rs # Quality profiles (strict/balanced/lax) │ ├── quality_gate.rs # Quality gate thresholds + pass/fail +│ ├── debt_tracker.rs # Tech debt metrics + trend tracking │ ├── security_scanner.rs # Static security pattern matching │ ├── language_analyzer.rs # Language-specific review guidance │ ├── secrets_scanner.rs # Secret/credential detection @@ -98,8 +100,8 @@ src/ ## Testing ```bash -cargo test # 485 tests total - # 463 unit tests +cargo test # 495 tests total + # 473 unit tests # 16 CLI integration tests # 6 config tests cargo test --no-verify # Skip pre-commit hooks (avoids timeout in hooks) @@ -230,7 +232,7 @@ Missing any = release blocker. ### 1. Code - [ ] All target issues merged to `develop` -- [ ] `cargo test` — all 453+ tests pass +- [ ] `cargo test` — all 495+ tests pass - [ ] `cargo clippy --all-targets -- -D warnings` — clean - [ ] `cargo fmt --all -- --check` — clean - [ ] `cargo build --release` — no errors @@ -300,7 +302,7 @@ When submitting cora to directories, aggregators, or showcases (Trendshift, etc. ### Key Metrics to Mention -- Test count (453+) +- Test count (495+) - Lines of Rust code (16,800+) - CI checks (10) - GitHub Marketplace action published diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 690e25de..5a5264d2 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -45,6 +45,13 @@ Complete command reference for the cora CLI. | `cora auth remove` | Remove stored API key | | `cora providers` | List detected AI providers | | `cora upload-sarif` `` | Upload SARIF to GitHub Code Scanning | +| `cora debt` | Show tech debt report from review history | +| `cora debt --json` | Debt report as JSON (for CI/dashboards) | +| `cora debt --trend` | Quality score trend graph | +| `cora debt --badge` | Shields.io badge JSON endpoint | +| `cora debt --estimate` | Show estimated fix time | +| `cora debt --since v0.4.5` | Filter by git tag or date | +| `cora debt --branch main` | Filter by branch | | `cora completion` `` | Generate shell completions (bash/zsh/fish) | | `cora mcp` | Start MCP server for AI coding agents (Claude Code, Cursor, Windsurf) | | `cora debt` | Show tech debt report from review history | diff --git a/docs/configuration.md b/docs/configuration.md index c01957c7..d856b760 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -219,6 +219,47 @@ quality_gate: When `quality_gate.enabled` is `false` (default), quality gate is skipped. The existing `--ci` flag and `hook.on_violation` settings continue to work as before. +## Debt Tracking + +cora tracks review findings over time and reports quality trends. After each review, a lightweight JSON snapshot is saved to `.cora/history/`. Use `cora debt` to view the aggregated report. + +```yaml +debt: + enabled: true # enable auto-save of review snapshots + history_dir: .cora/history # directory for snapshot files + retention_days: 90 # auto-cleanup old snapshots +``` + +### CLI Usage + +```bash +cora debt # show debt report table +cora debt --json # machine-readable JSON +cora debt --trend # quality score trend graph +cora debt --badge # shields.io badge JSON +cora debt --estimate # show estimated fix time +cora debt --since v0.4.5 # filter by git tag or date +``` + +### Quality Score + +Ranges from 0–10 (10 = no issues). Penalties per finding: + +| Severity | Penalty | +|----------|--------| +| Critical | -2.0 | +| Major | -1.0 | +| Minor | -0.3 | +| Info | -0.1 | + +### Badge Integration + +Use `cora debt --badge` output as a shields.io endpoint: + +```markdown +[![Quality](https://img.shields.io/endpoint?url=https://example.com/cora-badge.json)]() +``` + ## Secrets Pre-Scan cora runs a deterministic secrets scan before the AI review. 12 built-in patterns detect leaked credentials: diff --git a/docs/usage.md b/docs/usage.md index 50867721..21c76221 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -144,6 +144,51 @@ cora mcp See [Configuration → MCP Server](./configuration#mcp-server) for setup guides for Claude Code, Cursor, and other agents. +## Tech Debt Tracking + +cora automatically tracks review findings over time, letting you answer "is our codebase getting better or worse?" + +```bash +# Show debt report +$ cora debt + +# Show trend graph over review history +$ cora debt --trend + +# Machine-readable output for CI dashboards +$ cora debt --json + +# Shields.io badge JSON (embed in README) +$ cora debt --badge + +# Estimated fix time +$ cora debt --estimate + +# Filter by date or git tag +$ cora debt --since 2026-06-01 +$ cora debt --since v0.4.5 + +# Filter by branch +$ cora debt --branch develop +``` + +Snapshots are saved to `.cora/history/` after each review. Configure in `.cora.yaml`: + +```yaml +debt: + enabled: true # default + history_dir: .cora/history + retention_days: 90 +``` + +Quality score ranges from 0 (worst) to 10 (best). Each finding reduces the score: +- Critical: -2.0 +- Major: -1.0 +- Minor: -0.3 +- Info: -0.1 + +Trend indicators: ▼ improving (fewer findings), ► stable, ▲ worsening (more findings). + ## Tips - Use `cora review` with no flags for the fastest pre-commit feedback diff --git a/src/commands/debt.rs b/src/commands/debt.rs index fe67cf33..8fab1b5a 100644 --- a/src/commands/debt.rs +++ b/src/commands/debt.rs @@ -21,6 +21,10 @@ pub struct DebtOptions { pub since: Option, /// Filter: only snapshots for this branch. pub branch: Option, + /// Generate shields.io badge JSON. + pub badge: bool, + /// Show estimated debt fix time. + pub estimate: bool, } /// Execute the `cora debt` subcommand. @@ -68,8 +72,29 @@ pub fn execute_debt(opts: &DebtOptions) -> Result { let report = debt_tracker::aggregate(&snapshots); + if opts.badge { + let badge = if opts.estimate { + debt_tracker::generate_badge_with_debt(&report) + } else { + debt_tracker::generate_badge(&report) + }; + let json = serde_json::to_string(&badge)?; + println!("{json}"); + return Ok(EXIT_OK); + } + if opts.json { - let json = serde_json::to_string_pretty(&report)?; + // Add debt estimation to report output + let hours = debt_tracker::estimate_debt_hours(&report.findings); + let mut json_report = serde_json::to_value(&report)?; + if let Some(obj) = json_report.as_object_mut() { + obj.insert("estimated_debt_hours".to_string(), serde_json::json!(hours)); + obj.insert( + "estimated_debt_human".to_string(), + serde_json::json!(debt_tracker::format_debt_hours(hours)), + ); + } + let json = serde_json::to_string_pretty(&json_report)?; println!("{json}"); return Ok(EXIT_OK); } @@ -79,7 +104,7 @@ pub fn execute_debt(opts: &DebtOptions) -> Result { println!(); } - print_debt_table(&report); + print_debt_table(&report, opts.estimate); Ok(EXIT_OK) } @@ -148,7 +173,7 @@ fn resolve_tag_date(tag: &str) -> Option> { } /// Print the debt report as a terminal table. -fn print_debt_table(report: &debt_tracker::DebtReport) { +fn print_debt_table(report: &debt_tracker::DebtReport, show_estimate: bool) { let mut out = Vec::new(); // Header @@ -199,6 +224,13 @@ fn print_debt_table(report: &debt_tracker::DebtReport) { ) .unwrap(); + // Debt estimation + if show_estimate { + let hours = debt_tracker::estimate_debt_hours(&report.findings); + let human = debt_tracker::format_debt_hours(hours); + writeln!(out, " Est. Debt: {}", human.yellow()).unwrap(); + } + // Severity breakdown writeln!(out).unwrap(); writeln!(out, " {}", "Severity Breakdown:".white().bold()).unwrap(); diff --git a/src/engine/debt_tracker.rs b/src/engine/debt_tracker.rs index aeebcb59..b8a56ddb 100644 --- a/src/engine/debt_tracker.rs +++ b/src/engine/debt_tracker.rs @@ -510,6 +510,110 @@ impl DebtConfig { } } +// ─── Debt estimation ─── + +/// Estimated fix time in hours per severity level. +const DEBT_HOURS_CRITICAL: f64 = 2.0; +const DEBT_HOURS_MAJOR: f64 = 1.0; +const DEBT_HOURS_MINOR: f64 = 0.25; +const DEBT_HOURS_INFO: f64 = 0.1; + +/// Estimate total fix time from severity counts. +#[allow(dead_code)] +pub fn estimate_debt_hours(findings: &HashMap) -> f64 { + let critical = findings.get("critical").copied().unwrap_or(0) as f64; + let major = findings.get("major").copied().unwrap_or(0) as f64; + let minor = findings.get("minor").copied().unwrap_or(0) as f64; + let info = findings.get("info").copied().unwrap_or(0) as f64; + + critical * DEBT_HOURS_CRITICAL + + major * DEBT_HOURS_MAJOR + + minor * DEBT_HOURS_MINOR + + info * DEBT_HOURS_INFO +} + +/// Format debt hours as human-readable string. +#[allow(dead_code)] +pub fn format_debt_hours(hours: f64) -> String { + if hours < 1.0 { + format!("~{:.0}min", hours * 60.0) + } else if hours < 8.0 { + format!("~{:.1}h", hours) + } else { + format!("~{:.1}d", hours / 8.0) + } +} + +// ─── Badge generation ─── + +/// Generate a shields.io-compatible JSON badge endpoint for quality score. +/// +/// Output can be hosted as a static JSON file and used with: +/// `![quality](https://img.shields.io/endpoint?url=...badge.json)` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BadgeData { + /// Schema version (always 1). + #[serde(rename = "schemaVersion")] + pub schema_version: u32, + /// Badge label. + pub label: String, + /// Badge message (score + trend). + pub message: String, + /// Badge color: green/yellow/red. + pub color: String, +} + +#[allow(dead_code)] +pub fn generate_badge(report: &DebtReport) -> BadgeData { + let score = report.quality_score_avg; + let trend_arrow = match report.trend.as_str() { + "improving" => " ▼", // fewer findings = improving + "worsening" => " ▲", // more findings = worsening + _ => "", + }; + let color = if score >= 8.0 { + "green" + } else if score >= 5.0 { + "yellow" + } else { + "red" + }; + + BadgeData { + schema_version: 1, + label: "code quality".to_string(), + message: format!("{:.1}/10{trend_arrow}", score), + color: color.to_string(), + } +} + +/// Generate a badge that includes debt estimation. +#[allow(dead_code)] +pub fn generate_badge_with_debt(report: &DebtReport) -> BadgeData { + let score = report.quality_score_avg; + let hours = estimate_debt_hours(&report.findings); + let human = format_debt_hours(hours); + let trend_arrow = match report.trend.as_str() { + "improving" => " ▼", // fewer findings = improving + "worsening" => " ▲", // more findings = worsening + _ => "", + }; + let color = if score >= 8.0 { + "green" + } else if score >= 5.0 { + "yellow" + } else { + "red" + }; + + BadgeData { + schema_version: 1, + label: "code quality".to_string(), + message: format!("{:.1}/10 · debt {human}{trend_arrow}", score), + color: color.to_string(), + } +} + // ─── Tests ─── #[cfg(test)] @@ -994,4 +1098,118 @@ mod tests { assert_eq!(remaining.len(), 1); assert_eq!(remaining[0].commit.as_deref(), Some("new2222")); } + + // ─── estimate_debt_hours ─── + + #[test] + fn estimate_debt_empty() { + let hours = estimate_debt_hours(&HashMap::new()); + assert!((hours - 0.0).abs() < f64::EPSILON); + } + + #[test] + fn estimate_debt_critical_only() { + let mut findings = HashMap::new(); + findings.insert("critical".to_string(), 3); + let hours = estimate_debt_hours(&findings); + assert!((hours - 6.0).abs() < f64::EPSILON); // 3 * 2.0 + } + + #[test] + fn estimate_debt_mixed() { + let mut findings = HashMap::new(); + findings.insert("critical".to_string(), 1); + findings.insert("major".to_string(), 2); + findings.insert("minor".to_string(), 4); + findings.insert("info".to_string(), 3); + let hours = estimate_debt_hours(&findings); + // 1*2.0 + 2*1.0 + 4*0.25 + 3*0.1 = 2.0 + 2.0 + 1.0 + 0.3 = 5.3 + assert!((hours - 5.3).abs() < 0.001); + } + + // ─── format_debt_hours ─── + + #[test] + fn format_debt_minutes() { + assert_eq!(format_debt_hours(0.5), "~30min"); + } + + #[test] + fn format_debt_hours_display() { + assert_eq!(format_debt_hours(3.5), "~3.5h"); + } + + #[test] + fn format_debt_days() { + assert_eq!(format_debt_hours(16.0), "~2.0d"); + } + + // ─── generate_badge ─── + + #[test] + fn badge_high_score() { + let report = aggregate(&[DebtSnapshot::from_review( + &[], + None, + None, + None, + 1, + None, + None, + )]); + let badge = generate_badge(&report); + assert_eq!(badge.schema_version, 1); + assert_eq!(badge.label, "code quality"); + assert!(badge.message.contains("10.0/10")); + assert_eq!(badge.color, "green"); + } + + #[test] + fn badge_medium_score() { + let report = aggregate(&[DebtSnapshot::from_review( + &[ + make_issue(Severity::Major, "bug"), + make_issue(Severity::Major, "bug"), + make_issue(Severity::Major, "bug"), + ], + None, + None, + None, + 1, + None, + None, + )]); + let badge = generate_badge(&report); + // 10.0 - 3.0 = 7.0, which is >= 5.0 and < 8.0 = yellow + assert_eq!(badge.color, "yellow"); + } + + #[test] + fn badge_low_score() { + let issues: Vec = (0..6) + .map(|_| make_issue(Severity::Critical, "security")) + .collect(); + let report = aggregate(&[DebtSnapshot::from_review( + &issues, None, None, None, 1, None, None, + )]); + let badge = generate_badge(&report); + assert_eq!(badge.color, "red"); + } + + #[test] + fn badge_serializes_with_rename() { + let report = aggregate(&[DebtSnapshot::from_review( + &[], + None, + None, + None, + 1, + None, + None, + )]); + let badge = generate_badge(&report); + let json = serde_json::to_string(&badge).unwrap(); + // Should use camelCase for shields.io compatibility + assert!(json.contains("\"schemaVersion\":")); + } } diff --git a/src/main.rs b/src/main.rs index 77d46a3a..a64adcdd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -267,6 +267,14 @@ enum Command { /// Filter by branch name #[clap(long)] branch: Option, + + /// Output shields.io badge JSON + #[clap(long)] + badge: bool, + + /// Show estimated debt fix time + #[clap(long)] + estimate: bool, }, } @@ -533,12 +541,16 @@ async fn main() -> Result<()> { trend, since, branch, + badge, + estimate, } => { let opts = debt::DebtOptions { json, trend, since, branch, + badge, + estimate, }; debt::execute_debt(&opts)? }