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