1use crate::analysis::integrity::BrokenRefKind;
11
12use super::{Rule, RuleCategory, RuleConfig, RuleContext, RuleViolation, Severity};
13
14pub struct StaleFreshnessRule;
24
25impl Rule for StaleFreshnessRule {
26 fn id(&self) -> &str { "freshness-stale" }
27 fn name(&self) -> &str { "Stale Documentation" }
28 fn description(&self) -> &str { "Flags documents not updated in >30 days" }
29 fn category(&self) -> RuleCategory { RuleCategory::Freshness }
30 fn default_severity(&self) -> Severity { Severity::Warning }
31
32 fn evaluate(&self, ctx: &RuleContext, config: &RuleConfig) -> Vec<RuleViolation> {
33 let high_days = config.options.get("high_days")
35 .and_then(|v| v.as_i64())
36 .unwrap_or(90);
37 let medium_days = config.options.get("medium_days")
38 .and_then(|v| v.as_i64())
39 .unwrap_or(30);
40
41 let freshness = match ctx.freshness {
42 Some(f) => f,
43 None => {
44 return ctx.git_infos
46 .iter()
47 .filter_map(|gi| {
48 let days = gi.days_since_modified?;
49 if days > high_days {
50 Some(RuleViolation {
51 rule_id: self.id().to_string(),
52 category: self.category(),
53 severity: Severity::Error,
54 file_path: Some(gi.relative_path.clone()),
55 title: format!("{} not updated in {} days", gi.relative_path, days),
56 attribution: format!(
57 "AI doesn't know about recent project changes because {} hasn't been updated in {} days",
58 gi.relative_path, days
59 ),
60 suggestion: Some(format!(
61 "Review and update {} to reflect current project state",
62 gi.relative_path
63 )),
64 })
65 } else if days > medium_days {
66 Some(RuleViolation {
67 rule_id: self.id().to_string(),
68 category: self.category(),
69 severity: Severity::Warning,
70 file_path: Some(gi.relative_path.clone()),
71 title: format!("{} is {} days old", gi.relative_path, days),
72 attribution: format!(
73 "AI may be working with outdated context because {} was last updated {} days ago",
74 gi.relative_path, days
75 ),
76 suggestion: Some(format!(
77 "Consider updating {} if the project has changed recently",
78 gi.relative_path
79 )),
80 })
81 } else {
82 None
83 }
84 })
85 .collect();
86 }
87 };
88
89 let mut violations = Vec::new();
90
91 for file in &freshness.file_scores {
92 if let Some(days) = file.days_since_modified {
93 if days > high_days {
94 violations.push(RuleViolation {
95 rule_id: self.id().to_string(),
96 category: self.category(),
97 severity: Severity::Error,
98 file_path: Some(file.relative_path.clone()),
99 title: format!(
100 "{} not updated in {} days",
101 file.relative_path, days
102 ),
103 attribution: format!(
104 "AI doesn't know about recent project changes because {} hasn't been updated in {} days",
105 file.relative_path, days
106 ),
107 suggestion: Some(format!(
108 "Review and update {} to reflect current project state",
109 file.relative_path
110 )),
111 });
112 } else if days > medium_days {
113 violations.push(RuleViolation {
114 rule_id: self.id().to_string(),
115 category: self.category(),
116 severity: Severity::Warning,
117 file_path: Some(file.relative_path.clone()),
118 title: format!(
119 "{} is {} days old",
120 file.relative_path, days
121 ),
122 attribution: format!(
123 "AI may be working with outdated context because {} was last updated {} days ago",
124 file.relative_path, days
125 ),
126 suggestion: Some(format!(
127 "Consider updating {} if the project has changed recently",
128 file.relative_path
129 )),
130 });
131 }
132 }
133 }
134
135 violations
136 }
137}
138
139pub struct CouplingPenaltyRule;
144
145impl Rule for CouplingPenaltyRule {
146 fn id(&self) -> &str { "freshness-coupling" }
147 fn name(&self) -> &str { "Code-Doc Coupling Drift" }
148 fn description(&self) -> &str { "Flags docs referencing code that changed since doc was updated" }
149 fn category(&self) -> RuleCategory { RuleCategory::Freshness }
150 fn default_severity(&self) -> Severity { Severity::Error }
151
152 fn evaluate(&self, ctx: &RuleContext, config: &RuleConfig) -> Vec<RuleViolation> {
153 let threshold = config.options.get("threshold")
154 .and_then(|v| v.as_f64())
155 .unwrap_or(0.1);
156
157 let freshness = match ctx.freshness {
158 Some(f) => f,
159 None => return vec![],
160 };
161
162 let mut violations = Vec::new();
163
164 for file in &freshness.file_scores {
165 if file.coupling_penalty > threshold {
166 violations.push(RuleViolation {
167 rule_id: self.id().to_string(),
168 category: self.category(),
169 severity: self.default_severity(),
170 file_path: Some(file.relative_path.clone()),
171 title: format!(
172 "{} references code that changed since last doc update",
173 file.relative_path
174 ),
175 attribution: format!(
176 "AI may give incorrect answers because {} references code that has been modified since the doc was last updated",
177 file.relative_path
178 ),
179 suggestion: Some(format!(
180 "Update {} to reflect the latest code changes",
181 file.relative_path
182 )),
183 });
184 }
185 }
186
187 violations
188 }
189}
190
191pub struct BrokenLinkRule;
201
202impl Rule for BrokenLinkRule {
203 fn id(&self) -> &str { "integrity-broken-link" }
204 fn name(&self) -> &str { "Broken Internal Link" }
205 fn description(&self) -> &str { "Flags broken file or heading references in docs" }
206 fn category(&self) -> RuleCategory { RuleCategory::Integrity }
207 fn default_severity(&self) -> Severity { Severity::Error }
208
209 fn evaluate(&self, ctx: &RuleContext, _config: &RuleConfig) -> Vec<RuleViolation> {
210 let integrity = match ctx.integrity {
211 Some(i) => i,
212 None => return vec![],
213 };
214
215 let mut violations = Vec::new();
216
217 for broken in &integrity.broken {
218 let severity = match broken.kind {
219 BrokenRefKind::FileNotFound => Severity::Error,
220 BrokenRefKind::HeadingNotFound => Severity::Warning,
221 BrokenRefKind::DirectiveReference => Severity::Error,
222 };
223
224 violations.push(RuleViolation {
225 rule_id: self.id().to_string(),
226 category: self.category(),
227 severity,
228 file_path: Some(broken.source_file.clone()),
229 title: format!(
230 "Broken link in {}: {}",
231 broken.source_file, broken.target
232 ),
233 attribution: format!(
234 "AI may follow broken references because {} links to {} which doesn't exist",
235 broken.source_file, broken.target
236 ),
237 suggestion: Some(format!(
238 "Fix or remove the reference to {} in {}",
239 broken.target, broken.source_file
240 )),
241 });
242 }
243
244 violations
245 }
246}
247
248pub struct MissingClaudeMdRule;
256
257impl Rule for MissingClaudeMdRule {
258 fn id(&self) -> &str { "config-missing-claude-md" }
259 fn name(&self) -> &str { "Missing CLAUDE.md" }
260 fn description(&self) -> &str { "Ensures CLAUDE.md exists in the project" }
261 fn category(&self) -> RuleCategory { RuleCategory::Configuration }
262 fn default_severity(&self) -> Severity { Severity::Error }
263
264 fn evaluate(&self, ctx: &RuleContext, _config: &RuleConfig) -> Vec<RuleViolation> {
265 let has_claude_md = match ctx.config_quality {
267 Some(cq) => cq.has_claude_md,
268 None => ctx.files.iter().any(|f| {
269 f.relative_path == "CLAUDE.md" || f.relative_path == ".claude/instructions"
270 }),
271 };
272
273 if has_claude_md {
274 return vec![];
275 }
276
277 vec![RuleViolation {
278 rule_id: self.id().to_string(),
279 category: self.category(),
280 severity: self.default_severity(),
281 file_path: None,
282 title: "Missing CLAUDE.md".to_string(),
283 attribution: "AI has no project-specific instructions because CLAUDE.md is missing"
284 .to_string(),
285 suggestion: Some(
286 "Create a CLAUDE.md with project rules, stack, file structure, and coding conventions"
287 .to_string(),
288 ),
289 }]
290 }
291}
292
293pub struct MissingReadmeRule;
297
298impl Rule for MissingReadmeRule {
299 fn id(&self) -> &str { "config-missing-readme" }
300 fn name(&self) -> &str { "Missing README.md" }
301 fn description(&self) -> &str { "Ensures README.md exists in the project" }
302 fn category(&self) -> RuleCategory { RuleCategory::Configuration }
303 fn default_severity(&self) -> Severity { Severity::Warning }
304
305 fn evaluate(&self, ctx: &RuleContext, _config: &RuleConfig) -> Vec<RuleViolation> {
306 let has_readme = match ctx.config_quality {
307 Some(cq) => cq.has_readme,
308 None => ctx.files.iter().any(|f| {
309 f.relative_path.to_lowercase().starts_with("readme")
310 }),
311 };
312
313 if has_readme {
314 return vec![];
315 }
316
317 vec![RuleViolation {
318 rule_id: self.id().to_string(),
319 category: self.category(),
320 severity: self.default_severity(),
321 file_path: None,
322 title: "Missing README.md".to_string(),
323 attribution: "AI lacks project overview context because README.md is missing"
324 .to_string(),
325 suggestion: Some(
326 "Create a README.md with project description, setup instructions, and architecture overview"
327 .to_string(),
328 ),
329 }]
330 }
331}
332
333pub struct ShortConfigRule;
337
338impl Rule for ShortConfigRule {
339 fn id(&self) -> &str { "config-short" }
340 fn name(&self) -> &str { "Short Config File" }
341 fn description(&self) -> &str { "Flags config files that are too short to be useful" }
342 fn category(&self) -> RuleCategory { RuleCategory::Configuration }
343 fn default_severity(&self) -> Severity { Severity::Warning }
344
345 fn evaluate(&self, ctx: &RuleContext, config: &RuleConfig) -> Vec<RuleViolation> {
346 let threshold = config.options.get("threshold")
347 .and_then(|v| v.as_f64())
348 .unwrap_or(0.3);
349
350 let config_quality = match ctx.config_quality {
351 Some(c) => c,
352 None => return vec![],
353 };
354
355 let mut violations = Vec::new();
356
357 for detail in &config_quality.details {
358 if detail.length_score < threshold {
359 violations.push(RuleViolation {
360 rule_id: self.id().to_string(),
361 category: self.category(),
362 severity: self.default_severity(),
363 file_path: Some(detail.file.clone()),
364 title: format!("{} is too short", detail.file),
365 attribution: format!(
366 "AI receives insufficient guidance because {} lacks detail",
367 detail.file
368 ),
369 suggestion: Some(format!(
370 "Expand {} with more rules, file paths, and coding conventions",
371 detail.file
372 )),
373 });
374 }
375 }
376
377 violations
378 }
379}
380
381pub struct GenericConfigRule;
385
386impl Rule for GenericConfigRule {
387 fn id(&self) -> &str { "config-generic" }
388 fn name(&self) -> &str { "Generic Config File" }
389 fn description(&self) -> &str { "Flags config files lacking actionable directives (MUST, NEVER, ALWAYS)" }
390 fn category(&self) -> RuleCategory { RuleCategory::Configuration }
391 fn default_severity(&self) -> Severity { Severity::Info }
392
393 fn evaluate(&self, ctx: &RuleContext, config: &RuleConfig) -> Vec<RuleViolation> {
394 let threshold = config.options.get("threshold")
395 .and_then(|v| v.as_f64())
396 .unwrap_or(0.2);
397
398 let config_quality = match ctx.config_quality {
399 Some(c) => c,
400 None => return vec![],
401 };
402
403 let mut violations = Vec::new();
404
405 for detail in &config_quality.details {
406 if detail.actionable_score < threshold {
407 violations.push(RuleViolation {
408 rule_id: self.id().to_string(),
409 category: self.category(),
410 severity: self.default_severity(),
411 file_path: Some(detail.file.clone()),
412 title: format!("{} lacks actionable rules", detail.file),
413 attribution: format!(
414 "AI may not follow project conventions because {} doesn't contain clear directives (MUST, NEVER, ALWAYS)",
415 detail.file
416 ),
417 suggestion: Some(format!(
418 "Add explicit rules with MUST/NEVER/ALWAYS keywords to {}",
419 detail.file
420 )),
421 });
422 }
423 }
424
425 violations
426 }
427}
428
429#[cfg(test)]
434mod tests {
435 use super::*;
436 use crate::analysis::{
437 config_quality::{ConfigDetail, ConfigQualityResult},
438 integrity::{BrokenRefKind, BrokenReference, IntegrityResult},
439 staleness::{FileFreshness, FreshnessResult},
440 };
441 use crate::parser::ParsedDocument;
442 use crate::scanner::DiscoveredFile;
443 use std::collections::{HashMap, HashSet};
444 use std::path::PathBuf;
445
446 fn default_config() -> RuleConfig {
447 RuleConfig::default()
448 }
449
450 fn make_file(relative_path: &str) -> DiscoveredFile {
451 DiscoveredFile {
452 path: PathBuf::from(relative_path),
453 relative_path: relative_path.to_string(),
454 size: 100,
455 modified_at: None,
456 extension: Some("md".to_string()),
457 is_markdown: true,
458 content_hash: "abc123".to_string(),
459 }
460 }
461
462 fn ctx_with_freshness(freshness: &FreshnessResult) -> RuleContext<'_> {
463 let known = Box::leak(Box::new(HashSet::new()));
464 let parsed = Box::leak(Box::new(HashMap::<String, ParsedDocument>::new()));
465 RuleContext {
466 files: &[],
467 known_paths: known,
468 parsed_docs: parsed,
469 git_infos: &[],
470 project_root: std::path::Path::new("/tmp/test"),
471 freshness: Some(freshness),
472 integrity: None,
473 config_quality: None,
474 agent_setup: None,
475 structure: None,
476 }
477 }
478
479 fn ctx_with_integrity(integrity: &IntegrityResult) -> RuleContext<'_> {
480 let known = Box::leak(Box::new(HashSet::new()));
481 let parsed = Box::leak(Box::new(HashMap::<String, ParsedDocument>::new()));
482 RuleContext {
483 files: &[],
484 known_paths: known,
485 parsed_docs: parsed,
486 git_infos: &[],
487 project_root: std::path::Path::new("/tmp/test"),
488 freshness: None,
489 integrity: Some(integrity),
490 config_quality: None,
491 agent_setup: None,
492 structure: None,
493 }
494 }
495
496 fn ctx_with_config_quality(config: &ConfigQualityResult) -> RuleContext<'_> {
497 let known = Box::leak(Box::new(HashSet::new()));
498 let parsed = Box::leak(Box::new(HashMap::<String, ParsedDocument>::new()));
499 RuleContext {
500 files: &[],
501 known_paths: known,
502 parsed_docs: parsed,
503 git_infos: &[],
504 project_root: std::path::Path::new("/tmp/test"),
505 freshness: None,
506 integrity: None,
507 config_quality: Some(config),
508 agent_setup: None,
509 structure: None,
510 }
511 }
512
513 fn ctx_with_files(files: &[DiscoveredFile]) -> RuleContext<'_> {
514 let known = Box::leak(Box::new(HashSet::new()));
515 let parsed = Box::leak(Box::new(HashMap::<String, ParsedDocument>::new()));
516 RuleContext {
517 files,
518 known_paths: known,
519 parsed_docs: parsed,
520 git_infos: &[],
521 project_root: std::path::Path::new("/tmp/test"),
522 freshness: None,
523 integrity: None,
524 config_quality: None,
525 agent_setup: None,
526 structure: None,
527 }
528 }
529
530 #[test]
533 fn test_stale_no_issues_for_fresh_files() {
534 let freshness = FreshnessResult {
535 score: 0.95,
536 file_scores: vec![FileFreshness {
537 relative_path: "README.md".to_string(),
538 score: 0.95,
539 days_since_modified: Some(5),
540 coupling_penalty: 0.0,
541 importance_weight: 1.0,
542 }],
543 };
544 let ctx = ctx_with_freshness(&freshness);
545 let violations = StaleFreshnessRule.evaluate(&ctx, &default_config());
546 assert!(violations.is_empty());
547 }
548
549 #[test]
550 fn test_stale_medium_for_aging_files() {
551 let freshness = FreshnessResult {
552 score: 0.4,
553 file_scores: vec![FileFreshness {
554 relative_path: "docs/api.md".to_string(),
555 score: 0.4,
556 days_since_modified: Some(45),
557 coupling_penalty: 0.0,
558 importance_weight: 1.0,
559 }],
560 };
561 let ctx = ctx_with_freshness(&freshness);
562 let violations = StaleFreshnessRule.evaluate(&ctx, &default_config());
563 assert_eq!(violations.len(), 1);
564 assert_eq!(violations[0].severity, Severity::Warning);
565 assert!(violations[0].title.contains("45 days"));
566 }
567
568 #[test]
569 fn test_stale_high_for_very_old_files() {
570 let freshness = FreshnessResult {
571 score: 0.1,
572 file_scores: vec![FileFreshness {
573 relative_path: "old.md".to_string(),
574 score: 0.1,
575 days_since_modified: Some(100),
576 coupling_penalty: 0.0,
577 importance_weight: 1.0,
578 }],
579 };
580 let ctx = ctx_with_freshness(&freshness);
581 let violations = StaleFreshnessRule.evaluate(&ctx, &default_config());
582 assert_eq!(violations.len(), 1);
583 assert_eq!(violations[0].severity, Severity::Error);
584 assert!(violations[0].title.contains("not updated in 100 days"));
585 }
586
587 #[test]
588 fn test_stale_multiple_files() {
589 let freshness = FreshnessResult {
590 score: 0.3,
591 file_scores: vec![
592 FileFreshness {
593 relative_path: "fresh.md".to_string(),
594 score: 0.9,
595 days_since_modified: Some(5),
596 coupling_penalty: 0.0,
597 importance_weight: 1.0,
598 },
599 FileFreshness {
600 relative_path: "aging.md".to_string(),
601 score: 0.4,
602 days_since_modified: Some(50),
603 coupling_penalty: 0.0,
604 importance_weight: 1.0,
605 },
606 FileFreshness {
607 relative_path: "old.md".to_string(),
608 score: 0.1,
609 days_since_modified: Some(120),
610 coupling_penalty: 0.0,
611 importance_weight: 1.0,
612 },
613 ],
614 };
615 let ctx = ctx_with_freshness(&freshness);
616 let violations = StaleFreshnessRule.evaluate(&ctx, &default_config());
617 assert_eq!(violations.len(), 2); }
619
620 #[test]
621 fn test_stale_none_days_skipped() {
622 let freshness = FreshnessResult {
623 score: 0.5,
624 file_scores: vec![FileFreshness {
625 relative_path: "unknown.md".to_string(),
626 score: 0.5,
627 days_since_modified: None,
628 coupling_penalty: 0.0,
629 importance_weight: 1.0,
630 }],
631 };
632 let ctx = ctx_with_freshness(&freshness);
633 let violations = StaleFreshnessRule.evaluate(&ctx, &default_config());
634 assert!(violations.is_empty());
635 }
636
637 #[test]
638 fn test_stale_custom_thresholds() {
639 let freshness = FreshnessResult {
640 score: 0.5,
641 file_scores: vec![FileFreshness {
642 relative_path: "doc.md".to_string(),
643 score: 0.5,
644 days_since_modified: Some(20),
645 coupling_penalty: 0.0,
646 importance_weight: 1.0,
647 }],
648 };
649 let mut config = default_config();
650 config.options.insert("high_days".to_string(), serde_json::json!(30));
651 config.options.insert("medium_days".to_string(), serde_json::json!(10));
652
653 let ctx = ctx_with_freshness(&freshness);
654 let violations = StaleFreshnessRule.evaluate(&ctx, &config);
655 assert_eq!(violations.len(), 1);
657 assert_eq!(violations[0].severity, Severity::Warning);
658 }
659
660 #[test]
663 fn test_coupling_no_issue_below_threshold() {
664 let freshness = FreshnessResult {
665 score: 0.9,
666 file_scores: vec![FileFreshness {
667 relative_path: "docs/api.md".to_string(),
668 score: 0.9,
669 days_since_modified: Some(2),
670 coupling_penalty: 0.05,
671 importance_weight: 1.0,
672 }],
673 };
674 let ctx = ctx_with_freshness(&freshness);
675 let violations = CouplingPenaltyRule.evaluate(&ctx, &default_config());
676 assert!(violations.is_empty());
677 }
678
679 #[test]
680 fn test_coupling_issue_above_threshold() {
681 let freshness = FreshnessResult {
682 score: 0.5,
683 file_scores: vec![FileFreshness {
684 relative_path: "docs/api.md".to_string(),
685 score: 0.5,
686 days_since_modified: Some(2),
687 coupling_penalty: 0.2,
688 importance_weight: 1.0,
689 }],
690 };
691 let ctx = ctx_with_freshness(&freshness);
692 let violations = CouplingPenaltyRule.evaluate(&ctx, &default_config());
693 assert_eq!(violations.len(), 1);
694 assert_eq!(violations[0].severity, Severity::Error);
695 assert!(violations[0].title.contains("references code that changed"));
696 }
697
698 #[test]
699 fn test_coupling_no_freshness_data() {
700 let known = Box::leak(Box::new(HashSet::new()));
701 let parsed = Box::leak(Box::new(HashMap::<String, ParsedDocument>::new()));
702 let ctx = RuleContext {
703 files: &[],
704 known_paths: known,
705 parsed_docs: parsed,
706 git_infos: &[],
707 project_root: std::path::Path::new("/tmp"),
708 freshness: None,
709 integrity: None,
710 config_quality: None,
711 agent_setup: None,
712 structure: None,
713 };
714 let violations = CouplingPenaltyRule.evaluate(&ctx, &default_config());
715 assert!(violations.is_empty());
716 }
717
718 #[test]
721 fn test_broken_link_no_issues_when_clean() {
722 let integrity = IntegrityResult {
723 score: 1.0,
724 total_refs: 5,
725 valid_refs: 5,
726 broken: vec![],
727 };
728 let ctx = ctx_with_integrity(&integrity);
729 let violations = BrokenLinkRule.evaluate(&ctx, &default_config());
730 assert!(violations.is_empty());
731 }
732
733 #[test]
734 fn test_broken_link_file_not_found() {
735 let integrity = IntegrityResult {
736 score: 0.5,
737 total_refs: 2,
738 valid_refs: 1,
739 broken: vec![BrokenReference {
740 source_file: "README.md".to_string(),
741 target: "missing.md".to_string(),
742 kind: BrokenRefKind::FileNotFound,
743 }],
744 };
745 let ctx = ctx_with_integrity(&integrity);
746 let violations = BrokenLinkRule.evaluate(&ctx, &default_config());
747 assert_eq!(violations.len(), 1);
748 assert_eq!(violations[0].severity, Severity::Error);
749 }
750
751 #[test]
752 fn test_broken_link_heading_not_found() {
753 let integrity = IntegrityResult {
754 score: 0.5,
755 total_refs: 2,
756 valid_refs: 1,
757 broken: vec![BrokenReference {
758 source_file: "docs/guide.md".to_string(),
759 target: "docs/api.md#nonexistent".to_string(),
760 kind: BrokenRefKind::HeadingNotFound,
761 }],
762 };
763 let ctx = ctx_with_integrity(&integrity);
764 let violations = BrokenLinkRule.evaluate(&ctx, &default_config());
765 assert_eq!(violations.len(), 1);
766 assert_eq!(violations[0].severity, Severity::Warning);
767 }
768
769 #[test]
770 fn test_broken_link_multiple() {
771 let integrity = IntegrityResult {
772 score: 0.0,
773 total_refs: 3,
774 valid_refs: 0,
775 broken: vec![
776 BrokenReference {
777 source_file: "README.md".to_string(),
778 target: "a.md".to_string(),
779 kind: BrokenRefKind::FileNotFound,
780 },
781 BrokenReference {
782 source_file: "README.md".to_string(),
783 target: "b.md".to_string(),
784 kind: BrokenRefKind::FileNotFound,
785 },
786 BrokenReference {
787 source_file: "docs/x.md".to_string(),
788 target: "docs/y.md#z".to_string(),
789 kind: BrokenRefKind::HeadingNotFound,
790 },
791 ],
792 };
793 let ctx = ctx_with_integrity(&integrity);
794 let violations = BrokenLinkRule.evaluate(&ctx, &default_config());
795 assert_eq!(violations.len(), 3);
796 }
797
798 #[test]
801 fn test_missing_claude_md_from_analysis() {
802 let config = ConfigQualityResult {
803 score: 0.2,
804 has_claude_md: false,
805 has_claude_instructions: false,
806 has_readme: true,
807 details: vec![],
808 llm_adjusted: false,
809 };
810 let ctx = ctx_with_config_quality(&config);
811 let violations = MissingClaudeMdRule.evaluate(&ctx, &default_config());
812 assert_eq!(violations.len(), 1);
813 assert_eq!(violations[0].title, "Missing CLAUDE.md");
814 assert_eq!(violations[0].severity, Severity::Error);
815 }
816
817 #[test]
818 fn test_missing_claude_md_passes_from_analysis() {
819 let config = ConfigQualityResult {
820 score: 0.8,
821 has_claude_md: true,
822 has_claude_instructions: true,
823 has_readme: true,
824 details: vec![],
825 llm_adjusted: false,
826 };
827 let ctx = ctx_with_config_quality(&config);
828 let violations = MissingClaudeMdRule.evaluate(&ctx, &default_config());
829 assert!(violations.is_empty());
830 }
831
832 #[test]
833 fn test_missing_claude_md_fallback_to_files() {
834 let files = vec![make_file("README.md")];
835 let ctx = ctx_with_files(&files);
836 let violations = MissingClaudeMdRule.evaluate(&ctx, &default_config());
837 assert_eq!(violations.len(), 1);
838 }
839
840 #[test]
841 fn test_missing_claude_md_fallback_passes() {
842 let files = vec![make_file("README.md"), make_file("CLAUDE.md")];
843 let ctx = ctx_with_files(&files);
844 let violations = MissingClaudeMdRule.evaluate(&ctx, &default_config());
845 assert!(violations.is_empty());
846 }
847
848 #[test]
851 fn test_missing_readme_from_analysis() {
852 let config = ConfigQualityResult {
853 score: 0.2,
854 has_claude_md: true,
855 has_claude_instructions: false,
856 has_readme: false,
857 details: vec![],
858 llm_adjusted: false,
859 };
860 let ctx = ctx_with_config_quality(&config);
861 let violations = MissingReadmeRule.evaluate(&ctx, &default_config());
862 assert_eq!(violations.len(), 1);
863 assert_eq!(violations[0].title, "Missing README.md");
864 assert_eq!(violations[0].severity, Severity::Warning);
865 }
866
867 #[test]
868 fn test_missing_readme_passes() {
869 let config = ConfigQualityResult {
870 score: 0.8,
871 has_claude_md: true,
872 has_claude_instructions: false,
873 has_readme: true,
874 details: vec![],
875 llm_adjusted: false,
876 };
877 let ctx = ctx_with_config_quality(&config);
878 let violations = MissingReadmeRule.evaluate(&ctx, &default_config());
879 assert!(violations.is_empty());
880 }
881
882 #[test]
885 fn test_short_config_no_issue_for_long_file() {
886 let config = ConfigQualityResult {
887 score: 0.8,
888 has_claude_md: true,
889 has_claude_instructions: false,
890 has_readme: true,
891 details: vec![ConfigDetail {
892 file: "CLAUDE.md".to_string(),
893 length_score: 0.9,
894 structure_score: 0.8,
895 specificity_score: 0.7,
896 actionable_score: 0.8,
897 file_refs_score: 0.7,
898 shell_commands_score: 0.6,
899 recency_score: 1.0,
900 llm_quality_score: None,
901 }],
902 llm_adjusted: false,
903 };
904 let ctx = ctx_with_config_quality(&config);
905 let violations = ShortConfigRule.evaluate(&ctx, &default_config());
906 assert!(violations.is_empty());
907 }
908
909 #[test]
910 fn test_short_config_issue_for_short_file() {
911 let config = ConfigQualityResult {
912 score: 0.3,
913 has_claude_md: true,
914 has_claude_instructions: false,
915 has_readme: true,
916 details: vec![ConfigDetail {
917 file: "CLAUDE.md".to_string(),
918 length_score: 0.1,
919 structure_score: 0.2,
920 specificity_score: 0.1,
921 actionable_score: 0.5,
922 file_refs_score: 0.0,
923 shell_commands_score: 0.0,
924 recency_score: 1.0,
925 llm_quality_score: None,
926 }],
927 llm_adjusted: false,
928 };
929 let ctx = ctx_with_config_quality(&config);
930 let violations = ShortConfigRule.evaluate(&ctx, &default_config());
931 assert_eq!(violations.len(), 1);
932 assert!(violations[0].title.contains("too short"));
933 }
934
935 #[test]
938 fn test_generic_config_no_issue_for_actionable() {
939 let config = ConfigQualityResult {
940 score: 0.8,
941 has_claude_md: true,
942 has_claude_instructions: false,
943 has_readme: true,
944 details: vec![ConfigDetail {
945 file: "CLAUDE.md".to_string(),
946 length_score: 0.9,
947 structure_score: 0.8,
948 specificity_score: 0.7,
949 actionable_score: 0.8,
950 file_refs_score: 0.7,
951 shell_commands_score: 0.6,
952 recency_score: 1.0,
953 llm_quality_score: None,
954 }],
955 llm_adjusted: false,
956 };
957 let ctx = ctx_with_config_quality(&config);
958 let violations = GenericConfigRule.evaluate(&ctx, &default_config());
959 assert!(violations.is_empty());
960 }
961
962 #[test]
963 fn test_generic_config_issue_for_low_actionable() {
964 let config = ConfigQualityResult {
965 score: 0.3,
966 has_claude_md: true,
967 has_claude_instructions: false,
968 has_readme: true,
969 details: vec![ConfigDetail {
970 file: ".cursorrules".to_string(),
971 length_score: 0.5,
972 structure_score: 0.3,
973 specificity_score: 0.2,
974 actionable_score: 0.05,
975 file_refs_score: 0.1,
976 shell_commands_score: 0.0,
977 recency_score: 0.8,
978 llm_quality_score: None,
979 }],
980 llm_adjusted: false,
981 };
982 let ctx = ctx_with_config_quality(&config);
983 let violations = GenericConfigRule.evaluate(&ctx, &default_config());
984 assert_eq!(violations.len(), 1);
985 assert!(violations[0].title.contains("lacks actionable rules"));
986 assert_eq!(violations[0].severity, Severity::Info);
987 }
988
989 #[test]
992 fn test_builtin_matches_scoring_engine_stale() {
993 let freshness = FreshnessResult {
994 score: 0.1,
995 file_scores: vec![FileFreshness {
996 relative_path: "old.md".to_string(),
997 score: 0.1,
998 days_since_modified: Some(100),
999 coupling_penalty: 0.0,
1000 importance_weight: 1.0,
1001 }],
1002 };
1003 let ctx = ctx_with_freshness(&freshness);
1004 let violations = StaleFreshnessRule.evaluate(&ctx, &default_config());
1005 assert_eq!(violations.len(), 1);
1006 assert_eq!(violations[0].severity, Severity::Error);
1007 assert!(violations[0].title.contains("not updated in 100 days"));
1008 }
1009
1010 #[test]
1011 fn test_builtin_matches_scoring_engine_missing_config() {
1012 let config = ConfigQualityResult {
1013 score: 0.0,
1014 has_claude_md: false,
1015 has_claude_instructions: false,
1016 has_readme: false,
1017 details: vec![],
1018 llm_adjusted: false,
1019 };
1020 let ctx = ctx_with_config_quality(&config);
1021
1022 let claude_violations = MissingClaudeMdRule.evaluate(&ctx, &default_config());
1023 assert_eq!(claude_violations.len(), 1);
1024 assert_eq!(claude_violations[0].severity, Severity::Error); let readme_violations = MissingReadmeRule.evaluate(&ctx, &default_config());
1027 assert_eq!(readme_violations.len(), 1);
1028 assert_eq!(readme_violations[0].severity, Severity::Warning); }
1030
1031 #[test]
1032 fn test_all_rules_return_empty_without_analysis() {
1033 let known = Box::leak(Box::new(HashSet::new()));
1034 let parsed = Box::leak(Box::new(HashMap::<String, ParsedDocument>::new()));
1035 let files = vec![make_file("CLAUDE.md"), make_file("README.md")];
1036 let ctx = RuleContext {
1037 files: &files,
1038 known_paths: known,
1039 parsed_docs: parsed,
1040 git_infos: &[],
1041 project_root: std::path::Path::new("/tmp/test"),
1042 freshness: None,
1043 integrity: None,
1044 config_quality: None,
1045 agent_setup: None,
1046 structure: None,
1047 };
1048
1049 assert!(StaleFreshnessRule.evaluate(&ctx, &default_config()).is_empty());
1051 assert!(CouplingPenaltyRule.evaluate(&ctx, &default_config()).is_empty());
1052 assert!(BrokenLinkRule.evaluate(&ctx, &default_config()).is_empty());
1054 assert!(MissingClaudeMdRule.evaluate(&ctx, &default_config()).is_empty());
1056 assert!(MissingReadmeRule.evaluate(&ctx, &default_config()).is_empty());
1057 assert!(ShortConfigRule.evaluate(&ctx, &default_config()).is_empty());
1059 assert!(GenericConfigRule.evaluate(&ctx, &default_config()).is_empty());
1060 }
1061}