Skip to main content

kardo_core/rules/
builtin.rs

1//! Built-in rule implementations.
2//!
3//! These rules replicate the hardcoded issue generation logic from
4//! `scoring::engine` (generate_freshness_issues, generate_integrity_issues,
5//! generate_config_issues) as configurable `Rule` trait implementations.
6//!
7//! Each rule operates on analysis results stored in `RuleContext` and produces
8//! `RuleViolation`s that are identical in semantics to the old `QualityIssue`s.
9
10use crate::analysis::integrity::BrokenRefKind;
11
12use super::{Rule, RuleCategory, RuleConfig, RuleContext, RuleViolation, Severity};
13
14// ────────────────────────────────────────────────────────────────────
15// Freshness Rules
16// ────────────────────────────────────────────────────────────────────
17
18/// Flags documents that haven't been updated in a long time.
19///
20/// Matches the hardcoded logic in `ScoringEngine::generate_freshness_issues`:
21///   >90 days  => Error (was IssueSeverity::High)
22///   >30 days  => Warning (was IssueSeverity::Medium)
23pub 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        // Allow threshold overrides via config options
34        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                // Fallback: use raw git_infos if no analysis results
45                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
139/// Flags documents whose referenced source code has changed since the doc
140/// was last updated (coupling penalty > 0.1).
141///
142/// Matches `ScoringEngine::generate_freshness_issues` coupling logic.
143pub 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
191// ────────────────────────────────────────────────────────────────────
192// Integrity Rules
193// ────────────────────────────────────────────────────────────────────
194
195/// Flags broken internal links (file not found or heading not found).
196///
197/// Matches `ScoringEngine::generate_integrity_issues`:
198///   FileNotFound    => Error (was IssueSeverity::High)
199///   HeadingNotFound => Warning (was IssueSeverity::Medium)
200pub 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
248// ────────────────────────────────────────────────────────────────────
249// Configuration Rules
250// ────────────────────────────────────────────────────────────────────
251
252/// Flags when CLAUDE.md is missing from the project.
253///
254/// Matches `ScoringEngine::generate_config_issues` (has_claude_md check).
255pub 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        // Prefer analysis results; fall back to raw file scan
266        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
293/// Flags when README.md is missing from the project.
294///
295/// Matches `ScoringEngine::generate_config_issues` (has_readme check).
296pub 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
333/// Flags config files that are too short (length_score < 0.3).
334///
335/// Matches `ScoringEngine::generate_config_issues` (length_score check).
336pub 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
381/// Flags config files that lack actionable rules (actionable_score < 0.2).
382///
383/// Matches `ScoringEngine::generate_config_issues` (actionable_score check).
384pub 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// ────────────────────────────────────────────────────────────────────
430// Tests
431// ────────────────────────────────────────────────────────────────────
432
433#[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    // ── StaleFreshnessRule tests ──
531
532    #[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); // aging + old, not fresh
618    }
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        // 20 > 10 (medium) but < 30 (high) => 1 warning
656        assert_eq!(violations.len(), 1);
657        assert_eq!(violations[0].severity, Severity::Warning);
658    }
659
660    // ── CouplingPenaltyRule tests ──
661
662    #[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    // ── BrokenLinkRule tests ──
719
720    #[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    // ── MissingClaudeMdRule tests ──
799
800    #[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    // ── MissingReadmeRule tests ──
849
850    #[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    // ── ShortConfigRule tests ──
883
884    #[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    // ── GenericConfigRule tests ──
936
937    #[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    // ── Scoring engine equivalence ──
990
991    #[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); // was High
1025
1026        let readme_violations = MissingReadmeRule.evaluate(&ctx, &default_config());
1027        assert_eq!(readme_violations.len(), 1);
1028        assert_eq!(readme_violations[0].severity, Severity::Warning); // was Medium
1029    }
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        // Stale and coupling need freshness data
1050        assert!(StaleFreshnessRule.evaluate(&ctx, &default_config()).is_empty());
1051        assert!(CouplingPenaltyRule.evaluate(&ctx, &default_config()).is_empty());
1052        // Broken link needs integrity data
1053        assert!(BrokenLinkRule.evaluate(&ctx, &default_config()).is_empty());
1054        // Missing rules fallback to file scan — both present
1055        assert!(MissingClaudeMdRule.evaluate(&ctx, &default_config()).is_empty());
1056        assert!(MissingReadmeRule.evaluate(&ctx, &default_config()).is_empty());
1057        // Short/generic need config_quality data
1058        assert!(ShortConfigRule.evaluate(&ctx, &default_config()).is_empty());
1059        assert!(GenericConfigRule.evaluate(&ctx, &default_config()).is_empty());
1060    }
1061}