Skip to main content

agnix_core/
lib.rs

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