Skip to main content

kardo_core/scoring/
engine.rs

1//! Main scoring engine: weighted average + floor penalties.
2
3use crate::analysis::{
4    AgentSetupResult, ConfigQualityResult, FreshnessResult, IntegrityResult, StructureResult,
5};
6
7use super::types::*;
8
9/// Scoring weights from Blueprint Section 4.
10const W_FRESHNESS: f64 = 0.30;
11const W_CONFIGURATION: f64 = 0.25;
12const W_INTEGRITY: f64 = 0.20;
13const W_AGENT_SETUP: f64 = 0.15;
14const W_STRUCTURE: f64 = 0.10;
15
16/// Floor penalty thresholds.
17/// Calibrated 2026-02-23 on 84-repo benchmark (batch 3).
18/// CAP_ZERO lowered from 0.45 to 0.20 to match expert scores for
19/// repos with no AI config (experts rate ~20%).
20const FLOOR_LOW_THRESHOLD: f64 = 0.2;
21const FLOOR_ZERO_THRESHOLD: f64 = 0.001;
22const CAP_ZERO: f64 = 0.20;
23
24/// Computes the overall AI-Readiness Score from component analysis results.
25pub struct ScoringEngine;
26
27impl ScoringEngine {
28    /// Compute the AI-Readiness Score from analysis results.
29    #[must_use]
30    pub fn score(
31        freshness: &FreshnessResult,
32        integrity: &IntegrityResult,
33        config_quality: &ConfigQualityResult,
34        agent_setup: &AgentSetupResult,
35        structure: &StructureResult,
36    ) -> ProjectScore {
37        let components = ComponentScores {
38            freshness: freshness.score,
39            configuration: config_quality.score,
40            integrity: integrity.score,
41            agent_setup: agent_setup.score,
42            structure: structure.score,
43        };
44
45        // Weighted average
46        let mut total = W_FRESHNESS * components.freshness
47            + W_CONFIGURATION * components.configuration
48            + W_INTEGRITY * components.integrity
49            + W_AGENT_SETUP * components.agent_setup
50            + W_STRUCTURE * components.structure;
51
52        let all_scores = [
53            components.freshness,
54            components.configuration,
55            components.integrity,
56            components.agent_setup,
57            components.structure,
58        ];
59
60        // BUG-3 fix: Bonus for exceptional quality — applied BEFORE floor penalties.
61        // Rewards repos where ALL components are strong, lifting the ~80.6 ceiling.
62        let min_component = all_scores.iter().cloned().fold(f64::INFINITY, f64::min);
63        let bonus = if min_component >= 0.85 {
64            // All components excellent: bonus up to +0.08
65            ((min_component - 0.85) * 0.8).min(0.08)
66        } else if min_component >= 0.75 {
67            // All components good: small bonus up to +0.03
68            (min_component - 0.75) * 0.3
69        } else {
70            0.0
71        };
72        total = (total + bonus).min(1.0); // Cap at 1.0 to preserve perfect-score semantics
73
74        // Floor penalties
75        if all_scores.iter().any(|&s| s < FLOOR_ZERO_THRESHOLD) {
76            total = total.min(CAP_ZERO);
77        } else if all_scores.iter().any(|&s| s < FLOOR_LOW_THRESHOLD) {
78            // BUG-2 fix: Dynamic CAP_LOW based on severity and count of low components.
79            // Replaces flat 0.55 cap that caused 9-repo cluster in benchmark.
80            let low_count = all_scores
81                .iter()
82                .filter(|&&s| (FLOOR_ZERO_THRESHOLD..FLOOR_LOW_THRESHOLD).contains(&s))
83                .count();
84            let avg_low_deficit = if low_count > 0 {
85                all_scores
86                    .iter()
87                    .filter(|&&s| (FLOOR_ZERO_THRESHOLD..FLOOR_LOW_THRESHOLD).contains(&s))
88                    .map(|&s| FLOOR_LOW_THRESHOLD - s)
89                    .sum::<f64>()
90                    / low_count as f64
91            } else {
92                0.0
93            };
94
95            // Cap range: 0.45 (severe, multiple low) to 0.65 (mild, single slightly-low)
96            let dynamic_cap = if low_count >= 3 {
97                0.45
98            } else if low_count == 2 {
99                0.50
100            } else {
101                // Single low component: scale 0.55–0.65 based on deficit severity
102                // deficit 0.0 (just at threshold) → cap 0.65
103                // deficit 0.2 (component=0, but > FLOOR_ZERO_THRESHOLD) → cap 0.45
104                (0.65 - avg_low_deficit * 1.0).clamp(0.45, 0.65)
105            };
106            total = total.min(dynamic_cap);
107        }
108
109        let traffic_light = TrafficLight::from_score(total);
110
111        // Generate issues from analysis results
112        let mut issues = Vec::new();
113        Self::generate_freshness_issues(freshness, &mut issues);
114        Self::generate_integrity_issues(integrity, &mut issues);
115        Self::generate_config_issues(config_quality, &mut issues);
116
117        // Enrich issues with priority metadata
118        for issue in &mut issues {
119            Self::enrich_issue(issue, &components);
120        }
121
122        // Sort by severity rank (Blocking first, then High, Medium, Low)
123        issues.sort_by_key(|i| (i.severity.rank(), std::cmp::Reverse(i.priority_score)));
124
125        ProjectScore {
126            total,
127            components,
128            traffic_light,
129            issues,
130        }
131    }
132
133    /// Enrich a single issue with fix_type, effort, score_delta, and priority_score.
134    fn enrich_issue(issue: &mut QualityIssue, components: &ComponentScores) {
135        // Assign fix_type and effort based on issue id pattern
136        let (fix_type, effort) = Self::classify_fix(&issue.id);
137        issue.fix_type = fix_type;
138        issue.effort_minutes = Some(effort);
139
140        // Estimate score delta
141        issue.score_delta_est = Some(Self::estimate_score_delta(issue.severity, issue.category, components));
142
143        // Compute composite priority
144        issue.priority_score = Self::compute_priority(issue);
145    }
146
147    /// Map issue id pattern to fix type and estimated effort (minutes).
148    fn classify_fix(issue_id: &str) -> (FixType, u32) {
149        if issue_id.starts_with("config-missing-") {
150            (FixType::Scaffold, 1)
151        } else if issue_id.starts_with("freshness-stale-") {
152            (FixType::Update, 5)
153        } else if issue_id.starts_with("freshness-aging-") {
154            (FixType::Manual, 15)
155        } else if issue_id.starts_with("freshness-coupling-") {
156            (FixType::Update, 5)
157        } else if issue_id.starts_with("integrity-") {
158            // Distinguish by severity set during generation
159            if issue_id.contains("HeadingNotFound") {
160                (FixType::Update, 2)
161            } else {
162                (FixType::Manual, 5)
163            }
164        } else if issue_id.starts_with("config-short-") {
165            (FixType::Update, 10)
166        } else if issue_id.starts_with("config-generic-") {
167            (FixType::Manual, 20)
168        } else {
169            (FixType::NoFix, 0)
170        }
171    }
172
173    /// Estimate score improvement (percentage points) from fixing one issue.
174    fn estimate_score_delta(severity: IssueSeverity, category: IssueCategory, components: &ComponentScores) -> f64 {
175        let weight = match category {
176            IssueCategory::Freshness => W_FRESHNESS,
177            IssueCategory::Configuration => W_CONFIGURATION,
178            IssueCategory::Integrity => W_INTEGRITY,
179            IssueCategory::AgentSetup => W_AGENT_SETUP,
180            IssueCategory::Structure => W_STRUCTURE,
181        };
182
183        let component_score = match category {
184            IssueCategory::Freshness => components.freshness,
185            IssueCategory::Configuration => components.configuration,
186            IssueCategory::Integrity => components.integrity,
187            IssueCategory::AgentSetup => components.agent_setup,
188            IssueCategory::Structure => components.structure,
189        };
190
191        // Base impact varies by severity
192        let base_impact = match severity {
193            IssueSeverity::Blocking => 0.25,
194            IssueSeverity::High => 0.15,
195            IssueSeverity::Medium => 0.08,
196            IssueSeverity::Low => 0.03,
197        };
198
199        // Scale by component weight and how low the component is (more room to improve)
200        let room = 1.0 - component_score;
201        (base_impact * weight * room * 100.0).round().max(0.5)
202    }
203
204    /// Compute composite priority score (0-1000).
205    /// 50% severity + 30% fixability + 20% delta
206    fn compute_priority(issue: &QualityIssue) -> u32 {
207        // Severity score: Blocking=1000, High=750, Medium=400, Low=100
208        let severity_score = match issue.severity {
209            IssueSeverity::Blocking => 1000.0,
210            IssueSeverity::High => 750.0,
211            IssueSeverity::Medium => 400.0,
212            IssueSeverity::Low => 100.0,
213        };
214
215        // Fixability: Scaffold=1000, Update=700, Manual=300, NoFix=50
216        let fix_score = match issue.fix_type {
217            FixType::Scaffold => 1000.0,
218            FixType::Update => 700.0,
219            FixType::Manual => 300.0,
220            FixType::NoFix => 50.0,
221        };
222
223        // Delta score: normalize to 0-1000 (assume max delta ~8 pts)
224        let delta = issue.score_delta_est.unwrap_or(0.0);
225        let delta_score = (delta / 8.0 * 1000.0).min(1000.0);
226
227        let composite = severity_score * 0.5 + fix_score * 0.3 + delta_score * 0.2;
228        composite.round() as u32
229    }
230
231    /// Compute a "Path to Green" plan from the current score.
232    /// Sorts fixable issues by efficiency (delta/effort) and accumulates until Green (76%) or exhausted.
233    #[must_use]
234    pub fn path_to_green(score: &ProjectScore) -> PathToGreen {
235        let green_threshold = 76.0;
236        let current = score.total * 100.0;
237
238        if current >= green_threshold {
239            return PathToGreen {
240                steps: vec![],
241                total_delta: 0.0,
242                total_effort_minutes: 0,
243                projected_score: current,
244                reachable: true,
245            };
246        }
247
248        // Collect fixable issues (Scaffold or Update), sorted by efficiency (delta/effort)
249        let mut fixable: Vec<&QualityIssue> = score.issues.iter()
250            .filter(|i| matches!(i.fix_type, FixType::Scaffold | FixType::Update))
251            .filter(|i| i.score_delta_est.unwrap_or(0.0) > 0.0)
252            .collect();
253
254        // Sort by efficiency (delta per minute of effort), descending
255        fixable.sort_by(|a, b| {
256            let eff_a = a.score_delta_est.unwrap_or(0.0) / a.effort_minutes.unwrap_or(1) as f64;
257            let eff_b = b.score_delta_est.unwrap_or(0.0) / b.effort_minutes.unwrap_or(1) as f64;
258            eff_b.partial_cmp(&eff_a).unwrap_or(std::cmp::Ordering::Equal)
259        });
260
261        let mut steps = Vec::new();
262        let mut cumulative = current;
263        let mut total_effort = 0u32;
264        let mut total_delta = 0.0f64;
265
266        for issue in &fixable {
267            let delta = issue.score_delta_est.unwrap_or(0.0);
268            let effort = issue.effort_minutes.unwrap_or(0);
269            cumulative += delta;
270            total_delta += delta;
271            total_effort += effort;
272
273            steps.push(PathToGreenStep {
274                issue_id: issue.id.clone(),
275                title: issue.title.clone(),
276                fix_type: issue.fix_type,
277                effort_minutes: effort,
278                score_delta: delta,
279                cumulative_score: cumulative,
280            });
281
282            if cumulative >= green_threshold {
283                break;
284            }
285        }
286
287        PathToGreen {
288            steps,
289            total_delta,
290            total_effort_minutes: total_effort,
291            projected_score: cumulative,
292            reachable: cumulative >= green_threshold,
293        }
294    }
295
296    fn generate_freshness_issues(result: &FreshnessResult, issues: &mut Vec<QualityIssue>) {
297        for file in &result.file_scores {
298            if let Some(days) = file.days_since_modified {
299                if days > 90 {
300                    issues.push(QualityIssue::new(
301                        format!("freshness-stale-{}", file.relative_path.replace('/', "-")),
302                        Some(file.relative_path.clone()),
303                        IssueCategory::Freshness,
304                        IssueSeverity::High,
305                        format!("{} not updated in {} days", file.relative_path, days),
306                        format!(
307                            "AI doesn't know about recent project changes because {} hasn't been updated in {} days",
308                            file.relative_path, days
309                        ),
310                        Some(format!("Review and update {} to reflect current project state", file.relative_path)),
311                    ));
312                } else if days > 30 {
313                    issues.push(QualityIssue::new(
314                        format!("freshness-aging-{}", file.relative_path.replace('/', "-")),
315                        Some(file.relative_path.clone()),
316                        IssueCategory::Freshness,
317                        IssueSeverity::Medium,
318                        format!("{} is {} days old", file.relative_path, days),
319                        format!(
320                            "AI may be working with outdated context because {} was last updated {} days ago",
321                            file.relative_path, days
322                        ),
323                        Some(format!("Consider updating {} if the project has changed recently", file.relative_path)),
324                    ));
325                }
326            }
327
328            if file.coupling_penalty > 0.1 {
329                issues.push(QualityIssue::new(
330                    format!("freshness-coupling-{}", file.relative_path.replace('/', "-")),
331                    Some(file.relative_path.clone()),
332                    IssueCategory::Freshness,
333                    IssueSeverity::High,
334                    format!("{} references code that changed since last doc update", file.relative_path),
335                    format!(
336                        "AI may give incorrect answers because {} references code that has been modified since the doc was last updated",
337                        file.relative_path
338                    ),
339                    Some(format!("Update {} to reflect the latest code changes", file.relative_path)),
340                ));
341            }
342        }
343    }
344
345    fn generate_integrity_issues(result: &IntegrityResult, issues: &mut Vec<QualityIssue>) {
346        for broken in &result.broken {
347            let severity = match broken.kind {
348                crate::analysis::integrity::BrokenRefKind::FileNotFound => IssueSeverity::High,
349                crate::analysis::integrity::BrokenRefKind::HeadingNotFound => IssueSeverity::Medium,
350                crate::analysis::integrity::BrokenRefKind::DirectiveReference => IssueSeverity::Blocking,
351            };
352
353            issues.push(QualityIssue::new(
354                format!(
355                    "integrity-{}-{}",
356                    broken.source_file.replace('/', "-"),
357                    broken.target.replace('/', "-")
358                ),
359                Some(broken.source_file.clone()),
360                IssueCategory::Integrity,
361                severity,
362                format!("Broken link in {}: {}", broken.source_file, broken.target),
363                format!(
364                    "AI may follow broken references because {} links to {} which doesn't exist",
365                    broken.source_file, broken.target
366                ),
367                Some(format!(
368                    "Fix or remove the reference to {} in {}",
369                    broken.target, broken.source_file
370                )),
371            ));
372        }
373    }
374
375    fn generate_config_issues(result: &ConfigQualityResult, issues: &mut Vec<QualityIssue>) {
376        if !result.has_claude_md {
377            issues.push(QualityIssue::new(
378                "config-missing-claude-md".to_string(),
379                None,
380                IssueCategory::Configuration,
381                IssueSeverity::Blocking,
382                "Missing CLAUDE.md".to_string(),
383                "AI has no project-specific instructions because CLAUDE.md is missing"
384                    .to_string(),
385                Some(
386                    "Create a CLAUDE.md with project rules, stack, file structure, and coding conventions"
387                        .to_string(),
388                ),
389            ));
390        }
391
392        if !result.has_readme {
393            issues.push(QualityIssue::new(
394                "config-missing-readme".to_string(),
395                None,
396                IssueCategory::Configuration,
397                IssueSeverity::Medium,
398                "Missing README.md".to_string(),
399                "AI lacks project overview context because README.md is missing"
400                    .to_string(),
401                Some(
402                    "Create a README.md with project description, setup instructions, and architecture overview"
403                        .to_string(),
404                ),
405            ));
406        }
407
408        for detail in &result.details {
409            if detail.length_score < 0.3 {
410                issues.push(QualityIssue::new(
411                    format!("config-short-{}", detail.file.replace('/', "-")),
412                    Some(detail.file.clone()),
413                    IssueCategory::Configuration,
414                    IssueSeverity::Medium,
415                    format!("{} is too short", detail.file),
416                    format!(
417                        "AI receives insufficient guidance because {} lacks detail",
418                        detail.file
419                    ),
420                    Some(format!(
421                        "Expand {} with more rules, file paths, and coding conventions",
422                        detail.file
423                    )),
424                ));
425            }
426
427            if detail.actionable_score < 0.2 {
428                issues.push(QualityIssue::new(
429                    format!("config-generic-{}", detail.file.replace('/', "-")),
430                    Some(detail.file.clone()),
431                    IssueCategory::Configuration,
432                    IssueSeverity::Low,
433                    format!("{} lacks actionable rules", detail.file),
434                    format!(
435                        "AI may not follow project conventions because {} doesn't contain clear directives (MUST, NEVER, ALWAYS)",
436                        detail.file
437                    ),
438                    Some(format!(
439                        "Add explicit rules with MUST/NEVER/ALWAYS keywords to {}",
440                        detail.file
441                    )),
442                ));
443            }
444        }
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use crate::analysis::{
452        config_quality::ConfigDetail,
453        integrity::BrokenReference,
454        staleness::FileFreshness,
455    };
456
457    fn healthy_freshness() -> FreshnessResult {
458        FreshnessResult {
459            score: 0.95,
460            file_scores: vec![FileFreshness {
461                relative_path: "README.md".to_string(),
462                score: 0.95,
463                days_since_modified: Some(2),
464                coupling_penalty: 0.0,
465                importance_weight: 1.5,
466            }],
467        }
468    }
469
470    fn healthy_integrity() -> IntegrityResult {
471        IntegrityResult {
472            score: 1.0,
473            total_refs: 5,
474            valid_refs: 5,
475            broken: vec![],
476        }
477    }
478
479    fn healthy_config() -> ConfigQualityResult {
480        ConfigQualityResult {
481            score: 0.85,
482            has_claude_md: true,
483            has_claude_instructions: true,
484            has_readme: true,
485            details: vec![ConfigDetail {
486                file: "CLAUDE.md".to_string(),
487                length_score: 0.9,
488                structure_score: 0.8,
489                specificity_score: 0.7,
490                actionable_score: 0.8,
491                file_refs_score: 0.7,
492                shell_commands_score: 0.6,
493                recency_score: 1.0,
494                llm_quality_score: None,
495            }],
496            llm_adjusted: false,
497        }
498    }
499
500    fn healthy_agent_setup() -> AgentSetupResult {
501        AgentSetupResult {
502            score: 0.8,
503            claude_md_score: 0.9,
504            claude_dir_score: 0.7,
505            mcp_score: 0.75,
506            cursor_score: 1.0,
507            other_ai_score: 0.3,
508            details: vec![],
509        }
510    }
511
512    fn healthy_structure() -> StructureResult {
513        StructureResult {
514            score: 0.85,
515            depth_score: 1.0,
516            coverage_score: 1.0,
517            naming_score: 1.0,
518            standard_score: 0.7,
519            organization_score: 0.5,
520        }
521    }
522
523    fn zero_agent_setup() -> AgentSetupResult {
524        AgentSetupResult {
525            score: 0.0,
526            claude_md_score: 0.0,
527            claude_dir_score: 0.0,
528            mcp_score: 0.0,
529            cursor_score: 0.0,
530            other_ai_score: 0.0,
531            details: vec![],
532        }
533    }
534
535    fn zero_structure() -> StructureResult {
536        StructureResult {
537            score: 0.0,
538            depth_score: 0.0,
539            coverage_score: 0.0,
540            naming_score: 0.0,
541            standard_score: 0.0,
542            organization_score: 0.0,
543        }
544    }
545
546    #[test]
547    fn test_healthy_project_green() {
548        let score = ScoringEngine::score(
549            &healthy_freshness(),
550            &healthy_integrity(),
551            &healthy_config(),
552            &healthy_agent_setup(),
553            &healthy_structure(),
554        );
555        assert_eq!(score.traffic_light, TrafficLight::Green);
556        assert!(
557            score.total >= 0.80,
558            "Expected GREEN score >= 0.80, got {}",
559            score.total
560        );
561    }
562
563    #[test]
564    fn test_stale_docs_yellow() {
565        let freshness = FreshnessResult {
566            score: 0.3,
567            file_scores: vec![FileFreshness {
568                relative_path: "README.md".to_string(),
569                score: 0.3,
570                days_since_modified: Some(60),
571                coupling_penalty: 0.0,
572                importance_weight: 1.5,
573            }],
574        };
575
576        let score = ScoringEngine::score(
577            &freshness,
578            &healthy_integrity(),
579            &healthy_config(),
580            &healthy_agent_setup(),
581            &healthy_structure(),
582        );
583        assert_eq!(score.traffic_light, TrafficLight::Yellow);
584    }
585
586    #[test]
587    fn test_no_claude_md_floor_penalty() {
588        let config = ConfigQualityResult {
589            score: 0.0,
590            has_claude_md: false,
591            has_claude_instructions: false,
592            has_readme: false,
593            details: vec![],
594            llm_adjusted: false,
595        };
596
597        let score = ScoringEngine::score(
598            &healthy_freshness(),
599            &healthy_integrity(),
600            &config,
601            &healthy_agent_setup(),
602            &healthy_structure(),
603        );
604        assert!(
605            score.total <= 0.20,
606            "Expected capped at 0.20, got {}",
607            score.total
608        );
609        assert_eq!(score.traffic_light, TrafficLight::Red);
610    }
611
612    #[test]
613    fn test_low_component_floor_penalty() {
614        let freshness = FreshnessResult {
615            score: 0.15,
616            file_scores: vec![],
617        };
618
619        let score = ScoringEngine::score(
620            &freshness,
621            &healthy_integrity(),
622            &healthy_config(),
623            &healthy_agent_setup(),
624            &healthy_structure(),
625        );
626        // Dynamic cap: single low component (freshness=0.15, deficit=0.05) → cap ≈ 0.60
627        assert!(
628            score.total <= 0.65,
629            "Expected capped by dynamic floor penalty (<= 0.65), got {}",
630            score.total
631        );
632        // Still should be penalized — not reaching full score
633        assert!(
634            score.total < 0.80,
635            "Low component should still be penalized, got {}",
636            score.total
637        );
638    }
639
640    #[test]
641    fn test_scoring_formula() {
642        let freshness = FreshnessResult {
643            score: 1.0,
644            file_scores: vec![],
645        };
646        let integrity = IntegrityResult {
647            score: 1.0,
648            total_refs: 1,
649            valid_refs: 1,
650            broken: vec![],
651        };
652        let config = ConfigQualityResult {
653            score: 1.0,
654            has_claude_md: true,
655            has_claude_instructions: true,
656            has_readme: true,
657            details: vec![],
658            llm_adjusted: false,
659        };
660        let agent_setup = AgentSetupResult {
661            score: 1.0,
662            claude_md_score: 1.0,
663            claude_dir_score: 1.0,
664            mcp_score: 1.0,
665            cursor_score: 1.0,
666            other_ai_score: 1.0,
667            details: vec![],
668        };
669        let structure = StructureResult {
670            score: 1.0,
671            depth_score: 1.0,
672            coverage_score: 1.0,
673            naming_score: 1.0,
674            standard_score: 1.0,
675            organization_score: 1.0,
676        };
677
678        let score = ScoringEngine::score(&freshness, &integrity, &config, &agent_setup, &structure);
679        // 0.30*1.0 + 0.25*1.0 + 0.20*1.0 + 0.15*1.0 + 0.10*1.0 = 1.0
680        assert!(
681            (score.total - 1.0).abs() < 0.01,
682            "Expected 1.0, got {}",
683            score.total
684        );
685    }
686
687    #[test]
688    fn test_traffic_light_thresholds() {
689        // Calibrated thresholds v3: Green >= 76%, Yellow >= 42%, Red < 42%
690        assert_eq!(TrafficLight::from_score(0.85), TrafficLight::Green);
691        assert_eq!(TrafficLight::from_score(0.76), TrafficLight::Green);
692        assert_eq!(TrafficLight::from_score(0.759), TrafficLight::Yellow);
693        assert_eq!(TrafficLight::from_score(0.42), TrafficLight::Yellow);
694        assert_eq!(TrafficLight::from_score(0.419), TrafficLight::Red);
695        assert_eq!(TrafficLight::from_score(0.0), TrafficLight::Red);
696    }
697
698    #[test]
699    fn test_issues_generated_for_missing_claude_md() {
700        let config = ConfigQualityResult {
701            score: 0.3,
702            has_claude_md: false,
703            has_claude_instructions: false,
704            has_readme: true,
705            details: vec![],
706            llm_adjusted: false,
707        };
708
709        let score = ScoringEngine::score(
710            &healthy_freshness(),
711            &healthy_integrity(),
712            &config,
713            &healthy_agent_setup(),
714            &healthy_structure(),
715        );
716
717        let config_issues: Vec<_> = score
718            .issues
719            .iter()
720            .filter(|i| i.category == IssueCategory::Configuration)
721            .collect();
722        assert!(!config_issues.is_empty());
723        assert!(config_issues
724            .iter()
725            .any(|i| i.id == "config-missing-claude-md"));
726    }
727
728    #[test]
729    fn test_issues_sorted_by_severity() {
730        let freshness = FreshnessResult {
731            score: 0.1,
732            file_scores: vec![FileFreshness {
733                relative_path: "old.md".to_string(),
734                score: 0.1,
735                days_since_modified: Some(100),
736                coupling_penalty: 0.2,
737                importance_weight: 1.0,
738            }],
739        };
740
741        let integrity = IntegrityResult {
742            score: 0.5,
743            total_refs: 2,
744            valid_refs: 1,
745            broken: vec![BrokenReference {
746                source_file: "README.md".to_string(),
747                target: "missing.md".to_string(),
748                kind: crate::analysis::integrity::BrokenRefKind::FileNotFound,
749            }],
750        };
751
752        let config = ConfigQualityResult {
753            score: 0.0,
754            has_claude_md: false,
755            has_claude_instructions: false,
756            has_readme: false,
757            details: vec![],
758            llm_adjusted: false,
759        };
760
761        let score = ScoringEngine::score(
762            &freshness,
763            &integrity,
764            &config,
765            &zero_agent_setup(),
766            &zero_structure(),
767        );
768        if score.issues.len() >= 2 {
769            // Missing CLAUDE.md is now Blocking, should be first
770            assert_eq!(score.issues[0].severity, IssueSeverity::Blocking);
771        }
772    }
773
774    #[test]
775    fn test_blocking_severity_sorts_first() {
776        let config = ConfigQualityResult {
777            score: 0.3,
778            has_claude_md: false,
779            has_claude_instructions: false,
780            has_readme: true,
781            details: vec![],
782            llm_adjusted: false,
783        };
784
785        let freshness = FreshnessResult {
786            score: 0.1,
787            file_scores: vec![FileFreshness {
788                relative_path: "old.md".to_string(),
789                score: 0.1,
790                days_since_modified: Some(100),
791                coupling_penalty: 0.0,
792                importance_weight: 1.0,
793            }],
794        };
795
796        let score = ScoringEngine::score(
797            &freshness,
798            &healthy_integrity(),
799            &config,
800            &healthy_agent_setup(),
801            &healthy_structure(),
802        );
803
804        // First issue should be Blocking (missing CLAUDE.md)
805        assert!(!score.issues.is_empty());
806        assert_eq!(score.issues[0].severity, IssueSeverity::Blocking);
807        assert_eq!(score.issues[0].id, "config-missing-claude-md");
808    }
809
810    #[test]
811    fn test_missing_claude_md_is_blocking() {
812        let config = ConfigQualityResult {
813            score: 0.3,
814            has_claude_md: false,
815            has_claude_instructions: false,
816            has_readme: true,
817            details: vec![],
818            llm_adjusted: false,
819        };
820
821        let score = ScoringEngine::score(
822            &healthy_freshness(),
823            &healthy_integrity(),
824            &config,
825            &healthy_agent_setup(),
826            &healthy_structure(),
827        );
828
829        let claude_md_issue = score.issues.iter().find(|i| i.id == "config-missing-claude-md");
830        assert!(claude_md_issue.is_some());
831        assert_eq!(claude_md_issue.unwrap().severity, IssueSeverity::Blocking);
832    }
833
834    #[test]
835    fn test_priority_score_computation() {
836        let config = ConfigQualityResult {
837            score: 0.3,
838            has_claude_md: false,
839            has_claude_instructions: false,
840            has_readme: false,
841            details: vec![],
842            llm_adjusted: false,
843        };
844
845        let score = ScoringEngine::score(
846            &healthy_freshness(),
847            &healthy_integrity(),
848            &config,
849            &healthy_agent_setup(),
850            &healthy_structure(),
851        );
852
853        // All issues should have non-zero priority scores
854        for issue in &score.issues {
855            assert!(issue.priority_score > 0, "Issue {} should have priority > 0", issue.id);
856        }
857
858        // Blocking issues should have higher priority than Medium
859        let blocking_issues: Vec<_> = score.issues.iter().filter(|i| i.severity == IssueSeverity::Blocking).collect();
860        let medium_issues: Vec<_> = score.issues.iter().filter(|i| i.severity == IssueSeverity::Medium).collect();
861
862        if !blocking_issues.is_empty() && !medium_issues.is_empty() {
863            assert!(
864                blocking_issues[0].priority_score > medium_issues[0].priority_score,
865                "Blocking priority {} should exceed Medium priority {}",
866                blocking_issues[0].priority_score, medium_issues[0].priority_score
867            );
868        }
869    }
870
871    #[test]
872    fn test_fix_type_assignment() {
873        let config = ConfigQualityResult {
874            score: 0.3,
875            has_claude_md: false,
876            has_claude_instructions: false,
877            has_readme: false,
878            details: vec![ConfigDetail {
879                file: "CLAUDE.md".to_string(),
880                length_score: 0.1,
881                structure_score: 0.5,
882                specificity_score: 0.5,
883                actionable_score: 0.5,
884                file_refs_score: 0.5,
885                shell_commands_score: 0.5,
886                recency_score: 1.0,
887                llm_quality_score: None,
888            }],
889            llm_adjusted: false,
890        };
891
892        let freshness = FreshnessResult {
893            score: 0.3,
894            file_scores: vec![FileFreshness {
895                relative_path: "old.md".to_string(),
896                score: 0.1,
897                days_since_modified: Some(100),
898                coupling_penalty: 0.0,
899                importance_weight: 1.0,
900            }],
901        };
902
903        let score = ScoringEngine::score(
904            &freshness,
905            &healthy_integrity(),
906            &config,
907            &healthy_agent_setup(),
908            &healthy_structure(),
909        );
910
911        // Missing CLAUDE.md → Scaffold
912        let claude_issue = score.issues.iter().find(|i| i.id == "config-missing-claude-md");
913        assert!(claude_issue.is_some());
914        assert_eq!(claude_issue.unwrap().fix_type, FixType::Scaffold);
915        assert_eq!(claude_issue.unwrap().effort_minutes, Some(1));
916
917        // Stale file → Update
918        let stale_issue = score.issues.iter().find(|i| i.id.starts_with("freshness-stale-"));
919        assert!(stale_issue.is_some());
920        assert_eq!(stale_issue.unwrap().fix_type, FixType::Update);
921    }
922
923    #[test]
924    fn test_severity_rank_ordering() {
925        assert!(IssueSeverity::Blocking.rank() < IssueSeverity::High.rank());
926        assert!(IssueSeverity::High.rank() < IssueSeverity::Medium.rank());
927        assert!(IssueSeverity::Medium.rank() < IssueSeverity::Low.rank());
928    }
929
930    #[test]
931    fn test_path_to_green_already_green() {
932        let score = ScoringEngine::score(
933            &healthy_freshness(),
934            &healthy_integrity(),
935            &healthy_config(),
936            &healthy_agent_setup(),
937            &healthy_structure(),
938        );
939        assert_eq!(score.traffic_light, TrafficLight::Green);
940
941        let p2g = ScoringEngine::path_to_green(&score);
942        assert!(p2g.steps.is_empty());
943        assert!(p2g.reachable);
944        assert_eq!(p2g.total_effort_minutes, 0);
945    }
946
947    #[test]
948    fn test_path_to_green_not_green() {
949        // Use low freshness to get Yellow, which triggers stale issues
950        let freshness = FreshnessResult {
951            score: 0.3,
952            file_scores: vec![FileFreshness {
953                relative_path: "README.md".to_string(),
954                score: 0.3,
955                days_since_modified: Some(100),
956                coupling_penalty: 0.0,
957                importance_weight: 1.5,
958            }],
959        };
960
961        let score = ScoringEngine::score(
962            &freshness,
963            &healthy_integrity(),
964            &healthy_config(),
965            &healthy_agent_setup(),
966            &healthy_structure(),
967        );
968        assert_ne!(score.traffic_light, TrafficLight::Green,
969            "Expected non-Green, got score {}", score.total);
970
971        let p2g = ScoringEngine::path_to_green(&score);
972        assert!(!p2g.steps.is_empty());
973        assert!(p2g.total_delta > 0.0);
974        // Each step should have positive delta
975        for step in &p2g.steps {
976            assert!(step.score_delta > 0.0);
977        }
978    }
979}