Skip to main content

agnix_core/
lib.rs

1//! # agnix-core
2//!
3//! Core validation engine for agent configurations.
4//!
5//! Validates:
6//! - Agent Skills (SKILL.md)
7//! - Agent definitions (.md files with frontmatter)
8//! - MCP tool configurations
9//! - Claude Code hooks
10//! - CLAUDE.md memory files
11//! - Plugin manifests
12
13pub mod config;
14pub mod diagnostics;
15pub mod eval;
16mod file_utils;
17pub mod fixes;
18mod parsers;
19mod rules;
20mod schemas;
21
22use std::collections::HashMap;
23use std::path::{Path, PathBuf};
24
25use rayon::prelude::*;
26
27pub use config::LintConfig;
28pub use diagnostics::{Diagnostic, DiagnosticLevel, Fix, LintError, LintResult};
29pub use fixes::{apply_fixes, FixResult};
30pub use rules::Validator;
31
32/// Result of validating a project, including diagnostics and metadata.
33#[derive(Debug, Clone)]
34pub struct ValidationResult {
35    /// Diagnostics found during validation.
36    pub diagnostics: Vec<Diagnostic>,
37    /// Number of files that were checked (excludes Unknown file types).
38    pub files_checked: usize,
39}
40
41/// Detected file type for validator dispatch
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub enum FileType {
44    /// SKILL.md files
45    Skill,
46    /// CLAUDE.md, AGENTS.md files
47    ClaudeMd,
48    /// .claude/agents/*.md or agents/*.md
49    Agent,
50    /// settings.json, settings.local.json
51    Hooks,
52    /// plugin.json (validator checks .claude-plugin/ location)
53    Plugin,
54    /// MCP configuration files (*.mcp.json, mcp.json, mcp-*.json)
55    Mcp,
56    /// GitHub Copilot global instructions (.github/copilot-instructions.md)
57    Copilot,
58    /// GitHub Copilot scoped instructions (.github/instructions/*.instructions.md)
59    CopilotScoped,
60    /// Cursor project rules (.cursor/rules/*.mdc)
61    CursorRule,
62    /// Legacy Cursor rules file (.cursorrules)
63    CursorRulesLegacy,
64    /// Other .md files (for XML/import checks)
65    GenericMarkdown,
66    /// Skip validation
67    Unknown,
68}
69
70/// Factory function type that creates validator instances.
71pub type ValidatorFactory = fn() -> Box<dyn Validator>;
72
73/// Registry that maps [`FileType`] values to validator factories.
74///
75/// This is the extension point for the validation engine. A
76/// `ValidatorRegistry` owns a set of [`ValidatorFactory`] functions for each
77/// supported [`FileType`], and constructs concrete [`Validator`] instances on
78/// demand.
79///
80/// Most callers should use [`ValidatorRegistry::with_defaults`] to obtain a
81/// registry pre-populated with all built-in validators.
82pub struct ValidatorRegistry {
83    validators: HashMap<FileType, Vec<ValidatorFactory>>,
84}
85
86impl ValidatorRegistry {
87    /// Create an empty registry with no registered validators.
88    pub fn new() -> Self {
89        Self {
90            validators: HashMap::new(),
91        }
92    }
93
94    /// Create a registry pre-populated with built-in validators.
95    pub fn with_defaults() -> Self {
96        let mut registry = Self::new();
97        registry.register_defaults();
98        registry
99    }
100
101    /// Register a validator factory for a given file type.
102    pub fn register(&mut self, file_type: FileType, factory: ValidatorFactory) {
103        self.validators.entry(file_type).or_default().push(factory);
104    }
105
106    /// Build a fresh validator instance list for the given file type.
107    pub fn validators_for(&self, file_type: FileType) -> Vec<Box<dyn Validator>> {
108        self.validators
109            .get(&file_type)
110            .into_iter()
111            .flatten()
112            .map(|factory| factory())
113            .collect()
114    }
115
116    fn register_defaults(&mut self) {
117        const DEFAULTS: &[(FileType, ValidatorFactory)] = &[
118            (FileType::Skill, skill_validator),
119            (FileType::Skill, xml_validator),
120            (FileType::Skill, imports_validator),
121            (FileType::ClaudeMd, claude_md_validator),
122            (FileType::ClaudeMd, cross_platform_validator),
123            (FileType::ClaudeMd, agents_md_validator),
124            (FileType::ClaudeMd, xml_validator),
125            (FileType::ClaudeMd, imports_validator),
126            (FileType::ClaudeMd, prompt_validator),
127            (FileType::Agent, agent_validator),
128            (FileType::Agent, xml_validator),
129            (FileType::Hooks, hooks_validator),
130            (FileType::Plugin, plugin_validator),
131            (FileType::Mcp, mcp_validator),
132            (FileType::Copilot, copilot_validator),
133            (FileType::Copilot, xml_validator),
134            (FileType::CopilotScoped, copilot_validator),
135            (FileType::CopilotScoped, xml_validator),
136            (FileType::CursorRule, cursor_validator),
137            (FileType::CursorRulesLegacy, cursor_validator),
138            (FileType::GenericMarkdown, cross_platform_validator),
139            (FileType::GenericMarkdown, xml_validator),
140            (FileType::GenericMarkdown, imports_validator),
141        ];
142
143        for &(file_type, factory) in DEFAULTS {
144            self.register(file_type, factory);
145        }
146    }
147}
148
149impl Default for ValidatorRegistry {
150    fn default() -> Self {
151        Self::with_defaults()
152    }
153}
154
155fn skill_validator() -> Box<dyn Validator> {
156    Box::new(rules::skill::SkillValidator)
157}
158
159fn claude_md_validator() -> Box<dyn Validator> {
160    Box::new(rules::claude_md::ClaudeMdValidator)
161}
162
163fn agents_md_validator() -> Box<dyn Validator> {
164    Box::new(rules::agents_md::AgentsMdValidator)
165}
166
167fn agent_validator() -> Box<dyn Validator> {
168    Box::new(rules::agent::AgentValidator)
169}
170
171fn hooks_validator() -> Box<dyn Validator> {
172    Box::new(rules::hooks::HooksValidator)
173}
174
175fn plugin_validator() -> Box<dyn Validator> {
176    Box::new(rules::plugin::PluginValidator)
177}
178
179fn mcp_validator() -> Box<dyn Validator> {
180    Box::new(rules::mcp::McpValidator)
181}
182
183fn xml_validator() -> Box<dyn Validator> {
184    Box::new(rules::xml::XmlValidator)
185}
186
187fn imports_validator() -> Box<dyn Validator> {
188    Box::new(rules::imports::ImportsValidator)
189}
190
191fn cross_platform_validator() -> Box<dyn Validator> {
192    Box::new(rules::cross_platform::CrossPlatformValidator)
193}
194
195fn prompt_validator() -> Box<dyn Validator> {
196    Box::new(rules::prompt::PromptValidator)
197}
198
199fn copilot_validator() -> Box<dyn Validator> {
200    Box::new(rules::copilot::CopilotValidator)
201}
202
203fn cursor_validator() -> Box<dyn Validator> {
204    Box::new(rules::cursor::CursorValidator)
205}
206/// Detect file type based on path patterns
207pub fn detect_file_type(path: &Path) -> FileType {
208    let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
209    let parent = path
210        .parent()
211        .and_then(|p| p.file_name())
212        .and_then(|n| n.to_str());
213    let grandparent = path
214        .parent()
215        .and_then(|p| p.parent())
216        .and_then(|p| p.file_name())
217        .and_then(|n| n.to_str());
218
219    match filename {
220        "SKILL.md" => FileType::Skill,
221        "CLAUDE.md" | "CLAUDE.local.md" | "AGENTS.md" | "AGENTS.local.md"
222        | "AGENTS.override.md" => FileType::ClaudeMd,
223        "settings.json" | "settings.local.json" => FileType::Hooks,
224        // Classify any plugin.json as Plugin - validator checks location constraint (CC-PL-001)
225        "plugin.json" => FileType::Plugin,
226        // MCP configuration files
227        "mcp.json" => FileType::Mcp,
228        name if name.ends_with(".mcp.json") => FileType::Mcp,
229        name if name.starts_with("mcp-") && name.ends_with(".json") => FileType::Mcp,
230        // GitHub Copilot global instructions (.github/copilot-instructions.md)
231        "copilot-instructions.md" if parent == Some(".github") => FileType::Copilot,
232        // GitHub Copilot scoped instructions (.github/instructions/*.instructions.md)
233        name if name.ends_with(".instructions.md")
234            && parent == Some("instructions")
235            && grandparent == Some(".github") =>
236        {
237            FileType::CopilotScoped
238        }
239        // Cursor project rules (.cursor/rules/*.mdc)
240        name if name.ends_with(".mdc")
241            && parent == Some("rules")
242            && grandparent == Some(".cursor") =>
243        {
244            FileType::CursorRule
245        }
246        // Legacy Cursor rules file (.cursorrules)
247        ".cursorrules" => FileType::CursorRulesLegacy,
248        name if name.ends_with(".md") => {
249            if parent == Some("agents") || grandparent == Some("agents") {
250                FileType::Agent
251            } else {
252                FileType::GenericMarkdown
253            }
254        }
255        _ => FileType::Unknown,
256    }
257}
258
259/// Validate a single file
260pub fn validate_file(path: &Path, config: &LintConfig) -> LintResult<Vec<Diagnostic>> {
261    let registry = ValidatorRegistry::with_defaults();
262    validate_file_with_registry(path, config, &registry)
263}
264
265/// Validate a single file with a custom validator registry
266pub fn validate_file_with_registry(
267    path: &Path,
268    config: &LintConfig,
269    registry: &ValidatorRegistry,
270) -> LintResult<Vec<Diagnostic>> {
271    let file_type = detect_file_type(path);
272
273    if file_type == FileType::Unknown {
274        return Ok(vec![]);
275    }
276
277    let content = file_utils::safe_read_file(path)?;
278
279    let validators = registry.validators_for(file_type);
280    let mut diagnostics = Vec::new();
281
282    for validator in validators {
283        diagnostics.extend(validator.validate(path, &content, config));
284    }
285
286    Ok(diagnostics)
287}
288
289/// Main entry point for validating a project
290pub fn validate_project(path: &Path, config: &LintConfig) -> LintResult<ValidationResult> {
291    let registry = ValidatorRegistry::with_defaults();
292    validate_project_with_registry(path, config, &registry)
293}
294
295struct ExcludePattern {
296    pattern: glob::Pattern,
297    dir_only_prefix: Option<String>,
298    allow_probe: bool,
299}
300
301fn normalize_rel_path(entry_path: &Path, root: &Path) -> String {
302    let rel_path = entry_path.strip_prefix(root).unwrap_or(entry_path);
303    let mut path_str = rel_path.to_string_lossy().replace('\\', "/");
304    if let Some(stripped) = path_str.strip_prefix("./") {
305        path_str = stripped.to_string();
306    }
307    path_str
308}
309
310fn compile_exclude_patterns(excludes: &[String]) -> Vec<ExcludePattern> {
311    excludes
312        .iter()
313        .map(|pattern| {
314            let normalized = pattern.replace('\\', "/");
315            let (glob_str, dir_only_prefix) = if let Some(prefix) = normalized.strip_suffix('/') {
316                (format!("{}/**", prefix), Some(prefix.to_string()))
317            } else {
318                (normalized.clone(), None)
319            };
320            let allow_probe = dir_only_prefix.is_some() || glob_str.contains("**");
321            ExcludePattern {
322                pattern: glob::Pattern::new(&glob_str)
323                    .unwrap_or_else(|_| panic!("Invalid exclude pattern in config: {}", pattern)),
324                dir_only_prefix,
325                allow_probe,
326            }
327        })
328        .collect()
329}
330
331fn should_prune_dir(rel_dir: &str, exclude_patterns: &[ExcludePattern]) -> bool {
332    if rel_dir.is_empty() {
333        return false;
334    }
335    // Probe path used to detect patterns that match files inside a directory.
336    // Only apply it for recursive patterns (e.g. ** or dir-only prefix).
337    let probe = format!("{}/__agnix_probe__", rel_dir.trim_end_matches('/'));
338    exclude_patterns
339        .iter()
340        .any(|p| p.pattern.matches(rel_dir) || (p.allow_probe && p.pattern.matches(&probe)))
341}
342
343fn is_excluded_file(path_str: &str, exclude_patterns: &[ExcludePattern]) -> bool {
344    exclude_patterns
345        .iter()
346        .any(|p| p.pattern.matches(path_str) && p.dir_only_prefix.as_deref() != Some(path_str))
347}
348
349/// Main entry point for validating a project with a custom validator registry
350pub fn validate_project_with_registry(
351    path: &Path,
352    config: &LintConfig,
353    registry: &ValidatorRegistry,
354) -> LintResult<ValidationResult> {
355    use ignore::WalkBuilder;
356    use std::sync::Arc;
357
358    let root_dir = resolve_validation_root(path);
359    let mut config = config.clone();
360    config.set_root_dir(root_dir.clone());
361
362    // Pre-compile exclude patterns once (avoids N+1 pattern compilation)
363    // Panic on invalid patterns to catch config errors early
364    let exclude_patterns = compile_exclude_patterns(&config.exclude);
365    let exclude_patterns = Arc::new(exclude_patterns);
366    let root_path = root_dir.clone();
367
368    let walk_root = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
369
370    // Collect file paths (sequential walk, parallel validation)
371    // Note: hidden(false) includes .github directory for Copilot instruction files
372    let paths: Vec<PathBuf> = WalkBuilder::new(&walk_root)
373        .hidden(false)
374        .git_ignore(true)
375        .filter_entry({
376            let exclude_patterns = Arc::clone(&exclude_patterns);
377            let root_path = root_path.clone();
378            move |entry| {
379                let entry_path = entry.path();
380                if entry_path == root_path {
381                    return true;
382                }
383                if entry.file_type().is_some_and(|ft| ft.is_dir()) {
384                    let rel_path = normalize_rel_path(entry_path, &root_path);
385                    return !should_prune_dir(&rel_path, exclude_patterns.as_slice());
386                }
387                true
388            }
389        })
390        .build()
391        .filter_map(|entry| entry.ok())
392        .filter(|entry| entry.path().is_file())
393        .filter(|entry| {
394            let entry_path = entry.path();
395            let path_str = normalize_rel_path(entry_path, &root_path);
396            !is_excluded_file(&path_str, exclude_patterns.as_slice())
397        })
398        .map(|entry| entry.path().to_path_buf())
399        .collect();
400
401    // Count recognized files (detect_file_type is string-only, no I/O)
402    let files_checked = paths
403        .iter()
404        .filter(|p| detect_file_type(p) != FileType::Unknown)
405        .count();
406
407    // Validate files in parallel
408    let mut diagnostics: Vec<Diagnostic> = paths
409        .par_iter()
410        .flat_map(
411            |file_path| match validate_file_with_registry(file_path, &config, registry) {
412                Ok(file_diagnostics) => file_diagnostics,
413                Err(e) => {
414                    vec![Diagnostic::error(
415                        file_path.clone(),
416                        0,
417                        0,
418                        "file::read",
419                        format!("Failed to validate file: {}", e),
420                    )]
421                }
422            },
423        )
424        .collect();
425
426    // AGM-006: Check for multiple AGENTS.md files in the directory tree (project-level check)
427    if config.is_rule_enabled("AGM-006") {
428        let agents_md_paths: Vec<_> = paths
429            .iter()
430            .filter(|p| p.file_name().and_then(|n| n.to_str()) == Some("AGENTS.md"))
431            .collect();
432
433        if agents_md_paths.len() > 1 {
434            for agents_file in &agents_md_paths {
435                let parent_files =
436                    schemas::agents_md::check_agents_md_hierarchy(agents_file, &paths);
437                let description = if !parent_files.is_empty() {
438                    let parent_paths: Vec<String> = parent_files
439                        .iter()
440                        .map(|p| p.to_string_lossy().to_string())
441                        .collect();
442                    format!(
443                        "Nested AGENTS.md detected - parent AGENTS.md files exist at: {}",
444                        parent_paths.join(", ")
445                    )
446                } else {
447                    let other_paths: Vec<String> = agents_md_paths
448                        .iter()
449                        .filter(|p| p.as_path() != agents_file.as_path())
450                        .map(|p| p.to_string_lossy().to_string())
451                        .collect();
452                    format!(
453                        "Multiple AGENTS.md files detected - other AGENTS.md files exist at: {}",
454                        other_paths.join(", ")
455                    )
456                };
457
458                diagnostics.push(
459                    Diagnostic::warning(
460                        (*agents_file).clone(),
461                        1,
462                        0,
463                        "AGM-006",
464                        description,
465                    )
466                    .with_suggestion(
467                        "Some tools load AGENTS.md hierarchically. Document inheritance behavior or consolidate files.".to_string(),
468                    ),
469                );
470            }
471        }
472    }
473
474    // XP-004, XP-005, XP-006: Cross-layer contradiction detection (project-level checks)
475    // These rules analyze relationships between multiple instruction files
476    let xp004_enabled = config.is_rule_enabled("XP-004");
477    let xp005_enabled = config.is_rule_enabled("XP-005");
478    let xp006_enabled = config.is_rule_enabled("XP-006");
479
480    if xp004_enabled || xp005_enabled || xp006_enabled {
481        // Collect instruction files (CLAUDE.md, AGENTS.md, etc.)
482        let instruction_files: Vec<_> = paths
483            .iter()
484            .filter(|p| schemas::cross_platform::is_instruction_file(p))
485            .collect();
486
487        if instruction_files.len() > 1 {
488            // Read content of all instruction files
489            let mut file_contents: Vec<(PathBuf, String)> = Vec::new();
490            for file_path in &instruction_files {
491                match file_utils::safe_read_file(file_path) {
492                    Ok(content) => {
493                        file_contents.push(((*file_path).clone(), content));
494                    }
495                    Err(e) => {
496                        diagnostics.push(Diagnostic::error(
497                            (*file_path).clone(),
498                            0,
499                            0,
500                            "XP-004",
501                            format!("Failed to read instruction file: {}", e),
502                        ));
503                    }
504                }
505            }
506
507            // XP-004: Detect conflicting build/test commands
508            if xp004_enabled {
509                let file_commands: Vec<_> = file_contents
510                    .iter()
511                    .map(|(path, content)| {
512                        (
513                            path.clone(),
514                            schemas::cross_platform::extract_build_commands(content),
515                        )
516                    })
517                    .filter(|(_, cmds)| !cmds.is_empty())
518                    .collect();
519
520                let build_conflicts =
521                    schemas::cross_platform::detect_build_conflicts(&file_commands);
522                for conflict in build_conflicts {
523                    diagnostics.push(
524                        Diagnostic::warning(
525                            conflict.file1.clone(),
526                            conflict.file1_line,
527                            0,
528                            "XP-004",
529                            format!(
530                                "Conflicting package managers: {} uses {} but {} uses {} for {} commands",
531                                conflict.file1.display(),
532                                conflict.file1_manager.as_str(),
533                                conflict.file2.display(),
534                                conflict.file2_manager.as_str(),
535                                match conflict.command_type {
536                                    schemas::cross_platform::CommandType::Install => "install",
537                                    schemas::cross_platform::CommandType::Build => "build",
538                                    schemas::cross_platform::CommandType::Test => "test",
539                                    schemas::cross_platform::CommandType::Run => "run",
540                                    schemas::cross_platform::CommandType::Other => "other",
541                                }
542                            ),
543                        )
544                        .with_suggestion(
545                            "Standardize on a single package manager across all instruction files".to_string(),
546                        ),
547                    );
548                }
549            }
550
551            // XP-005: Detect conflicting tool constraints
552            if xp005_enabled {
553                let file_constraints: Vec<_> = file_contents
554                    .iter()
555                    .map(|(path, content)| {
556                        (
557                            path.clone(),
558                            schemas::cross_platform::extract_tool_constraints(content),
559                        )
560                    })
561                    .filter(|(_, constraints)| !constraints.is_empty())
562                    .collect();
563
564                let tool_conflicts =
565                    schemas::cross_platform::detect_tool_conflicts(&file_constraints);
566                for conflict in tool_conflicts {
567                    diagnostics.push(
568                        Diagnostic::error(
569                            conflict.allow_file.clone(),
570                            conflict.allow_line,
571                            0,
572                            "XP-005",
573                            format!(
574                                "Conflicting tool constraints: '{}' is allowed in {} but disallowed in {}",
575                                conflict.tool_name,
576                                conflict.allow_file.display(),
577                                conflict.disallow_file.display()
578                            ),
579                        )
580                        .with_suggestion(
581                            "Resolve the conflict by consistently allowing or disallowing the tool".to_string(),
582                        ),
583                    );
584                }
585            }
586
587            // XP-006: Detect multiple layers without documented precedence
588            if xp006_enabled {
589                let layers: Vec<_> = file_contents
590                    .iter()
591                    .map(|(path, content)| schemas::cross_platform::categorize_layer(path, content))
592                    .collect();
593
594                if let Some(issue) = schemas::cross_platform::detect_precedence_issues(&layers) {
595                    // Report on the first layer file
596                    if let Some(first_layer) = issue.layers.first() {
597                        diagnostics.push(
598                            Diagnostic::warning(
599                                first_layer.path.clone(),
600                                1,
601                                0,
602                                "XP-006",
603                                issue.description,
604                            )
605                            .with_suggestion(
606                                "Document which file takes precedence (e.g., 'CLAUDE.md takes precedence over AGENTS.md')".to_string(),
607                            ),
608                        );
609                    }
610                }
611            }
612        }
613    }
614
615    // VER-001: Warn when no tool/spec versions are explicitly pinned (project-level check)
616    // This helps users understand that version-dependent rules are using default assumptions
617    if config.is_rule_enabled("VER-001") {
618        let has_any_version_pinned = config.is_claude_code_version_pinned()
619            || config.tool_versions.codex.is_some()
620            || config.tool_versions.cursor.is_some()
621            || config.tool_versions.copilot.is_some()
622            || config.is_mcp_revision_pinned()
623            || config.spec_revisions.agent_skills_spec.is_some()
624            || config.spec_revisions.agents_md_spec.is_some();
625
626        if !has_any_version_pinned {
627            // Use .agnix.toml path or project root as the file reference
628            let config_file = root_dir.join(".agnix.toml");
629            let report_path = if config_file.exists() {
630                config_file
631            } else {
632                root_dir.clone()
633            };
634
635            diagnostics.push(
636                Diagnostic::info(
637                    report_path,
638                    1,
639                    0,
640                    "VER-001",
641                    "No tool or spec versions pinned. Version-dependent rules will use default assumptions.".to_string(),
642                )
643                .with_suggestion(
644                    "Pin versions in .agnix.toml [tool_versions] or [spec_revisions] for deterministic validation.".to_string(),
645                ),
646            );
647        }
648    }
649
650    // Sort by severity (errors first), then by file path, then by line/rule for full determinism
651    diagnostics.sort_by(|a, b| {
652        a.level
653            .cmp(&b.level)
654            .then_with(|| a.file.cmp(&b.file))
655            .then_with(|| a.line.cmp(&b.line))
656            .then_with(|| a.rule.cmp(&b.rule))
657    });
658
659    Ok(ValidationResult {
660        diagnostics,
661        files_checked,
662    })
663}
664
665fn resolve_validation_root(path: &Path) -> PathBuf {
666    let candidate = if path.is_file() {
667        path.parent().unwrap_or(Path::new("."))
668    } else {
669        path
670    };
671    std::fs::canonicalize(candidate).unwrap_or_else(|_| candidate.to_path_buf())
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677
678    fn workspace_root() -> &'static Path {
679        use std::sync::OnceLock;
680
681        static ROOT: OnceLock<PathBuf> = OnceLock::new();
682        ROOT.get_or_init(|| {
683            let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
684            for ancestor in manifest_dir.ancestors() {
685                let cargo_toml = ancestor.join("Cargo.toml");
686                if let Ok(content) = std::fs::read_to_string(&cargo_toml) {
687                    if content.contains("[workspace]") || content.contains("[workspace.") {
688                        return ancestor.to_path_buf();
689                    }
690                }
691            }
692            panic!(
693                "Failed to locate workspace root from CARGO_MANIFEST_DIR={}",
694                manifest_dir.display()
695            );
696        })
697        .as_path()
698    }
699
700    #[test]
701    fn test_detect_skill_file() {
702        assert_eq!(detect_file_type(Path::new("SKILL.md")), FileType::Skill);
703        assert_eq!(
704            detect_file_type(Path::new(".claude/skills/my-skill/SKILL.md")),
705            FileType::Skill
706        );
707    }
708
709    #[test]
710    fn test_detect_claude_md() {
711        assert_eq!(detect_file_type(Path::new("CLAUDE.md")), FileType::ClaudeMd);
712        assert_eq!(detect_file_type(Path::new("AGENTS.md")), FileType::ClaudeMd);
713        assert_eq!(
714            detect_file_type(Path::new("project/CLAUDE.md")),
715            FileType::ClaudeMd
716        );
717    }
718
719    #[test]
720    fn test_detect_instruction_variants() {
721        // CLAUDE.local.md variant
722        assert_eq!(
723            detect_file_type(Path::new("CLAUDE.local.md")),
724            FileType::ClaudeMd
725        );
726        assert_eq!(
727            detect_file_type(Path::new("project/CLAUDE.local.md")),
728            FileType::ClaudeMd
729        );
730
731        // AGENTS.local.md variant
732        assert_eq!(
733            detect_file_type(Path::new("AGENTS.local.md")),
734            FileType::ClaudeMd
735        );
736        assert_eq!(
737            detect_file_type(Path::new("subdir/AGENTS.local.md")),
738            FileType::ClaudeMd
739        );
740
741        // AGENTS.override.md variant
742        assert_eq!(
743            detect_file_type(Path::new("AGENTS.override.md")),
744            FileType::ClaudeMd
745        );
746        assert_eq!(
747            detect_file_type(Path::new("deep/nested/AGENTS.override.md")),
748            FileType::ClaudeMd
749        );
750    }
751
752    #[test]
753    fn test_repo_agents_md_matches_claude_md() {
754        let repo_root = workspace_root();
755
756        let claude_path = repo_root.join("CLAUDE.md");
757        let claude = std::fs::read_to_string(&claude_path).unwrap_or_else(|e| {
758            panic!("Failed to read CLAUDE.md at {}: {e}", claude_path.display());
759        });
760        let agents_path = repo_root.join("AGENTS.md");
761        let agents = std::fs::read_to_string(&agents_path).unwrap_or_else(|e| {
762            panic!("Failed to read AGENTS.md at {}: {e}", agents_path.display());
763        });
764
765        assert_eq!(agents, claude, "AGENTS.md must match CLAUDE.md");
766    }
767
768    #[test]
769    fn test_detect_agents() {
770        assert_eq!(
771            detect_file_type(Path::new("agents/my-agent.md")),
772            FileType::Agent
773        );
774        assert_eq!(
775            detect_file_type(Path::new(".claude/agents/helper.md")),
776            FileType::Agent
777        );
778    }
779
780    #[test]
781    fn test_detect_hooks() {
782        assert_eq!(
783            detect_file_type(Path::new("settings.json")),
784            FileType::Hooks
785        );
786        assert_eq!(
787            detect_file_type(Path::new(".claude/settings.local.json")),
788            FileType::Hooks
789        );
790    }
791
792    #[test]
793    fn test_detect_plugin() {
794        // plugin.json in .claude-plugin/ directory
795        assert_eq!(
796            detect_file_type(Path::new("my-plugin.claude-plugin/plugin.json")),
797            FileType::Plugin
798        );
799        // plugin.json outside .claude-plugin/ is still classified as Plugin
800        // (validator checks location constraint CC-PL-001)
801        assert_eq!(
802            detect_file_type(Path::new("some/plugin.json")),
803            FileType::Plugin
804        );
805        assert_eq!(detect_file_type(Path::new("plugin.json")), FileType::Plugin);
806    }
807
808    #[test]
809    fn test_detect_generic_markdown() {
810        assert_eq!(
811            detect_file_type(Path::new("README.md")),
812            FileType::GenericMarkdown
813        );
814        assert_eq!(
815            detect_file_type(Path::new("docs/guide.md")),
816            FileType::GenericMarkdown
817        );
818    }
819
820    #[test]
821    fn test_detect_mcp() {
822        assert_eq!(detect_file_type(Path::new("mcp.json")), FileType::Mcp);
823        assert_eq!(detect_file_type(Path::new("tools.mcp.json")), FileType::Mcp);
824        assert_eq!(
825            detect_file_type(Path::new("my-server.mcp.json")),
826            FileType::Mcp
827        );
828        assert_eq!(detect_file_type(Path::new("mcp-tools.json")), FileType::Mcp);
829        assert_eq!(
830            detect_file_type(Path::new("mcp-servers.json")),
831            FileType::Mcp
832        );
833        assert_eq!(
834            detect_file_type(Path::new(".claude/mcp.json")),
835            FileType::Mcp
836        );
837    }
838
839    #[test]
840    fn test_detect_unknown() {
841        assert_eq!(detect_file_type(Path::new("main.rs")), FileType::Unknown);
842        assert_eq!(
843            detect_file_type(Path::new("package.json")),
844            FileType::Unknown
845        );
846    }
847
848    #[test]
849    fn test_validators_for_skill() {
850        let registry = ValidatorRegistry::with_defaults();
851        let validators = registry.validators_for(FileType::Skill);
852        assert_eq!(validators.len(), 3);
853    }
854
855    #[test]
856    fn test_validators_for_claude_md() {
857        let registry = ValidatorRegistry::with_defaults();
858        let validators = registry.validators_for(FileType::ClaudeMd);
859        assert_eq!(validators.len(), 6);
860    }
861
862    #[test]
863    fn test_validators_for_mcp() {
864        let registry = ValidatorRegistry::with_defaults();
865        let validators = registry.validators_for(FileType::Mcp);
866        assert_eq!(validators.len(), 1);
867    }
868
869    #[test]
870    fn test_validators_for_unknown() {
871        let registry = ValidatorRegistry::with_defaults();
872        let validators = registry.validators_for(FileType::Unknown);
873        assert_eq!(validators.len(), 0);
874    }
875
876    #[test]
877    fn test_validate_file_with_custom_registry() {
878        struct DummyValidator;
879
880        impl Validator for DummyValidator {
881            fn validate(
882                &self,
883                path: &Path,
884                _content: &str,
885                _config: &LintConfig,
886            ) -> Vec<Diagnostic> {
887                vec![Diagnostic::error(
888                    path.to_path_buf(),
889                    1,
890                    1,
891                    "TEST-001",
892                    "Registry override".to_string(),
893                )]
894            }
895        }
896
897        let temp = tempfile::TempDir::new().unwrap();
898        let skill_path = temp.path().join("SKILL.md");
899        std::fs::write(&skill_path, "---\nname: test\n---\nBody").unwrap();
900
901        let mut registry = ValidatorRegistry::new();
902        registry.register(FileType::Skill, || Box::new(DummyValidator));
903
904        let diagnostics =
905            validate_file_with_registry(&skill_path, &LintConfig::default(), &registry).unwrap();
906
907        assert_eq!(diagnostics.len(), 1);
908        assert_eq!(diagnostics[0].rule, "TEST-001");
909    }
910
911    #[test]
912    fn test_validate_file_unknown_type() {
913        let temp = tempfile::TempDir::new().unwrap();
914        let unknown_path = temp.path().join("test.rs");
915        std::fs::write(&unknown_path, "fn main() {}").unwrap();
916
917        let config = LintConfig::default();
918        let diagnostics = validate_file(&unknown_path, &config).unwrap();
919
920        assert_eq!(diagnostics.len(), 0);
921    }
922
923    #[test]
924    fn test_validate_file_skill() {
925        let temp = tempfile::TempDir::new().unwrap();
926        let skill_path = temp.path().join("SKILL.md");
927        std::fs::write(
928            &skill_path,
929            "---\nname: test-skill\ndescription: Use when testing\n---\nBody",
930        )
931        .unwrap();
932
933        let config = LintConfig::default();
934        let diagnostics = validate_file(&skill_path, &config).unwrap();
935
936        assert!(diagnostics.is_empty());
937    }
938
939    #[test]
940    fn test_validate_file_invalid_skill() {
941        let temp = tempfile::TempDir::new().unwrap();
942        let skill_path = temp.path().join("SKILL.md");
943        std::fs::write(
944            &skill_path,
945            "---\nname: deploy-prod\ndescription: Deploys\n---\nBody",
946        )
947        .unwrap();
948
949        let config = LintConfig::default();
950        let diagnostics = validate_file(&skill_path, &config).unwrap();
951
952        assert!(!diagnostics.is_empty());
953        assert!(diagnostics.iter().any(|d| d.rule == "CC-SK-006"));
954    }
955
956    #[test]
957    fn test_validate_project_finds_issues() {
958        let temp = tempfile::TempDir::new().unwrap();
959        let skill_dir = temp.path().join("skills").join("deploy");
960        std::fs::create_dir_all(&skill_dir).unwrap();
961        std::fs::write(
962            skill_dir.join("SKILL.md"),
963            "---\nname: deploy-prod\ndescription: Deploys\n---\nBody",
964        )
965        .unwrap();
966
967        let config = LintConfig::default();
968        let result = validate_project(temp.path(), &config).unwrap();
969
970        assert!(!result.diagnostics.is_empty());
971    }
972
973    #[test]
974    fn test_validate_project_empty_dir() {
975        let temp = tempfile::TempDir::new().unwrap();
976
977        // Disable VER-001 since we're testing an empty project
978        let mut config = LintConfig::default();
979        config.rules.disabled_rules = vec!["VER-001".to_string()];
980        let result = validate_project(temp.path(), &config).unwrap();
981
982        assert!(result.diagnostics.is_empty());
983    }
984
985    #[test]
986    fn test_validate_project_sorts_by_severity() {
987        let temp = tempfile::TempDir::new().unwrap();
988
989        let skill_dir = temp.path().join("skill1");
990        std::fs::create_dir_all(&skill_dir).unwrap();
991        std::fs::write(
992            skill_dir.join("SKILL.md"),
993            "---\nname: deploy-prod\ndescription: Deploys\n---\nBody",
994        )
995        .unwrap();
996
997        let config = LintConfig::default();
998        let result = validate_project(temp.path(), &config).unwrap();
999
1000        for i in 1..result.diagnostics.len() {
1001            assert!(result.diagnostics[i - 1].level <= result.diagnostics[i].level);
1002        }
1003    }
1004
1005    #[test]
1006    fn test_validate_invalid_skill_triggers_both_rules() {
1007        let temp = tempfile::TempDir::new().unwrap();
1008        let skill_path = temp.path().join("SKILL.md");
1009        std::fs::write(
1010            &skill_path,
1011            "---\nname: deploy-prod\ndescription: Deploys\nallowed-tools: Bash Read Write\n---\nBody",
1012        )
1013        .unwrap();
1014
1015        let config = LintConfig::default();
1016        let diagnostics = validate_file(&skill_path, &config).unwrap();
1017
1018        assert!(diagnostics.iter().any(|d| d.rule == "CC-SK-006"));
1019        assert!(diagnostics.iter().any(|d| d.rule == "CC-SK-007"));
1020    }
1021
1022    #[test]
1023    fn test_validate_valid_skill_produces_no_errors() {
1024        let temp = tempfile::TempDir::new().unwrap();
1025        let skill_path = temp.path().join("SKILL.md");
1026        std::fs::write(
1027            &skill_path,
1028            "---\nname: code-review\ndescription: Use when reviewing code\n---\nBody",
1029        )
1030        .unwrap();
1031
1032        let config = LintConfig::default();
1033        let diagnostics = validate_file(&skill_path, &config).unwrap();
1034
1035        let errors: Vec<_> = diagnostics
1036            .iter()
1037            .filter(|d| d.level == DiagnosticLevel::Error)
1038            .collect();
1039        assert!(errors.is_empty());
1040    }
1041
1042    #[test]
1043    fn test_parallel_validation_deterministic_output() {
1044        // Create a project structure with multiple files that will generate diagnostics
1045        let temp = tempfile::TempDir::new().unwrap();
1046
1047        // Create multiple skill files with issues to ensure non-trivial parallel work
1048        for i in 0..5 {
1049            let skill_dir = temp.path().join(format!("skill-{}", i));
1050            std::fs::create_dir_all(&skill_dir).unwrap();
1051            std::fs::write(
1052                skill_dir.join("SKILL.md"),
1053                format!(
1054                    "---\nname: deploy-prod-{}\ndescription: Deploys things\n---\nBody",
1055                    i
1056                ),
1057            )
1058            .unwrap();
1059        }
1060
1061        // Create some CLAUDE.md files too
1062        for i in 0..3 {
1063            let dir = temp.path().join(format!("project-{}", i));
1064            std::fs::create_dir_all(&dir).unwrap();
1065            std::fs::write(
1066                dir.join("CLAUDE.md"),
1067                "# Project\n\nBe helpful and concise.\n",
1068            )
1069            .unwrap();
1070        }
1071
1072        let config = LintConfig::default();
1073
1074        // Run validation multiple times and verify identical output
1075        let first_result = validate_project(temp.path(), &config).unwrap();
1076
1077        for run in 1..=10 {
1078            let result = validate_project(temp.path(), &config).unwrap();
1079
1080            assert_eq!(
1081                first_result.diagnostics.len(),
1082                result.diagnostics.len(),
1083                "Run {} produced different number of diagnostics",
1084                run
1085            );
1086
1087            for (i, (a, b)) in first_result
1088                .diagnostics
1089                .iter()
1090                .zip(result.diagnostics.iter())
1091                .enumerate()
1092            {
1093                assert_eq!(
1094                    a.file, b.file,
1095                    "Run {} diagnostic {} has different file",
1096                    run, i
1097                );
1098                assert_eq!(
1099                    a.rule, b.rule,
1100                    "Run {} diagnostic {} has different rule",
1101                    run, i
1102                );
1103                assert_eq!(
1104                    a.level, b.level,
1105                    "Run {} diagnostic {} has different level",
1106                    run, i
1107                );
1108            }
1109        }
1110
1111        // Verify we actually got some diagnostics (the dangerous name rule should fire)
1112        assert!(
1113            !first_result.diagnostics.is_empty(),
1114            "Expected diagnostics for deploy-prod-* skill names"
1115        );
1116    }
1117
1118    #[test]
1119    fn test_parallel_validation_single_file() {
1120        // Edge case: verify parallel code works correctly with just one file
1121        let temp = tempfile::TempDir::new().unwrap();
1122        std::fs::write(
1123            temp.path().join("SKILL.md"),
1124            "---\nname: deploy-prod\ndescription: Deploys\n---\nBody",
1125        )
1126        .unwrap();
1127
1128        let config = LintConfig::default();
1129        let result = validate_project(temp.path(), &config).unwrap();
1130
1131        // Should have at least one diagnostic for the dangerous name (CC-SK-006)
1132        assert!(
1133            result.diagnostics.iter().any(|d| d.rule == "CC-SK-006"),
1134            "Expected CC-SK-006 diagnostic for dangerous deploy-prod name"
1135        );
1136    }
1137
1138    #[test]
1139    fn test_parallel_validation_mixed_results() {
1140        // Test mix of valid and invalid files processed in parallel
1141        let temp = tempfile::TempDir::new().unwrap();
1142
1143        // Valid skill (no diagnostics expected)
1144        let valid_dir = temp.path().join("valid");
1145        std::fs::create_dir_all(&valid_dir).unwrap();
1146        std::fs::write(
1147            valid_dir.join("SKILL.md"),
1148            "---\nname: code-review\ndescription: Use when reviewing code\n---\nBody",
1149        )
1150        .unwrap();
1151
1152        // Invalid skill (diagnostics expected)
1153        let invalid_dir = temp.path().join("invalid");
1154        std::fs::create_dir_all(&invalid_dir).unwrap();
1155        std::fs::write(
1156            invalid_dir.join("SKILL.md"),
1157            "---\nname: deploy-prod\ndescription: Deploys\n---\nBody",
1158        )
1159        .unwrap();
1160
1161        let config = LintConfig::default();
1162        let result = validate_project(temp.path(), &config).unwrap();
1163
1164        // Should have diagnostics only from the invalid skill
1165        let error_diagnostics: Vec<_> = result
1166            .diagnostics
1167            .iter()
1168            .filter(|d| d.level == DiagnosticLevel::Error)
1169            .collect();
1170
1171        assert!(
1172            error_diagnostics
1173                .iter()
1174                .all(|d| d.file.to_string_lossy().contains("invalid")),
1175            "Errors should only come from the invalid skill"
1176        );
1177    }
1178
1179    #[test]
1180    fn test_validate_project_agents_md_collection() {
1181        // Verify that validation correctly collects AGENTS.md paths for AGM-006
1182        let temp = tempfile::TempDir::new().unwrap();
1183
1184        // Create multiple AGENTS.md files in different directories
1185        std::fs::write(temp.path().join("AGENTS.md"), "# Root agents").unwrap();
1186
1187        let subdir = temp.path().join("subproject");
1188        std::fs::create_dir_all(&subdir).unwrap();
1189        std::fs::write(subdir.join("AGENTS.md"), "# Subproject agents").unwrap();
1190
1191        let config = LintConfig::default();
1192        let result = validate_project(temp.path(), &config).unwrap();
1193
1194        // Should have AGM-006 warnings for both AGENTS.md files
1195        let agm006_diagnostics: Vec<_> = result
1196            .diagnostics
1197            .iter()
1198            .filter(|d| d.rule == "AGM-006")
1199            .collect();
1200
1201        assert_eq!(
1202            agm006_diagnostics.len(),
1203            2,
1204            "Expected AGM-006 diagnostic for each AGENTS.md file, got: {:?}",
1205            agm006_diagnostics
1206        );
1207    }
1208
1209    #[test]
1210    fn test_validate_project_files_checked_count() {
1211        // Verify that validation correctly counts recognized file types
1212        let temp = tempfile::TempDir::new().unwrap();
1213
1214        // Create recognized file types
1215        std::fs::write(
1216            temp.path().join("SKILL.md"),
1217            "---\nname: test-skill\ndescription: Test skill\n---\nBody",
1218        )
1219        .unwrap();
1220        std::fs::write(temp.path().join("CLAUDE.md"), "# Project memory").unwrap();
1221
1222        // Create unrecognized file types (should not be counted)
1223        // Note: .md files are GenericMarkdown (recognized), so use non-markdown extensions
1224        std::fs::write(temp.path().join("notes.txt"), "Some notes").unwrap();
1225        std::fs::write(temp.path().join("data.json"), "{}").unwrap();
1226
1227        let config = LintConfig::default();
1228        let result = validate_project(temp.path(), &config).unwrap();
1229
1230        // files_checked should only count recognized types (SKILL.md + CLAUDE.md = 2)
1231        // .txt and .json (not matching MCP patterns) are FileType::Unknown
1232        assert_eq!(
1233            result.files_checked, 2,
1234            "files_checked should count only recognized file types, got {}",
1235            result.files_checked
1236        );
1237    }
1238
1239    #[test]
1240    fn test_validate_project_plugin_detection() {
1241        let temp = tempfile::TempDir::new().unwrap();
1242        let plugin_dir = temp.path().join("my-plugin.claude-plugin");
1243        std::fs::create_dir_all(&plugin_dir).unwrap();
1244
1245        // Create plugin.json with a validation issue (missing description - CC-PL-004)
1246        std::fs::write(
1247            plugin_dir.join("plugin.json"),
1248            r#"{"name": "test-plugin", "version": "1.0.0"}"#,
1249        )
1250        .unwrap();
1251
1252        let config = LintConfig::default();
1253        let result = validate_project(temp.path(), &config).unwrap();
1254
1255        // Should detect the plugin.json and report CC-PL-004 for missing description
1256        let plugin_diagnostics: Vec<_> = result
1257            .diagnostics
1258            .iter()
1259            .filter(|d| d.rule.starts_with("CC-PL-"))
1260            .collect();
1261
1262        assert!(
1263            !plugin_diagnostics.is_empty(),
1264            "validate_project() should detect and validate plugin.json files"
1265        );
1266
1267        assert!(
1268            plugin_diagnostics.iter().any(|d| d.rule == "CC-PL-004"),
1269            "Should report CC-PL-004 for missing description field"
1270        );
1271    }
1272
1273    // ===== MCP Validation Integration Tests =====
1274
1275    #[test]
1276    fn test_validate_file_mcp() {
1277        let temp = tempfile::TempDir::new().unwrap();
1278        let mcp_path = temp.path().join("tools.mcp.json");
1279        std::fs::write(
1280            &mcp_path,
1281            r#"{"name": "test-tool", "description": "A test tool for testing purposes", "inputSchema": {"type": "object"}}"#,
1282        )
1283        .unwrap();
1284
1285        let config = LintConfig::default();
1286        let diagnostics = validate_file(&mcp_path, &config).unwrap();
1287
1288        // Tool without consent field should trigger MCP-005 warning
1289        assert!(diagnostics.iter().any(|d| d.rule == "MCP-005"));
1290    }
1291
1292    #[test]
1293    fn test_validate_file_mcp_invalid_schema() {
1294        let temp = tempfile::TempDir::new().unwrap();
1295        let mcp_path = temp.path().join("mcp.json");
1296        std::fs::write(
1297            &mcp_path,
1298            r#"{"name": "test-tool", "description": "A test tool for testing purposes", "inputSchema": "not an object"}"#,
1299        )
1300        .unwrap();
1301
1302        let config = LintConfig::default();
1303        let diagnostics = validate_file(&mcp_path, &config).unwrap();
1304
1305        // Invalid schema should trigger MCP-003
1306        assert!(diagnostics.iter().any(|d| d.rule == "MCP-003"));
1307    }
1308
1309    #[test]
1310    fn test_validate_project_mcp_detection() {
1311        let temp = tempfile::TempDir::new().unwrap();
1312
1313        // Create an MCP file with issues
1314        std::fs::write(
1315            temp.path().join("tools.mcp.json"),
1316            r#"{"name": "", "description": "Short", "inputSchema": {"type": "object"}}"#,
1317        )
1318        .unwrap();
1319
1320        let config = LintConfig::default();
1321        let result = validate_project(temp.path(), &config).unwrap();
1322
1323        // Should detect the MCP file and report issues
1324        let mcp_diagnostics: Vec<_> = result
1325            .diagnostics
1326            .iter()
1327            .filter(|d| d.rule.starts_with("MCP-"))
1328            .collect();
1329
1330        assert!(
1331            !mcp_diagnostics.is_empty(),
1332            "validate_project() should detect and validate MCP files"
1333        );
1334
1335        // Empty name should trigger MCP-002
1336        assert!(
1337            mcp_diagnostics.iter().any(|d| d.rule == "MCP-002"),
1338            "Should report MCP-002 for empty name"
1339        );
1340    }
1341
1342    // ===== Cross-Platform Validation Integration Tests =====
1343
1344    #[test]
1345    fn test_validate_agents_md_with_claude_features() {
1346        let temp = tempfile::TempDir::new().unwrap();
1347
1348        // Create AGENTS.md with Claude-specific features
1349        std::fs::write(
1350            temp.path().join("AGENTS.md"),
1351            r#"# Agent Config
1352- type: PreToolExecution
1353  command: echo "test"
1354"#,
1355        )
1356        .unwrap();
1357
1358        let config = LintConfig::default();
1359        let result = validate_project(temp.path(), &config).unwrap();
1360
1361        // Should detect XP-001 error for Claude-specific hooks in AGENTS.md
1362        let xp_001: Vec<_> = result
1363            .diagnostics
1364            .iter()
1365            .filter(|d| d.rule == "XP-001")
1366            .collect();
1367        assert!(
1368            !xp_001.is_empty(),
1369            "Expected XP-001 error for hooks in AGENTS.md"
1370        );
1371    }
1372
1373    #[test]
1374    fn test_validate_agents_md_with_context_fork() {
1375        let temp = tempfile::TempDir::new().unwrap();
1376
1377        // Create AGENTS.md with context: fork
1378        std::fs::write(
1379            temp.path().join("AGENTS.md"),
1380            r#"---
1381name: test
1382context: fork
1383agent: Explore
1384---
1385# Test Agent
1386"#,
1387        )
1388        .unwrap();
1389
1390        let config = LintConfig::default();
1391        let result = validate_project(temp.path(), &config).unwrap();
1392
1393        // Should detect XP-001 errors for Claude-specific features
1394        let xp_001: Vec<_> = result
1395            .diagnostics
1396            .iter()
1397            .filter(|d| d.rule == "XP-001")
1398            .collect();
1399        assert!(
1400            !xp_001.is_empty(),
1401            "Expected XP-001 errors for context:fork and agent in AGENTS.md"
1402        );
1403    }
1404
1405    #[test]
1406    fn test_validate_agents_md_no_headers() {
1407        let temp = tempfile::TempDir::new().unwrap();
1408
1409        // Create AGENTS.md with no headers
1410        std::fs::write(
1411            temp.path().join("AGENTS.md"),
1412            "Just plain text without any markdown headers.",
1413        )
1414        .unwrap();
1415
1416        let config = LintConfig::default();
1417        let result = validate_project(temp.path(), &config).unwrap();
1418
1419        // Should detect XP-002 warning for missing headers
1420        let xp_002: Vec<_> = result
1421            .diagnostics
1422            .iter()
1423            .filter(|d| d.rule == "XP-002")
1424            .collect();
1425        assert!(
1426            !xp_002.is_empty(),
1427            "Expected XP-002 warning for missing headers in AGENTS.md"
1428        );
1429    }
1430
1431    #[test]
1432    fn test_validate_agents_md_hard_coded_paths() {
1433        let temp = tempfile::TempDir::new().unwrap();
1434
1435        // Create AGENTS.md with hard-coded platform paths
1436        std::fs::write(
1437            temp.path().join("AGENTS.md"),
1438            r#"# Config
1439Check .claude/settings.json and .cursor/rules/ for configuration.
1440"#,
1441        )
1442        .unwrap();
1443
1444        let config = LintConfig::default();
1445        let result = validate_project(temp.path(), &config).unwrap();
1446
1447        // Should detect XP-003 warnings for hard-coded paths
1448        let xp_003: Vec<_> = result
1449            .diagnostics
1450            .iter()
1451            .filter(|d| d.rule == "XP-003")
1452            .collect();
1453        assert_eq!(
1454            xp_003.len(),
1455            2,
1456            "Expected 2 XP-003 warnings for hard-coded paths"
1457        );
1458    }
1459
1460    #[test]
1461    fn test_validate_valid_agents_md() {
1462        let temp = tempfile::TempDir::new().unwrap();
1463
1464        // Create valid AGENTS.md without any issues
1465        std::fs::write(
1466            temp.path().join("AGENTS.md"),
1467            r#"# Project Guidelines
1468
1469Follow the coding style guide.
1470
1471## Commands
1472- npm run build
1473- npm run test
1474"#,
1475        )
1476        .unwrap();
1477
1478        let config = LintConfig::default();
1479        let result = validate_project(temp.path(), &config).unwrap();
1480
1481        // Should have no XP-* diagnostics
1482        let xp_rules: Vec<_> = result
1483            .diagnostics
1484            .iter()
1485            .filter(|d| d.rule.starts_with("XP-"))
1486            .collect();
1487        assert!(
1488            xp_rules.is_empty(),
1489            "Valid AGENTS.md should have no XP-* diagnostics"
1490        );
1491    }
1492
1493    #[test]
1494    fn test_validate_claude_md_allows_claude_features() {
1495        let temp = tempfile::TempDir::new().unwrap();
1496
1497        // Create CLAUDE.md with Claude-specific features (allowed)
1498        std::fs::write(
1499            temp.path().join("CLAUDE.md"),
1500            r#"---
1501name: test
1502context: fork
1503agent: Explore
1504allowed-tools: Read Write
1505---
1506# Claude Agent
1507"#,
1508        )
1509        .unwrap();
1510
1511        let config = LintConfig::default();
1512        let result = validate_project(temp.path(), &config).unwrap();
1513
1514        // XP-001 should NOT fire for CLAUDE.md (Claude features are allowed there)
1515        let xp_001: Vec<_> = result
1516            .diagnostics
1517            .iter()
1518            .filter(|d| d.rule == "XP-001")
1519            .collect();
1520        assert!(
1521            xp_001.is_empty(),
1522            "CLAUDE.md should be allowed to have Claude-specific features"
1523        );
1524    }
1525
1526    // ===== AGM-006: Multiple AGENTS.md Tests =====
1527
1528    #[test]
1529    fn test_agm_006_nested_agents_md() {
1530        let temp = tempfile::TempDir::new().unwrap();
1531
1532        // Create nested AGENTS.md files
1533        std::fs::write(
1534            temp.path().join("AGENTS.md"),
1535            "# Project\n\nThis project does something.",
1536        )
1537        .unwrap();
1538
1539        let subdir = temp.path().join("subdir");
1540        std::fs::create_dir_all(&subdir).unwrap();
1541        std::fs::write(
1542            subdir.join("AGENTS.md"),
1543            "# Subproject\n\nThis is a nested AGENTS.md.",
1544        )
1545        .unwrap();
1546
1547        let config = LintConfig::default();
1548        let result = validate_project(temp.path(), &config).unwrap();
1549
1550        // Should detect AGM-006 for both AGENTS.md files
1551        let agm_006: Vec<_> = result
1552            .diagnostics
1553            .iter()
1554            .filter(|d| d.rule == "AGM-006")
1555            .collect();
1556        assert_eq!(
1557            agm_006.len(),
1558            2,
1559            "Should detect both AGENTS.md files, got {:?}",
1560            agm_006
1561        );
1562        assert!(agm_006
1563            .iter()
1564            .any(|d| d.file.to_string_lossy().contains("subdir")));
1565        assert!(agm_006
1566            .iter()
1567            .any(|d| d.message.contains("Nested AGENTS.md")));
1568        assert!(agm_006
1569            .iter()
1570            .any(|d| d.message.contains("Multiple AGENTS.md files")));
1571    }
1572
1573    #[test]
1574    fn test_agm_006_no_nesting() {
1575        let temp = tempfile::TempDir::new().unwrap();
1576
1577        // Create single AGENTS.md file
1578        std::fs::write(
1579            temp.path().join("AGENTS.md"),
1580            "# Project\n\nThis project does something.",
1581        )
1582        .unwrap();
1583
1584        let config = LintConfig::default();
1585        let result = validate_project(temp.path(), &config).unwrap();
1586
1587        // Should not detect AGM-006 for a single AGENTS.md
1588        let agm_006: Vec<_> = result
1589            .diagnostics
1590            .iter()
1591            .filter(|d| d.rule == "AGM-006")
1592            .collect();
1593        assert!(
1594            agm_006.is_empty(),
1595            "Single AGENTS.md should not trigger AGM-006"
1596        );
1597    }
1598
1599    #[test]
1600    fn test_agm_006_multiple_agents_md() {
1601        let temp = tempfile::TempDir::new().unwrap();
1602
1603        let app_a = temp.path().join("app-a");
1604        let app_b = temp.path().join("app-b");
1605        std::fs::create_dir_all(&app_a).unwrap();
1606        std::fs::create_dir_all(&app_b).unwrap();
1607
1608        std::fs::write(
1609            app_a.join("AGENTS.md"),
1610            "# App A\n\nThis project does something.",
1611        )
1612        .unwrap();
1613        std::fs::write(
1614            app_b.join("AGENTS.md"),
1615            "# App B\n\nThis project does something.",
1616        )
1617        .unwrap();
1618
1619        let config = LintConfig::default();
1620        let result = validate_project(temp.path(), &config).unwrap();
1621
1622        let agm_006: Vec<_> = result
1623            .diagnostics
1624            .iter()
1625            .filter(|d| d.rule == "AGM-006")
1626            .collect();
1627        assert_eq!(
1628            agm_006.len(),
1629            2,
1630            "Should detect both AGENTS.md files, got {:?}",
1631            agm_006
1632        );
1633        assert!(agm_006
1634            .iter()
1635            .all(|d| d.message.contains("Multiple AGENTS.md files")));
1636    }
1637
1638    #[test]
1639    fn test_agm_006_disabled() {
1640        let temp = tempfile::TempDir::new().unwrap();
1641
1642        // Create nested AGENTS.md files
1643        std::fs::write(
1644            temp.path().join("AGENTS.md"),
1645            "# Project\n\nThis project does something.",
1646        )
1647        .unwrap();
1648
1649        let subdir = temp.path().join("subdir");
1650        std::fs::create_dir_all(&subdir).unwrap();
1651        std::fs::write(
1652            subdir.join("AGENTS.md"),
1653            "# Subproject\n\nThis is a nested AGENTS.md.",
1654        )
1655        .unwrap();
1656
1657        let mut config = LintConfig::default();
1658        config.rules.disabled_rules = vec!["AGM-006".to_string()];
1659        let result = validate_project(temp.path(), &config).unwrap();
1660
1661        // Should not detect AGM-006 when disabled
1662        let agm_006: Vec<_> = result
1663            .diagnostics
1664            .iter()
1665            .filter(|d| d.rule == "AGM-006")
1666            .collect();
1667        assert!(agm_006.is_empty(), "AGM-006 should not fire when disabled");
1668    }
1669
1670    // ===== XP-004: Conflicting Build Commands =====
1671
1672    #[test]
1673    fn test_xp_004_conflicting_package_managers() {
1674        let temp = tempfile::TempDir::new().unwrap();
1675
1676        // CLAUDE.md uses npm
1677        std::fs::write(
1678            temp.path().join("CLAUDE.md"),
1679            "# Project\n\nUse `npm install` for dependencies.",
1680        )
1681        .unwrap();
1682
1683        // AGENTS.md uses pnpm
1684        std::fs::write(
1685            temp.path().join("AGENTS.md"),
1686            "# Project\n\nUse `pnpm install` for dependencies.",
1687        )
1688        .unwrap();
1689
1690        let config = LintConfig::default();
1691        let result = validate_project(temp.path(), &config).unwrap();
1692
1693        let xp_004: Vec<_> = result
1694            .diagnostics
1695            .iter()
1696            .filter(|d| d.rule == "XP-004")
1697            .collect();
1698        assert!(
1699            !xp_004.is_empty(),
1700            "Should detect conflicting package managers"
1701        );
1702        assert!(xp_004.iter().any(|d| d.message.contains("npm")));
1703        assert!(xp_004.iter().any(|d| d.message.contains("pnpm")));
1704    }
1705
1706    #[test]
1707    fn test_xp_004_no_conflict_same_manager() {
1708        let temp = tempfile::TempDir::new().unwrap();
1709
1710        // Both files use npm
1711        std::fs::write(
1712            temp.path().join("CLAUDE.md"),
1713            "# Project\n\nUse `npm install` for dependencies.",
1714        )
1715        .unwrap();
1716
1717        std::fs::write(
1718            temp.path().join("AGENTS.md"),
1719            "# Project\n\nUse `npm run build` for building.",
1720        )
1721        .unwrap();
1722
1723        let config = LintConfig::default();
1724        let result = validate_project(temp.path(), &config).unwrap();
1725
1726        let xp_004: Vec<_> = result
1727            .diagnostics
1728            .iter()
1729            .filter(|d| d.rule == "XP-004")
1730            .collect();
1731        assert!(
1732            xp_004.is_empty(),
1733            "Should not detect conflict when same package manager is used"
1734        );
1735    }
1736
1737    // ===== XP-005: Conflicting Tool Constraints =====
1738
1739    #[test]
1740    fn test_xp_005_conflicting_tool_constraints() {
1741        let temp = tempfile::TempDir::new().unwrap();
1742
1743        // CLAUDE.md allows Bash
1744        std::fs::write(
1745            temp.path().join("CLAUDE.md"),
1746            "# Project\n\nallowed-tools: Read Write Bash",
1747        )
1748        .unwrap();
1749
1750        // AGENTS.md disallows Bash
1751        std::fs::write(
1752            temp.path().join("AGENTS.md"),
1753            "# Project\n\nNever use Bash for operations.",
1754        )
1755        .unwrap();
1756
1757        let config = LintConfig::default();
1758        let result = validate_project(temp.path(), &config).unwrap();
1759
1760        let xp_005: Vec<_> = result
1761            .diagnostics
1762            .iter()
1763            .filter(|d| d.rule == "XP-005")
1764            .collect();
1765        assert!(
1766            !xp_005.is_empty(),
1767            "Should detect conflicting tool constraints"
1768        );
1769        assert!(xp_005.iter().any(|d| d.message.contains("Bash")));
1770    }
1771
1772    #[test]
1773    fn test_xp_005_no_conflict_consistent_constraints() {
1774        let temp = tempfile::TempDir::new().unwrap();
1775
1776        // Both files allow Read
1777        std::fs::write(
1778            temp.path().join("CLAUDE.md"),
1779            "# Project\n\nallowed-tools: Read Write",
1780        )
1781        .unwrap();
1782
1783        std::fs::write(
1784            temp.path().join("AGENTS.md"),
1785            "# Project\n\nYou can use Read for file access.",
1786        )
1787        .unwrap();
1788
1789        let config = LintConfig::default();
1790        let result = validate_project(temp.path(), &config).unwrap();
1791
1792        let xp_005: Vec<_> = result
1793            .diagnostics
1794            .iter()
1795            .filter(|d| d.rule == "XP-005")
1796            .collect();
1797        assert!(
1798            xp_005.is_empty(),
1799            "Should not detect conflict when constraints are consistent"
1800        );
1801    }
1802
1803    // ===== XP-006: Layer Precedence =====
1804
1805    #[test]
1806    fn test_xp_006_no_precedence_documentation() {
1807        let temp = tempfile::TempDir::new().unwrap();
1808
1809        // Both files exist but neither documents precedence
1810        std::fs::write(
1811            temp.path().join("CLAUDE.md"),
1812            "# Project\n\nThis is Claude.md.",
1813        )
1814        .unwrap();
1815
1816        std::fs::write(
1817            temp.path().join("AGENTS.md"),
1818            "# Project\n\nThis is Agents.md.",
1819        )
1820        .unwrap();
1821
1822        let config = LintConfig::default();
1823        let result = validate_project(temp.path(), &config).unwrap();
1824
1825        let xp_006: Vec<_> = result
1826            .diagnostics
1827            .iter()
1828            .filter(|d| d.rule == "XP-006")
1829            .collect();
1830        assert!(
1831            !xp_006.is_empty(),
1832            "Should detect missing precedence documentation"
1833        );
1834    }
1835
1836    #[test]
1837    fn test_xp_006_with_precedence_documentation() {
1838        let temp = tempfile::TempDir::new().unwrap();
1839
1840        // CLAUDE.md documents precedence
1841        std::fs::write(
1842            temp.path().join("CLAUDE.md"),
1843            "# Project\n\nCLAUDE.md takes precedence over AGENTS.md.",
1844        )
1845        .unwrap();
1846
1847        std::fs::write(
1848            temp.path().join("AGENTS.md"),
1849            "# Project\n\nThis is Agents.md.",
1850        )
1851        .unwrap();
1852
1853        let config = LintConfig::default();
1854        let result = validate_project(temp.path(), &config).unwrap();
1855
1856        let xp_006: Vec<_> = result
1857            .diagnostics
1858            .iter()
1859            .filter(|d| d.rule == "XP-006")
1860            .collect();
1861        assert!(
1862            xp_006.is_empty(),
1863            "Should not trigger XP-006 when precedence is documented"
1864        );
1865    }
1866
1867    #[test]
1868    fn test_xp_006_single_layer_no_issue() {
1869        let temp = tempfile::TempDir::new().unwrap();
1870
1871        // Only CLAUDE.md exists
1872        std::fs::write(
1873            temp.path().join("CLAUDE.md"),
1874            "# Project\n\nThis is Claude.md.",
1875        )
1876        .unwrap();
1877
1878        let config = LintConfig::default();
1879        let result = validate_project(temp.path(), &config).unwrap();
1880
1881        let xp_006: Vec<_> = result
1882            .diagnostics
1883            .iter()
1884            .filter(|d| d.rule == "XP-006")
1885            .collect();
1886        assert!(
1887            xp_006.is_empty(),
1888            "Should not trigger XP-006 with single instruction layer"
1889        );
1890    }
1891
1892    // ===== XP-004/005/006 Edge Case Tests (review findings) =====
1893
1894    #[test]
1895    fn test_xp_004_three_files_conflicting_managers() {
1896        let temp = tempfile::TempDir::new().unwrap();
1897
1898        // CLAUDE.md uses npm
1899        std::fs::write(
1900            temp.path().join("CLAUDE.md"),
1901            "# Project\n\nUse `npm install` for dependencies.",
1902        )
1903        .unwrap();
1904
1905        // AGENTS.md uses pnpm
1906        std::fs::write(
1907            temp.path().join("AGENTS.md"),
1908            "# Project\n\nUse `pnpm install` for dependencies.",
1909        )
1910        .unwrap();
1911
1912        // Add .cursor rules directory with yarn
1913        let cursor_dir = temp.path().join(".cursor").join("rules");
1914        std::fs::create_dir_all(&cursor_dir).unwrap();
1915        std::fs::write(
1916            cursor_dir.join("dev.mdc"),
1917            "# Rules\n\nUse `yarn install` for dependencies.",
1918        )
1919        .unwrap();
1920
1921        let config = LintConfig::default();
1922        let result = validate_project(temp.path(), &config).unwrap();
1923
1924        let xp_004: Vec<_> = result
1925            .diagnostics
1926            .iter()
1927            .filter(|d| d.rule == "XP-004")
1928            .collect();
1929
1930        // Should detect conflicts between all three different package managers
1931        assert!(
1932            xp_004.len() >= 2,
1933            "Should detect multiple conflicts with 3 different package managers, got {}",
1934            xp_004.len()
1935        );
1936    }
1937
1938    #[test]
1939    fn test_xp_004_disabled_rule() {
1940        let temp = tempfile::TempDir::new().unwrap();
1941
1942        // CLAUDE.md uses npm
1943        std::fs::write(
1944            temp.path().join("CLAUDE.md"),
1945            "# Project\n\nUse `npm install` for dependencies.",
1946        )
1947        .unwrap();
1948
1949        // AGENTS.md uses pnpm
1950        std::fs::write(
1951            temp.path().join("AGENTS.md"),
1952            "# Project\n\nUse `pnpm install` for dependencies.",
1953        )
1954        .unwrap();
1955
1956        let mut config = LintConfig::default();
1957        config.rules.disabled_rules = vec!["XP-004".to_string()];
1958        let result = validate_project(temp.path(), &config).unwrap();
1959
1960        let xp_004: Vec<_> = result
1961            .diagnostics
1962            .iter()
1963            .filter(|d| d.rule == "XP-004")
1964            .collect();
1965        assert!(xp_004.is_empty(), "XP-004 should not fire when disabled");
1966    }
1967
1968    #[test]
1969    fn test_xp_005_disabled_rule() {
1970        let temp = tempfile::TempDir::new().unwrap();
1971
1972        // CLAUDE.md allows Bash
1973        std::fs::write(
1974            temp.path().join("CLAUDE.md"),
1975            "# Project\n\nallowed-tools: Read Write Bash",
1976        )
1977        .unwrap();
1978
1979        // AGENTS.md disallows Bash
1980        std::fs::write(
1981            temp.path().join("AGENTS.md"),
1982            "# Project\n\nNever use Bash for operations.",
1983        )
1984        .unwrap();
1985
1986        let mut config = LintConfig::default();
1987        config.rules.disabled_rules = vec!["XP-005".to_string()];
1988        let result = validate_project(temp.path(), &config).unwrap();
1989
1990        let xp_005: Vec<_> = result
1991            .diagnostics
1992            .iter()
1993            .filter(|d| d.rule == "XP-005")
1994            .collect();
1995        assert!(xp_005.is_empty(), "XP-005 should not fire when disabled");
1996    }
1997
1998    #[test]
1999    fn test_xp_006_disabled_rule() {
2000        let temp = tempfile::TempDir::new().unwrap();
2001
2002        // Both files exist but neither documents precedence
2003        std::fs::write(
2004            temp.path().join("CLAUDE.md"),
2005            "# Project\n\nThis is Claude.md.",
2006        )
2007        .unwrap();
2008
2009        std::fs::write(
2010            temp.path().join("AGENTS.md"),
2011            "# Project\n\nThis is Agents.md.",
2012        )
2013        .unwrap();
2014
2015        let mut config = LintConfig::default();
2016        config.rules.disabled_rules = vec!["XP-006".to_string()];
2017        let result = validate_project(temp.path(), &config).unwrap();
2018
2019        let xp_006: Vec<_> = result
2020            .diagnostics
2021            .iter()
2022            .filter(|d| d.rule == "XP-006")
2023            .collect();
2024        assert!(xp_006.is_empty(), "XP-006 should not fire when disabled");
2025    }
2026
2027    #[test]
2028    fn test_xp_empty_instruction_files() {
2029        let temp = tempfile::TempDir::new().unwrap();
2030
2031        // Create empty CLAUDE.md and AGENTS.md
2032        std::fs::write(temp.path().join("CLAUDE.md"), "").unwrap();
2033        std::fs::write(temp.path().join("AGENTS.md"), "").unwrap();
2034
2035        let config = LintConfig::default();
2036        let result = validate_project(temp.path(), &config).unwrap();
2037
2038        // XP-004 should not fire for empty files (no commands)
2039        let xp_004: Vec<_> = result
2040            .diagnostics
2041            .iter()
2042            .filter(|d| d.rule == "XP-004")
2043            .collect();
2044        assert!(xp_004.is_empty(), "Empty files should not trigger XP-004");
2045
2046        // XP-005 should not fire for empty files (no constraints)
2047        let xp_005: Vec<_> = result
2048            .diagnostics
2049            .iter()
2050            .filter(|d| d.rule == "XP-005")
2051            .collect();
2052        assert!(xp_005.is_empty(), "Empty files should not trigger XP-005");
2053    }
2054
2055    #[test]
2056    fn test_xp_005_case_insensitive_tool_matching() {
2057        let temp = tempfile::TempDir::new().unwrap();
2058
2059        // CLAUDE.md allows BASH (uppercase)
2060        std::fs::write(
2061            temp.path().join("CLAUDE.md"),
2062            "# Project\n\nallowed-tools: Read Write BASH",
2063        )
2064        .unwrap();
2065
2066        // AGENTS.md disallows bash (lowercase)
2067        std::fs::write(
2068            temp.path().join("AGENTS.md"),
2069            "# Project\n\nNever use bash for operations.",
2070        )
2071        .unwrap();
2072
2073        let config = LintConfig::default();
2074        let result = validate_project(temp.path(), &config).unwrap();
2075
2076        let xp_005: Vec<_> = result
2077            .diagnostics
2078            .iter()
2079            .filter(|d| d.rule == "XP-005")
2080            .collect();
2081        assert!(
2082            !xp_005.is_empty(),
2083            "Should detect conflict between BASH and bash (case-insensitive)"
2084        );
2085    }
2086
2087    #[test]
2088    fn test_xp_005_word_boundary_no_false_positive() {
2089        let temp = tempfile::TempDir::new().unwrap();
2090
2091        // CLAUDE.md allows Bash
2092        std::fs::write(
2093            temp.path().join("CLAUDE.md"),
2094            "# Project\n\nallowed-tools: Read Write Bash",
2095        )
2096        .unwrap();
2097
2098        // AGENTS.md mentions "subash" (not "Bash")
2099        std::fs::write(
2100            temp.path().join("AGENTS.md"),
2101            "# Project\n\nNever use subash command.",
2102        )
2103        .unwrap();
2104
2105        let config = LintConfig::default();
2106        let result = validate_project(temp.path(), &config).unwrap();
2107
2108        let xp_005: Vec<_> = result
2109            .diagnostics
2110            .iter()
2111            .filter(|d| d.rule == "XP-005")
2112            .collect();
2113        assert!(
2114            xp_005.is_empty(),
2115            "Should NOT detect conflict - 'subash' is not 'Bash'"
2116        );
2117    }
2118
2119    // ===== VER-001 Version Awareness Tests =====
2120
2121    #[test]
2122    fn test_ver_001_warns_when_no_versions_pinned() {
2123        let temp = tempfile::TempDir::new().unwrap();
2124
2125        // Create minimal project
2126        std::fs::write(temp.path().join("CLAUDE.md"), "# Project\n\nInstructions.").unwrap();
2127
2128        // Default config has no versions pinned
2129        let config = LintConfig::default();
2130        let result = validate_project(temp.path(), &config).unwrap();
2131
2132        let ver_001: Vec<_> = result
2133            .diagnostics
2134            .iter()
2135            .filter(|d| d.rule == "VER-001")
2136            .collect();
2137        assert!(
2138            !ver_001.is_empty(),
2139            "Should warn when no tool/spec versions are pinned"
2140        );
2141        // Should be Info level
2142        assert_eq!(ver_001[0].level, DiagnosticLevel::Info);
2143    }
2144
2145    #[test]
2146    fn test_ver_001_no_warning_when_tool_version_pinned() {
2147        let temp = tempfile::TempDir::new().unwrap();
2148
2149        std::fs::write(temp.path().join("CLAUDE.md"), "# Project\n\nInstructions.").unwrap();
2150
2151        let mut config = LintConfig::default();
2152        config.tool_versions.claude_code = Some("2.1.3".to_string());
2153        let result = validate_project(temp.path(), &config).unwrap();
2154
2155        let ver_001: Vec<_> = result
2156            .diagnostics
2157            .iter()
2158            .filter(|d| d.rule == "VER-001")
2159            .collect();
2160        assert!(
2161            ver_001.is_empty(),
2162            "Should NOT warn when a tool version is pinned"
2163        );
2164    }
2165
2166    #[test]
2167    fn test_ver_001_no_warning_when_spec_revision_pinned() {
2168        let temp = tempfile::TempDir::new().unwrap();
2169
2170        std::fs::write(temp.path().join("CLAUDE.md"), "# Project\n\nInstructions.").unwrap();
2171
2172        let mut config = LintConfig::default();
2173        config.spec_revisions.mcp_protocol = Some("2025-06-18".to_string());
2174        let result = validate_project(temp.path(), &config).unwrap();
2175
2176        let ver_001: Vec<_> = result
2177            .diagnostics
2178            .iter()
2179            .filter(|d| d.rule == "VER-001")
2180            .collect();
2181        assert!(
2182            ver_001.is_empty(),
2183            "Should NOT warn when a spec revision is pinned"
2184        );
2185    }
2186
2187    #[test]
2188    fn test_ver_001_disabled_rule() {
2189        let temp = tempfile::TempDir::new().unwrap();
2190
2191        std::fs::write(temp.path().join("CLAUDE.md"), "# Project\n\nInstructions.").unwrap();
2192
2193        let mut config = LintConfig::default();
2194        config.rules.disabled_rules = vec!["VER-001".to_string()];
2195        let result = validate_project(temp.path(), &config).unwrap();
2196
2197        let ver_001: Vec<_> = result
2198            .diagnostics
2199            .iter()
2200            .filter(|d| d.rule == "VER-001")
2201            .collect();
2202        assert!(ver_001.is_empty(), "VER-001 should not fire when disabled");
2203    }
2204
2205    // ===== AGM Validation Integration Tests =====
2206
2207    #[test]
2208    fn test_agm_001_unclosed_code_block() {
2209        let temp = tempfile::TempDir::new().unwrap();
2210
2211        std::fs::write(
2212            temp.path().join("AGENTS.md"),
2213            "# Project\n\n```rust\nfn main() {}",
2214        )
2215        .unwrap();
2216
2217        let config = LintConfig::default();
2218        let result = validate_project(temp.path(), &config).unwrap();
2219
2220        let agm_001: Vec<_> = result
2221            .diagnostics
2222            .iter()
2223            .filter(|d| d.rule == "AGM-001")
2224            .collect();
2225        assert!(!agm_001.is_empty(), "Should detect unclosed code block");
2226    }
2227
2228    #[test]
2229    fn test_agm_003_over_char_limit() {
2230        let temp = tempfile::TempDir::new().unwrap();
2231
2232        let content = format!("# Project\n\n{}", "x".repeat(13000));
2233        std::fs::write(temp.path().join("AGENTS.md"), content).unwrap();
2234
2235        let config = LintConfig::default();
2236        let result = validate_project(temp.path(), &config).unwrap();
2237
2238        let agm_003: Vec<_> = result
2239            .diagnostics
2240            .iter()
2241            .filter(|d| d.rule == "AGM-003")
2242            .collect();
2243        assert!(
2244            !agm_003.is_empty(),
2245            "Should detect character limit exceeded"
2246        );
2247    }
2248
2249    #[test]
2250    fn test_agm_005_unguarded_platform_features() {
2251        let temp = tempfile::TempDir::new().unwrap();
2252
2253        std::fs::write(
2254            temp.path().join("AGENTS.md"),
2255            "# Project\n\n- type: PreToolExecution\n  command: echo test",
2256        )
2257        .unwrap();
2258
2259        let config = LintConfig::default();
2260        let result = validate_project(temp.path(), &config).unwrap();
2261
2262        let agm_005: Vec<_> = result
2263            .diagnostics
2264            .iter()
2265            .filter(|d| d.rule == "AGM-005")
2266            .collect();
2267        assert!(
2268            !agm_005.is_empty(),
2269            "Should detect unguarded platform features"
2270        );
2271    }
2272
2273    #[test]
2274    fn test_valid_agents_md_no_agm_errors() {
2275        let temp = tempfile::TempDir::new().unwrap();
2276
2277        std::fs::write(
2278            temp.path().join("AGENTS.md"),
2279            r#"# Project
2280
2281This project is a linter for agent configurations.
2282
2283## Build Commands
2284
2285Run npm install and npm build.
2286
2287## Claude Code Specific
2288
2289- type: PreToolExecution
2290  command: echo "test"
2291"#,
2292        )
2293        .unwrap();
2294
2295        let config = LintConfig::default();
2296        let result = validate_project(temp.path(), &config).unwrap();
2297
2298        let agm_errors: Vec<_> = result
2299            .diagnostics
2300            .iter()
2301            .filter(|d| d.rule.starts_with("AGM-") && d.level == DiagnosticLevel::Error)
2302            .collect();
2303        assert!(
2304            agm_errors.is_empty(),
2305            "Valid AGENTS.md should have no AGM-* errors, got: {:?}",
2306            agm_errors
2307        );
2308    }
2309    // ===== Fixture Directory Regression Tests =====
2310
2311    /// Helper to locate the fixtures directory for testing
2312    fn get_fixtures_dir() -> PathBuf {
2313        workspace_root().join("tests").join("fixtures")
2314    }
2315
2316    #[test]
2317    fn test_validate_fixtures_directory() {
2318        // Run validate_project() over tests/fixtures/ to verify detect_file_type() works
2319        // This is a regression guard for fixture layout (issue #74)
2320        let fixtures_dir = get_fixtures_dir();
2321
2322        let config = LintConfig::default();
2323        let result = validate_project(&fixtures_dir, &config).unwrap();
2324
2325        // Verify skill fixtures trigger expected AS-* rules
2326        let skill_diagnostics: Vec<_> = result
2327            .diagnostics
2328            .iter()
2329            .filter(|d| d.rule.starts_with("AS-"))
2330            .collect();
2331
2332        // deep-reference/SKILL.md should trigger AS-013 (reference too deep)
2333        assert!(
2334            skill_diagnostics
2335                .iter()
2336                .any(|d| d.rule == "AS-013" && d.file.to_string_lossy().contains("deep-reference")),
2337            "Expected AS-013 from deep-reference/SKILL.md fixture"
2338        );
2339
2340        // missing-frontmatter/SKILL.md should trigger AS-001 (missing frontmatter)
2341        assert!(
2342            skill_diagnostics
2343                .iter()
2344                .any(|d| d.rule == "AS-001"
2345                    && d.file.to_string_lossy().contains("missing-frontmatter")),
2346            "Expected AS-001 from missing-frontmatter/SKILL.md fixture"
2347        );
2348
2349        // windows-path/SKILL.md should trigger AS-014 (windows path separator)
2350        assert!(
2351            skill_diagnostics
2352                .iter()
2353                .any(|d| d.rule == "AS-014" && d.file.to_string_lossy().contains("windows-path")),
2354            "Expected AS-014 from windows-path/SKILL.md fixture"
2355        );
2356
2357        // Verify MCP fixtures trigger expected MCP-* rules
2358        let mcp_diagnostics: Vec<_> = result
2359            .diagnostics
2360            .iter()
2361            .filter(|d| d.rule.starts_with("MCP-"))
2362            .collect();
2363
2364        // At least some MCP diagnostics should be present
2365        assert!(
2366            !mcp_diagnostics.is_empty(),
2367            "Expected MCP diagnostics from tests/fixtures/mcp/*.mcp.json files"
2368        );
2369
2370        // missing-required-fields.mcp.json should trigger MCP-002 (missing description)
2371        assert!(
2372            mcp_diagnostics.iter().any(|d| d.rule == "MCP-002"
2373                && d.file.to_string_lossy().contains("missing-required-fields")),
2374            "Expected MCP-002 from missing-required-fields.mcp.json fixture"
2375        );
2376
2377        // empty-description.mcp.json should trigger MCP-004 (short description)
2378        assert!(
2379            mcp_diagnostics
2380                .iter()
2381                .any(|d| d.rule == "MCP-004"
2382                    && d.file.to_string_lossy().contains("empty-description")),
2383            "Expected MCP-004 from empty-description.mcp.json fixture"
2384        );
2385
2386        // invalid-input-schema.mcp.json should trigger MCP-003 (invalid schema)
2387        assert!(
2388            mcp_diagnostics.iter().any(|d| d.rule == "MCP-003"
2389                && d.file.to_string_lossy().contains("invalid-input-schema")),
2390            "Expected MCP-003 from invalid-input-schema.mcp.json fixture"
2391        );
2392
2393        // invalid-jsonrpc-version.mcp.json should trigger MCP-001 (invalid jsonrpc)
2394        assert!(
2395            mcp_diagnostics.iter().any(|d| d.rule == "MCP-001"
2396                && d.file.to_string_lossy().contains("invalid-jsonrpc-version")),
2397            "Expected MCP-001 from invalid-jsonrpc-version.mcp.json fixture"
2398        );
2399
2400        // missing-consent.mcp.json should trigger MCP-005 (missing consent)
2401        assert!(
2402            mcp_diagnostics.iter().any(
2403                |d| d.rule == "MCP-005" && d.file.to_string_lossy().contains("missing-consent")
2404            ),
2405            "Expected MCP-005 from missing-consent.mcp.json fixture"
2406        );
2407
2408        // untrusted-annotations.mcp.json should trigger MCP-006 (untrusted annotations)
2409        assert!(
2410            mcp_diagnostics.iter().any(|d| d.rule == "MCP-006"
2411                && d.file.to_string_lossy().contains("untrusted-annotations")),
2412            "Expected MCP-006 from untrusted-annotations.mcp.json fixture"
2413        );
2414
2415        // Verify AGM, XP, REF, and XML fixtures trigger expected rules
2416        let expectations = [
2417            (
2418                "AGM-002",
2419                "no-headers",
2420                "Expected AGM-002 from agents_md/no-headers/AGENTS.md fixture",
2421            ),
2422            (
2423                "XP-003",
2424                "hard-coded",
2425                "Expected XP-003 from cross_platform/hard-coded/AGENTS.md fixture",
2426            ),
2427            (
2428                "REF-001",
2429                "missing-import",
2430                "Expected REF-001 from refs/missing-import.md fixture",
2431            ),
2432            (
2433                "REF-002",
2434                "broken-link",
2435                "Expected REF-002 from refs/broken-link.md fixture",
2436            ),
2437            (
2438                "XML-001",
2439                "xml-001-unclosed",
2440                "Expected XML-001 from xml/xml-001-unclosed.md fixture",
2441            ),
2442            (
2443                "XML-002",
2444                "xml-002-mismatch",
2445                "Expected XML-002 from xml/xml-002-mismatch.md fixture",
2446            ),
2447            (
2448                "XML-003",
2449                "xml-003-unmatched",
2450                "Expected XML-003 from xml/xml-003-unmatched.md fixture",
2451            ),
2452        ];
2453
2454        for (rule, file_part, message) in expectations {
2455            assert!(
2456                result
2457                    .diagnostics
2458                    .iter()
2459                    .any(|d| { d.rule == rule && d.file.to_string_lossy().contains(file_part) }),
2460                "{}",
2461                message
2462            );
2463        }
2464    }
2465
2466    #[test]
2467    fn test_fixture_positive_cases_by_family() {
2468        let fixtures_dir = get_fixtures_dir();
2469        let config = LintConfig::default();
2470
2471        let temp = tempfile::TempDir::new().unwrap();
2472        let pe_source = fixtures_dir.join("valid/pe/prompt-complete-valid.md");
2473        let pe_content = std::fs::read_to_string(&pe_source)
2474            .unwrap_or_else(|_| panic!("Failed to read {}", pe_source.display()));
2475        let pe_path = temp.path().join("CLAUDE.md");
2476        std::fs::write(&pe_path, pe_content).unwrap();
2477
2478        let mut cases = vec![
2479            ("AGM-", fixtures_dir.join("agents_md/valid/AGENTS.md")),
2480            ("XP-", fixtures_dir.join("cross_platform/valid/AGENTS.md")),
2481            ("MCP-", fixtures_dir.join("mcp/valid-tool.mcp.json")),
2482            ("REF-", fixtures_dir.join("refs/valid-links.md")),
2483            ("XML-", fixtures_dir.join("xml/xml-valid.md")),
2484        ];
2485        cases.push(("PE-", pe_path));
2486
2487        for (prefix, path) in cases {
2488            let diagnostics = validate_file(&path, &config).unwrap();
2489            let family_diagnostics: Vec<_> = diagnostics
2490                .iter()
2491                .filter(|d| d.rule.starts_with(prefix))
2492                .collect();
2493
2494            assert!(
2495                family_diagnostics.is_empty(),
2496                "Expected no {} diagnostics for fixture {}",
2497                prefix,
2498                path.display()
2499            );
2500        }
2501    }
2502
2503    #[test]
2504    fn test_fixture_file_type_detection() {
2505        // Verify that fixture files are detected as correct FileType
2506        let fixtures_dir = get_fixtures_dir();
2507
2508        // Skill fixtures should be detected as FileType::Skill
2509        assert_eq!(
2510            detect_file_type(&fixtures_dir.join("skills/deep-reference/SKILL.md")),
2511            FileType::Skill,
2512            "deep-reference/SKILL.md should be detected as Skill"
2513        );
2514        assert_eq!(
2515            detect_file_type(&fixtures_dir.join("skills/missing-frontmatter/SKILL.md")),
2516            FileType::Skill,
2517            "missing-frontmatter/SKILL.md should be detected as Skill"
2518        );
2519        assert_eq!(
2520            detect_file_type(&fixtures_dir.join("skills/windows-path/SKILL.md")),
2521            FileType::Skill,
2522            "windows-path/SKILL.md should be detected as Skill"
2523        );
2524
2525        // MCP fixtures should be detected as FileType::Mcp
2526        assert_eq!(
2527            detect_file_type(&fixtures_dir.join("mcp/valid-tool.mcp.json")),
2528            FileType::Mcp,
2529            "valid-tool.mcp.json should be detected as Mcp"
2530        );
2531        assert_eq!(
2532            detect_file_type(&fixtures_dir.join("mcp/empty-description.mcp.json")),
2533            FileType::Mcp,
2534            "empty-description.mcp.json should be detected as Mcp"
2535        );
2536
2537        // Copilot fixtures should be detected as FileType::Copilot or CopilotScoped
2538        assert_eq!(
2539            detect_file_type(&fixtures_dir.join("copilot/.github/copilot-instructions.md")),
2540            FileType::Copilot,
2541            "copilot-instructions.md should be detected as Copilot"
2542        );
2543        assert_eq!(
2544            detect_file_type(
2545                &fixtures_dir.join("copilot/.github/instructions/typescript.instructions.md")
2546            ),
2547            FileType::CopilotScoped,
2548            "typescript.instructions.md should be detected as CopilotScoped"
2549        );
2550    }
2551
2552    // ===== GitHub Copilot Validation Integration Tests =====
2553
2554    #[test]
2555    fn test_detect_copilot_global() {
2556        assert_eq!(
2557            detect_file_type(Path::new(".github/copilot-instructions.md")),
2558            FileType::Copilot
2559        );
2560        assert_eq!(
2561            detect_file_type(Path::new("project/.github/copilot-instructions.md")),
2562            FileType::Copilot
2563        );
2564    }
2565
2566    #[test]
2567    fn test_detect_copilot_scoped() {
2568        assert_eq!(
2569            detect_file_type(Path::new(".github/instructions/typescript.instructions.md")),
2570            FileType::CopilotScoped
2571        );
2572        assert_eq!(
2573            detect_file_type(Path::new(
2574                "project/.github/instructions/rust.instructions.md"
2575            )),
2576            FileType::CopilotScoped
2577        );
2578    }
2579
2580    #[test]
2581    fn test_copilot_not_detected_outside_github() {
2582        // Files outside .github/ should not be detected as Copilot
2583        assert_ne!(
2584            detect_file_type(Path::new("copilot-instructions.md")),
2585            FileType::Copilot
2586        );
2587        assert_ne!(
2588            detect_file_type(Path::new("instructions/typescript.instructions.md")),
2589            FileType::CopilotScoped
2590        );
2591    }
2592
2593    #[test]
2594    fn test_validators_for_copilot() {
2595        let registry = ValidatorRegistry::with_defaults();
2596
2597        let copilot_validators = registry.validators_for(FileType::Copilot);
2598        assert_eq!(copilot_validators.len(), 2); // copilot + xml
2599
2600        let scoped_validators = registry.validators_for(FileType::CopilotScoped);
2601        assert_eq!(scoped_validators.len(), 2); // copilot + xml
2602    }
2603
2604    #[test]
2605    fn test_validate_copilot_fixtures() {
2606        // Use validate_file directly since .github is a hidden directory
2607        // that ignore::WalkBuilder skips by default
2608        let fixtures_dir = get_fixtures_dir();
2609        let copilot_dir = fixtures_dir.join("copilot");
2610
2611        let config = LintConfig::default();
2612
2613        // Validate global instructions
2614        let global_path = copilot_dir.join(".github/copilot-instructions.md");
2615        let diagnostics = validate_file(&global_path, &config).unwrap();
2616        let cop_errors: Vec<_> = diagnostics
2617            .iter()
2618            .filter(|d| d.rule.starts_with("COP-") && d.level == DiagnosticLevel::Error)
2619            .collect();
2620        assert!(
2621            cop_errors.is_empty(),
2622            "Valid global file should have no COP errors, got: {:?}",
2623            cop_errors
2624        );
2625
2626        // Validate scoped instructions
2627        let scoped_path = copilot_dir.join(".github/instructions/typescript.instructions.md");
2628        let diagnostics = validate_file(&scoped_path, &config).unwrap();
2629        let cop_errors: Vec<_> = diagnostics
2630            .iter()
2631            .filter(|d| d.rule.starts_with("COP-") && d.level == DiagnosticLevel::Error)
2632            .collect();
2633        assert!(
2634            cop_errors.is_empty(),
2635            "Valid scoped file should have no COP errors, got: {:?}",
2636            cop_errors
2637        );
2638    }
2639
2640    #[test]
2641    fn test_validate_copilot_invalid_fixtures() {
2642        // Use validate_file directly since .github is a hidden directory
2643        let fixtures_dir = get_fixtures_dir();
2644        let copilot_invalid_dir = fixtures_dir.join("copilot-invalid");
2645        let config = LintConfig::default();
2646
2647        // COP-001: Empty global file
2648        let empty_global = copilot_invalid_dir.join(".github/copilot-instructions.md");
2649        let diagnostics = validate_file(&empty_global, &config).unwrap();
2650        assert!(
2651            diagnostics.iter().any(|d| d.rule == "COP-001"),
2652            "Expected COP-001 from empty copilot-instructions.md fixture"
2653        );
2654
2655        // COP-002: Invalid YAML in bad-frontmatter
2656        let bad_frontmatter =
2657            copilot_invalid_dir.join(".github/instructions/bad-frontmatter.instructions.md");
2658        let diagnostics = validate_file(&bad_frontmatter, &config).unwrap();
2659        assert!(
2660            diagnostics.iter().any(|d| d.rule == "COP-002"),
2661            "Expected COP-002 from bad-frontmatter.instructions.md fixture"
2662        );
2663
2664        // COP-003: Invalid glob in bad-glob
2665        let bad_glob = copilot_invalid_dir.join(".github/instructions/bad-glob.instructions.md");
2666        let diagnostics = validate_file(&bad_glob, &config).unwrap();
2667        assert!(
2668            diagnostics.iter().any(|d| d.rule == "COP-003"),
2669            "Expected COP-003 from bad-glob.instructions.md fixture"
2670        );
2671
2672        // COP-004: Unknown keys in unknown-keys
2673        let unknown_keys =
2674            copilot_invalid_dir.join(".github/instructions/unknown-keys.instructions.md");
2675        let diagnostics = validate_file(&unknown_keys, &config).unwrap();
2676        assert!(
2677            diagnostics.iter().any(|d| d.rule == "COP-004"),
2678            "Expected COP-004 from unknown-keys.instructions.md fixture"
2679        );
2680    }
2681
2682    #[test]
2683    fn test_validate_copilot_file_empty() {
2684        // Test validate_file directly (not validate_project which skips hidden dirs)
2685        let temp = tempfile::TempDir::new().unwrap();
2686        let github_dir = temp.path().join(".github");
2687        std::fs::create_dir_all(&github_dir).unwrap();
2688        let file_path = github_dir.join("copilot-instructions.md");
2689        std::fs::write(&file_path, "").unwrap();
2690
2691        let config = LintConfig::default();
2692        let diagnostics = validate_file(&file_path, &config).unwrap();
2693
2694        let cop_001: Vec<_> = diagnostics.iter().filter(|d| d.rule == "COP-001").collect();
2695        assert_eq!(cop_001.len(), 1, "Expected COP-001 for empty file");
2696    }
2697
2698    #[test]
2699    fn test_validate_copilot_scoped_missing_frontmatter() {
2700        // Test validate_file directly
2701        let temp = tempfile::TempDir::new().unwrap();
2702        let instructions_dir = temp.path().join(".github").join("instructions");
2703        std::fs::create_dir_all(&instructions_dir).unwrap();
2704        let file_path = instructions_dir.join("test.instructions.md");
2705        std::fs::write(&file_path, "# Instructions without frontmatter").unwrap();
2706
2707        let config = LintConfig::default();
2708        let diagnostics = validate_file(&file_path, &config).unwrap();
2709
2710        let cop_002: Vec<_> = diagnostics.iter().filter(|d| d.rule == "COP-002").collect();
2711        assert_eq!(cop_002.len(), 1, "Expected COP-002 for missing frontmatter");
2712    }
2713
2714    #[test]
2715    fn test_validate_copilot_valid_scoped() {
2716        // Test validate_file directly
2717        let temp = tempfile::TempDir::new().unwrap();
2718        let instructions_dir = temp.path().join(".github").join("instructions");
2719        std::fs::create_dir_all(&instructions_dir).unwrap();
2720        let file_path = instructions_dir.join("rust.instructions.md");
2721        std::fs::write(
2722            &file_path,
2723            r#"---
2724applyTo: "**/*.rs"
2725---
2726# Rust Instructions
2727
2728Use idiomatic Rust patterns.
2729"#,
2730        )
2731        .unwrap();
2732
2733        let config = LintConfig::default();
2734        let diagnostics = validate_file(&file_path, &config).unwrap();
2735
2736        let cop_errors: Vec<_> = diagnostics
2737            .iter()
2738            .filter(|d| d.rule.starts_with("COP-") && d.level == DiagnosticLevel::Error)
2739            .collect();
2740        assert!(
2741            cop_errors.is_empty(),
2742            "Valid scoped file should have no COP errors"
2743        );
2744    }
2745
2746    #[test]
2747    fn test_validate_project_finds_github_hidden_dir() {
2748        // Test validate_project walks .github directory (not just validate_file)
2749        let temp = tempfile::TempDir::new().unwrap();
2750        let github_dir = temp.path().join(".github");
2751        std::fs::create_dir_all(&github_dir).unwrap();
2752
2753        // Create an empty copilot-instructions.md file (should trigger COP-001)
2754        let file_path = github_dir.join("copilot-instructions.md");
2755        std::fs::write(&file_path, "").unwrap();
2756
2757        let config = LintConfig::default();
2758        // Use validate_project (directory walk) instead of validate_file
2759        let result = validate_project(temp.path(), &config).unwrap();
2760
2761        assert!(
2762            result.diagnostics.iter().any(|d| d.rule == "COP-001"),
2763            "validate_project should find .github/copilot-instructions.md and report COP-001. Found: {:?}",
2764            result.diagnostics.iter().map(|d| &d.rule).collect::<Vec<_>>()
2765        );
2766    }
2767
2768    #[test]
2769    fn test_validate_project_finds_copilot_invalid_fixtures() {
2770        // Test validate_project on the actual fixture directory
2771        let fixtures_dir = get_fixtures_dir();
2772        let copilot_invalid_dir = fixtures_dir.join("copilot-invalid");
2773
2774        let config = LintConfig::default();
2775        let result = validate_project(&copilot_invalid_dir, &config).unwrap();
2776
2777        // Should find COP-001 from empty copilot-instructions.md
2778        assert!(
2779            result.diagnostics.iter().any(|d| d.rule == "COP-001"),
2780            "validate_project should find COP-001 in copilot-invalid fixtures. Found rules: {:?}",
2781            result
2782                .diagnostics
2783                .iter()
2784                .map(|d| &d.rule)
2785                .collect::<Vec<_>>()
2786        );
2787
2788        // Should find COP-002 from bad-frontmatter.instructions.md
2789        assert!(
2790            result.diagnostics.iter().any(|d| d.rule == "COP-002"),
2791            "validate_project should find COP-002 in copilot-invalid fixtures. Found rules: {:?}",
2792            result
2793                .diagnostics
2794                .iter()
2795                .map(|d| &d.rule)
2796                .collect::<Vec<_>>()
2797        );
2798    }
2799
2800    // ===== Cursor Project Rules Validation Integration Tests =====
2801
2802    #[test]
2803    fn test_detect_cursor_rule() {
2804        assert_eq!(
2805            detect_file_type(Path::new(".cursor/rules/typescript.mdc")),
2806            FileType::CursorRule
2807        );
2808        assert_eq!(
2809            detect_file_type(Path::new("project/.cursor/rules/rust.mdc")),
2810            FileType::CursorRule
2811        );
2812    }
2813
2814    #[test]
2815    fn test_detect_cursor_legacy() {
2816        assert_eq!(
2817            detect_file_type(Path::new(".cursorrules")),
2818            FileType::CursorRulesLegacy
2819        );
2820        assert_eq!(
2821            detect_file_type(Path::new("project/.cursorrules")),
2822            FileType::CursorRulesLegacy
2823        );
2824    }
2825
2826    #[test]
2827    fn test_cursor_not_detected_outside_cursor_dir() {
2828        // .mdc files outside .cursor/rules/ should not be detected as CursorRule
2829        assert_ne!(
2830            detect_file_type(Path::new("rules/typescript.mdc")),
2831            FileType::CursorRule
2832        );
2833        assert_ne!(
2834            detect_file_type(Path::new(".cursor/typescript.mdc")),
2835            FileType::CursorRule
2836        );
2837    }
2838
2839    #[test]
2840    fn test_validators_for_cursor() {
2841        let registry = ValidatorRegistry::with_defaults();
2842
2843        let cursor_validators = registry.validators_for(FileType::CursorRule);
2844        assert_eq!(cursor_validators.len(), 1); // cursor only
2845
2846        let legacy_validators = registry.validators_for(FileType::CursorRulesLegacy);
2847        assert_eq!(legacy_validators.len(), 1); // cursor only
2848    }
2849
2850    #[test]
2851    fn test_validate_cursor_fixtures() {
2852        // Use validate_file directly since .cursor is a hidden directory
2853        let fixtures_dir = get_fixtures_dir();
2854        let cursor_dir = fixtures_dir.join("cursor");
2855
2856        let config = LintConfig::default();
2857
2858        // Validate valid .mdc file
2859        let valid_path = cursor_dir.join(".cursor/rules/valid.mdc");
2860        let diagnostics = validate_file(&valid_path, &config).unwrap();
2861        let cur_errors: Vec<_> = diagnostics
2862            .iter()
2863            .filter(|d| d.rule.starts_with("CUR-") && d.level == DiagnosticLevel::Error)
2864            .collect();
2865        assert!(
2866            cur_errors.is_empty(),
2867            "Valid .mdc file should have no CUR errors, got: {:?}",
2868            cur_errors
2869        );
2870
2871        // Validate .mdc file with multiple globs
2872        let multiple_globs_path = cursor_dir.join(".cursor/rules/multiple-globs.mdc");
2873        let diagnostics = validate_file(&multiple_globs_path, &config).unwrap();
2874        let cur_errors: Vec<_> = diagnostics
2875            .iter()
2876            .filter(|d| d.rule.starts_with("CUR-") && d.level == DiagnosticLevel::Error)
2877            .collect();
2878        assert!(
2879            cur_errors.is_empty(),
2880            "Valid .mdc file with multiple globs should have no CUR errors, got: {:?}",
2881            cur_errors
2882        );
2883    }
2884
2885    #[test]
2886    fn test_validate_cursor_invalid_fixtures() {
2887        // Use validate_file directly since .cursor is a hidden directory
2888        let fixtures_dir = get_fixtures_dir();
2889        let cursor_invalid_dir = fixtures_dir.join("cursor-invalid");
2890        let config = LintConfig::default();
2891
2892        // CUR-001: Empty .mdc file
2893        let empty_mdc = cursor_invalid_dir.join(".cursor/rules/empty.mdc");
2894        let diagnostics = validate_file(&empty_mdc, &config).unwrap();
2895        assert!(
2896            diagnostics.iter().any(|d| d.rule == "CUR-001"),
2897            "Expected CUR-001 from empty.mdc fixture"
2898        );
2899
2900        // CUR-002: Missing frontmatter
2901        let no_frontmatter = cursor_invalid_dir.join(".cursor/rules/no-frontmatter.mdc");
2902        let diagnostics = validate_file(&no_frontmatter, &config).unwrap();
2903        assert!(
2904            diagnostics.iter().any(|d| d.rule == "CUR-002"),
2905            "Expected CUR-002 from no-frontmatter.mdc fixture"
2906        );
2907
2908        // CUR-003: Invalid YAML
2909        let bad_yaml = cursor_invalid_dir.join(".cursor/rules/bad-yaml.mdc");
2910        let diagnostics = validate_file(&bad_yaml, &config).unwrap();
2911        assert!(
2912            diagnostics.iter().any(|d| d.rule == "CUR-003"),
2913            "Expected CUR-003 from bad-yaml.mdc fixture"
2914        );
2915
2916        // CUR-004: Invalid glob pattern
2917        let bad_glob = cursor_invalid_dir.join(".cursor/rules/bad-glob.mdc");
2918        let diagnostics = validate_file(&bad_glob, &config).unwrap();
2919        assert!(
2920            diagnostics.iter().any(|d| d.rule == "CUR-004"),
2921            "Expected CUR-004 from bad-glob.mdc fixture"
2922        );
2923
2924        // CUR-005: Unknown keys
2925        let unknown_keys = cursor_invalid_dir.join(".cursor/rules/unknown-keys.mdc");
2926        let diagnostics = validate_file(&unknown_keys, &config).unwrap();
2927        assert!(
2928            diagnostics.iter().any(|d| d.rule == "CUR-005"),
2929            "Expected CUR-005 from unknown-keys.mdc fixture"
2930        );
2931    }
2932
2933    #[test]
2934    fn test_validate_cursor_legacy_fixture() {
2935        let fixtures_dir = get_fixtures_dir();
2936        let legacy_path = fixtures_dir.join("cursor-legacy/.cursorrules");
2937        let config = LintConfig::default();
2938
2939        let diagnostics = validate_file(&legacy_path, &config).unwrap();
2940        assert!(
2941            diagnostics.iter().any(|d| d.rule == "CUR-006"),
2942            "Expected CUR-006 from .cursorrules fixture"
2943        );
2944    }
2945
2946    #[test]
2947    fn test_validate_cursor_file_empty() {
2948        let temp = tempfile::TempDir::new().unwrap();
2949        let cursor_dir = temp.path().join(".cursor").join("rules");
2950        std::fs::create_dir_all(&cursor_dir).unwrap();
2951        let file_path = cursor_dir.join("empty.mdc");
2952        std::fs::write(&file_path, "").unwrap();
2953
2954        let config = LintConfig::default();
2955        let diagnostics = validate_file(&file_path, &config).unwrap();
2956
2957        let cur_001: Vec<_> = diagnostics.iter().filter(|d| d.rule == "CUR-001").collect();
2958        assert_eq!(cur_001.len(), 1, "Expected CUR-001 for empty file");
2959    }
2960
2961    #[test]
2962    fn test_validate_cursor_mdc_missing_frontmatter() {
2963        let temp = tempfile::TempDir::new().unwrap();
2964        let cursor_dir = temp.path().join(".cursor").join("rules");
2965        std::fs::create_dir_all(&cursor_dir).unwrap();
2966        let file_path = cursor_dir.join("test.mdc");
2967        std::fs::write(&file_path, "# Rules without frontmatter").unwrap();
2968
2969        let config = LintConfig::default();
2970        let diagnostics = validate_file(&file_path, &config).unwrap();
2971
2972        let cur_002: Vec<_> = diagnostics.iter().filter(|d| d.rule == "CUR-002").collect();
2973        assert_eq!(cur_002.len(), 1, "Expected CUR-002 for missing frontmatter");
2974    }
2975
2976    #[test]
2977    fn test_validate_cursor_valid_mdc() {
2978        let temp = tempfile::TempDir::new().unwrap();
2979        let cursor_dir = temp.path().join(".cursor").join("rules");
2980        std::fs::create_dir_all(&cursor_dir).unwrap();
2981        let file_path = cursor_dir.join("rust.mdc");
2982        std::fs::write(
2983            &file_path,
2984            r#"---
2985description: Rust rules
2986globs: "**/*.rs"
2987---
2988# Rust Rules
2989
2990Use idiomatic Rust patterns.
2991"#,
2992        )
2993        .unwrap();
2994
2995        let config = LintConfig::default();
2996        let diagnostics = validate_file(&file_path, &config).unwrap();
2997
2998        let cur_errors: Vec<_> = diagnostics
2999            .iter()
3000            .filter(|d| d.rule.starts_with("CUR-") && d.level == DiagnosticLevel::Error)
3001            .collect();
3002        assert!(
3003            cur_errors.is_empty(),
3004            "Valid .mdc file should have no CUR errors"
3005        );
3006    }
3007
3008    #[test]
3009    fn test_validate_project_finds_cursor_hidden_dir() {
3010        // Test validate_project walks .cursor directory
3011        let temp = tempfile::TempDir::new().unwrap();
3012        let cursor_dir = temp.path().join(".cursor").join("rules");
3013        std::fs::create_dir_all(&cursor_dir).unwrap();
3014
3015        // Create an empty .mdc file (should trigger CUR-001)
3016        let file_path = cursor_dir.join("empty.mdc");
3017        std::fs::write(&file_path, "").unwrap();
3018
3019        let config = LintConfig::default();
3020        let result = validate_project(temp.path(), &config).unwrap();
3021
3022        assert!(
3023            result.diagnostics.iter().any(|d| d.rule == "CUR-001"),
3024            "validate_project should find .cursor/rules/empty.mdc and report CUR-001. Found: {:?}",
3025            result
3026                .diagnostics
3027                .iter()
3028                .map(|d| &d.rule)
3029                .collect::<Vec<_>>()
3030        );
3031    }
3032
3033    #[test]
3034    fn test_validate_project_finds_cursor_invalid_fixtures() {
3035        // Test validate_project on the actual fixture directory
3036        let fixtures_dir = get_fixtures_dir();
3037        let cursor_invalid_dir = fixtures_dir.join("cursor-invalid");
3038
3039        let config = LintConfig::default();
3040        let result = validate_project(&cursor_invalid_dir, &config).unwrap();
3041
3042        // Should find CUR-001 from empty.mdc
3043        assert!(
3044            result.diagnostics.iter().any(|d| d.rule == "CUR-001"),
3045            "validate_project should find CUR-001 in cursor-invalid fixtures. Found rules: {:?}",
3046            result
3047                .diagnostics
3048                .iter()
3049                .map(|d| &d.rule)
3050                .collect::<Vec<_>>()
3051        );
3052
3053        // Should find CUR-002 from no-frontmatter.mdc
3054        assert!(
3055            result.diagnostics.iter().any(|d| d.rule == "CUR-002"),
3056            "validate_project should find CUR-002 in cursor-invalid fixtures. Found rules: {:?}",
3057            result
3058                .diagnostics
3059                .iter()
3060                .map(|d| &d.rule)
3061                .collect::<Vec<_>>()
3062        );
3063    }
3064
3065    // ===== PE Rules Dispatch Integration Tests =====
3066
3067    #[test]
3068    fn test_pe_rules_dispatched() {
3069        // Verify PE-* rules are dispatched when validating ClaudeMd file type.
3070        // Per SPEC.md, PE rules apply to CLAUDE.md and AGENTS.md only (not SKILL.md).
3071        let fixtures_dir = get_fixtures_dir().join("prompt");
3072        let config = LintConfig::default();
3073        let registry = ValidatorRegistry::with_defaults();
3074        let temp = tempfile::TempDir::new().unwrap();
3075        let claude_path = temp.path().join("CLAUDE.md");
3076
3077        // Test cases: (fixture_file, expected_rule)
3078        let test_cases = [
3079            ("pe-001-critical-in-middle.md", "PE-001"),
3080            ("pe-002-cot-on-simple.md", "PE-002"),
3081            ("pe-003-weak-language.md", "PE-003"),
3082            ("pe-004-ambiguous.md", "PE-004"),
3083        ];
3084
3085        for (fixture, expected_rule) in test_cases {
3086            let content = std::fs::read_to_string(fixtures_dir.join(fixture))
3087                .unwrap_or_else(|_| panic!("Failed to read fixture: {}", fixture));
3088            std::fs::write(&claude_path, &content).unwrap();
3089            let diagnostics =
3090                validate_file_with_registry(&claude_path, &config, &registry).unwrap();
3091            assert!(
3092                diagnostics.iter().any(|d| d.rule == expected_rule),
3093                "Expected {} from {} content",
3094                expected_rule,
3095                fixture
3096            );
3097        }
3098
3099        // Also verify PE rules dispatch on AGENTS.md file type
3100        let agents_path = temp.path().join("AGENTS.md");
3101        let pe_003_content =
3102            std::fs::read_to_string(fixtures_dir.join("pe-003-weak-language.md")).unwrap();
3103        std::fs::write(&agents_path, &pe_003_content).unwrap();
3104        let diagnostics = validate_file_with_registry(&agents_path, &config, &registry).unwrap();
3105        assert!(
3106            diagnostics.iter().any(|d| d.rule == "PE-003"),
3107            "Expected PE-003 from AGENTS.md with weak language content"
3108        );
3109    }
3110
3111    #[test]
3112    fn test_exclude_patterns_with_absolute_path() {
3113        let temp = tempfile::TempDir::new().unwrap();
3114
3115        // Create a structure that should be partially excluded
3116        let target_dir = temp.path().join("target");
3117        std::fs::create_dir_all(&target_dir).unwrap();
3118        std::fs::write(
3119            target_dir.join("SKILL.md"),
3120            "---\nname: build-artifact\ndescription: Should be excluded\n---\nBody",
3121        )
3122        .unwrap();
3123
3124        // Create a file that should NOT be excluded
3125        std::fs::write(
3126            temp.path().join("SKILL.md"),
3127            "---\nname: valid-skill\ndescription: Should be validated\n---\nBody",
3128        )
3129        .unwrap();
3130
3131        let mut config = LintConfig::default();
3132        config.exclude = vec!["target/**".to_string()];
3133
3134        // Use absolute path (canonicalize returns absolute path)
3135        let abs_path = std::fs::canonicalize(temp.path()).unwrap();
3136        let result = validate_project(&abs_path, &config).unwrap();
3137
3138        // Should NOT have diagnostics from target/SKILL.md (excluded)
3139        let target_diags: Vec<_> = result
3140            .diagnostics
3141            .iter()
3142            .filter(|d| d.file.to_string_lossy().contains("target"))
3143            .collect();
3144        assert!(
3145            target_diags.is_empty(),
3146            "Files in target/ should be excluded when using absolute path, got: {:?}",
3147            target_diags
3148        );
3149    }
3150
3151    #[test]
3152    fn test_exclude_patterns_with_relative_path() {
3153        let temp = tempfile::TempDir::new().unwrap();
3154
3155        // Create a structure that should be partially excluded
3156        let node_modules = temp.path().join("node_modules");
3157        std::fs::create_dir_all(&node_modules).unwrap();
3158        std::fs::write(
3159            node_modules.join("SKILL.md"),
3160            "---\nname: npm-artifact\ndescription: Should be excluded\n---\nBody",
3161        )
3162        .unwrap();
3163
3164        // Create a file that should NOT be excluded
3165        std::fs::write(
3166            temp.path().join("AGENTS.md"),
3167            "# Project\n\nThis should be validated.",
3168        )
3169        .unwrap();
3170
3171        let mut config = LintConfig::default();
3172        config.exclude = vec!["node_modules/**".to_string()];
3173
3174        // Use temp.path() directly to validate exclude pattern handling
3175        let result = validate_project(temp.path(), &config).unwrap();
3176
3177        // Should NOT have diagnostics from node_modules/
3178        let nm_diags: Vec<_> = result
3179            .diagnostics
3180            .iter()
3181            .filter(|d| d.file.to_string_lossy().contains("node_modules"))
3182            .collect();
3183        assert!(
3184            nm_diags.is_empty(),
3185            "Files in node_modules/ should be excluded, got: {:?}",
3186            nm_diags
3187        );
3188    }
3189
3190    #[test]
3191    fn test_exclude_patterns_nested_directories() {
3192        let temp = tempfile::TempDir::new().unwrap();
3193
3194        // Create deeply nested target directory
3195        let deep_target = temp.path().join("subproject").join("target").join("debug");
3196        std::fs::create_dir_all(&deep_target).unwrap();
3197        std::fs::write(
3198            deep_target.join("SKILL.md"),
3199            "---\nname: deep-artifact\ndescription: Deep exclude test\n---\nBody",
3200        )
3201        .unwrap();
3202
3203        let mut config = LintConfig::default();
3204        // Use ** prefix to match at any level
3205        config.exclude = vec!["**/target/**".to_string()];
3206
3207        let abs_path = std::fs::canonicalize(temp.path()).unwrap();
3208        let result = validate_project(&abs_path, &config).unwrap();
3209
3210        let target_diags: Vec<_> = result
3211            .diagnostics
3212            .iter()
3213            .filter(|d| d.file.to_string_lossy().contains("target"))
3214            .collect();
3215        assert!(
3216            target_diags.is_empty(),
3217            "Deeply nested target/ files should be excluded, got: {:?}",
3218            target_diags
3219        );
3220    }
3221
3222    #[test]
3223    fn test_should_prune_dir_with_globbed_patterns() {
3224        let patterns =
3225            compile_exclude_patterns(&vec!["target/**".to_string(), "**/target/**".to_string()]);
3226        assert!(
3227            should_prune_dir("target", &patterns),
3228            "Expected target/** to prune target directory"
3229        );
3230        assert!(
3231            should_prune_dir("sub/target", &patterns),
3232            "Expected **/target/** to prune nested target directory"
3233        );
3234    }
3235
3236    #[test]
3237    fn test_should_prune_dir_for_bare_pattern() {
3238        let patterns = compile_exclude_patterns(&vec!["target".to_string()]);
3239        assert!(
3240            should_prune_dir("target", &patterns),
3241            "Bare pattern should prune directory"
3242        );
3243        assert!(
3244            !should_prune_dir("sub/target", &patterns),
3245            "Bare pattern should not prune nested directories"
3246        );
3247    }
3248
3249    #[test]
3250    fn test_should_prune_dir_for_trailing_slash_pattern() {
3251        let patterns = compile_exclude_patterns(&vec!["target/".to_string()]);
3252        assert!(
3253            should_prune_dir("target", &patterns),
3254            "Trailing slash pattern should prune directory"
3255        );
3256    }
3257
3258    #[test]
3259    fn test_should_not_prune_root_dir() {
3260        let patterns = compile_exclude_patterns(&vec!["target/**".to_string()]);
3261        assert!(
3262            !should_prune_dir("", &patterns),
3263            "Root directory should never be pruned"
3264        );
3265    }
3266
3267    #[test]
3268    fn test_should_not_prune_dir_for_single_level_glob() {
3269        let patterns = compile_exclude_patterns(&vec!["target/*".to_string()]);
3270        assert!(
3271            !should_prune_dir("target", &patterns),
3272            "Single-level glob should not prune directory"
3273        );
3274    }
3275
3276    #[test]
3277    fn test_dir_only_pattern_does_not_exclude_file_named_dir() {
3278        let patterns = compile_exclude_patterns(&vec!["target/".to_string()]);
3279        assert!(
3280            !is_excluded_file("target", &patterns),
3281            "Directory-only pattern should not exclude a file named target"
3282        );
3283    }
3284
3285    #[test]
3286    fn test_dir_only_pattern_excludes_files_under_dir() {
3287        let patterns = compile_exclude_patterns(&vec!["target/".to_string()]);
3288        assert!(
3289            is_excluded_file("target/file.txt", &patterns),
3290            "Directory-only pattern should exclude files under target/"
3291        );
3292    }
3293
3294    // ===== ValidationResult files_checked Tests =====
3295
3296    #[test]
3297    fn test_files_checked_with_no_diagnostics() {
3298        // Test that files_checked is accurate even when there are no diagnostics
3299        let temp = tempfile::TempDir::new().unwrap();
3300
3301        // Create valid skill files that produce no diagnostics
3302        let skill_dir = temp.path().join("skills").join("code-review");
3303        std::fs::create_dir_all(&skill_dir).unwrap();
3304        std::fs::write(
3305            skill_dir.join("SKILL.md"),
3306            "---\nname: code-review\ndescription: Use when reviewing code\n---\nBody",
3307        )
3308        .unwrap();
3309
3310        // Create another valid skill
3311        let skill_dir2 = temp.path().join("skills").join("test-runner");
3312        std::fs::create_dir_all(&skill_dir2).unwrap();
3313        std::fs::write(
3314            skill_dir2.join("SKILL.md"),
3315            "---\nname: test-runner\ndescription: Use when running tests\n---\nBody",
3316        )
3317        .unwrap();
3318
3319        // Disable VER-001 since we're testing for zero diagnostics on valid files
3320        let mut config = LintConfig::default();
3321        config.rules.disabled_rules = vec!["VER-001".to_string()];
3322        let result = validate_project(temp.path(), &config).unwrap();
3323
3324        // Should have counted exactly the two valid skill files
3325        assert_eq!(
3326            result.files_checked, 2,
3327            "files_checked should count exactly the validated skill files, got {}",
3328            result.files_checked
3329        );
3330        assert!(
3331            result.diagnostics.is_empty(),
3332            "Valid skill files should have no diagnostics"
3333        );
3334    }
3335
3336    #[test]
3337    fn test_files_checked_excludes_unknown_file_types() {
3338        // Test that files_checked only counts recognized file types
3339        let temp = tempfile::TempDir::new().unwrap();
3340
3341        // Create files of unknown type
3342        std::fs::write(temp.path().join("main.rs"), "fn main() {}").unwrap();
3343        std::fs::write(temp.path().join("package.json"), "{}").unwrap();
3344
3345        // Create one recognized file
3346        std::fs::write(
3347            temp.path().join("SKILL.md"),
3348            "---\nname: code-review\ndescription: Use when reviewing code\n---\nBody",
3349        )
3350        .unwrap();
3351
3352        let config = LintConfig::default();
3353        let result = validate_project(temp.path(), &config).unwrap();
3354
3355        // Should only count the SKILL.md file, not .rs or package.json
3356        assert_eq!(
3357            result.files_checked, 1,
3358            "files_checked should only count recognized file types"
3359        );
3360    }
3361}