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