1use crate::analysis::{
4 AgentSetupResult, ConfigQualityResult, FreshnessResult, IntegrityResult, StructureResult,
5};
6
7use super::types::*;
8
9const 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
16const FLOOR_LOW_THRESHOLD: f64 = 0.2;
21const FLOOR_ZERO_THRESHOLD: f64 = 0.001;
22const CAP_ZERO: f64 = 0.20;
23
24pub struct ScoringEngine;
26
27impl ScoringEngine {
28 #[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 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 let min_component = all_scores.iter().cloned().fold(f64::INFINITY, f64::min);
63 let bonus = if min_component >= 0.85 {
64 ((min_component - 0.85) * 0.8).min(0.08)
66 } else if min_component >= 0.75 {
67 (min_component - 0.75) * 0.3
69 } else {
70 0.0
71 };
72 total = (total + bonus).min(1.0); 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 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 let dynamic_cap = if low_count >= 3 {
97 0.45
98 } else if low_count == 2 {
99 0.50
100 } else {
101 (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 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 for issue in &mut issues {
119 Self::enrich_issue(issue, &components);
120 }
121
122 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 fn enrich_issue(issue: &mut QualityIssue, components: &ComponentScores) {
135 let (fix_type, effort) = Self::classify_fix(&issue.id);
137 issue.fix_type = fix_type;
138 issue.effort_minutes = Some(effort);
139
140 issue.score_delta_est = Some(Self::estimate_score_delta(issue.severity, issue.category, components));
142
143 issue.priority_score = Self::compute_priority(issue);
145 }
146
147 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 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 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 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 let room = 1.0 - component_score;
201 (base_impact * weight * room * 100.0).round().max(0.5)
202 }
203
204 fn compute_priority(issue: &QualityIssue) -> u32 {
207 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 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 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 #[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 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 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 assert!(
628 score.total <= 0.65,
629 "Expected capped by dynamic floor penalty (<= 0.65), got {}",
630 score.total
631 );
632 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 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 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 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 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 for issue in &score.issues {
855 assert!(issue.priority_score > 0, "Issue {} should have priority > 0", issue.id);
856 }
857
858 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 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 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 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 for step in &p2g.steps {
976 assert!(step.score_delta > 0.0);
977 }
978 }
979}