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