1#![allow(clippy::collapsible_if)]
4
5#![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#[derive(Debug, Clone)]
59pub struct ValidationResult {
60 pub diagnostics: Vec<Diagnostic>,
62 pub files_checked: usize,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub enum FileType {
69 Skill,
71 ClaudeMd,
73 Agent,
75 Hooks,
77 Plugin,
79 Mcp,
81 Copilot,
83 CopilotScoped,
85 CursorRule,
87 CursorRulesLegacy,
89 GenericMarkdown,
91 Unknown,
93}
94
95pub type ValidatorFactory = fn() -> Box<dyn Validator>;
97
98pub struct ValidatorRegistry {
108 validators: HashMap<FileType, Vec<ValidatorFactory>>,
109}
110
111impl ValidatorRegistry {
112 pub fn new() -> Self {
114 Self {
115 validators: HashMap::new(),
116 }
117 }
118
119 pub fn with_defaults() -> Self {
121 let mut registry = Self::new();
122 registry.register_defaults();
123 registry
124 }
125
126 pub fn register(&mut self, file_type: FileType, factory: ValidatorFactory) {
128 self.validators.entry(file_type).or_default().push(factory);
129 }
130
131 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
236fn is_documentation_directory(path: &Path) -> bool {
241 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
263pub 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 "plugin.json" => FileType::Plugin,
283 "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 "copilot-instructions.md" if parent == Some(".github") => FileType::Copilot,
289 name if name.ends_with(".instructions.md")
291 && parent == Some("instructions")
292 && grandparent == Some(".github") =>
293 {
294 FileType::CopilotScoped
295 }
296 name if name.ends_with(".mdc")
298 && parent == Some("rules")
299 && grandparent == Some(".cursor") =>
300 {
301 FileType::CursorRule
302 }
303 ".cursorrules" | ".cursorrules.md" => FileType::CursorRulesLegacy,
305 name if name.ends_with(".md") => {
306 if parent == Some("agents") || grandparent == Some("agents") {
309 FileType::Agent
310 } else {
311 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 || 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 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
361pub fn validate_file(path: &Path, config: &LintConfig) -> LintResult<Vec<Diagnostic>> {
363 let registry = ValidatorRegistry::with_defaults();
364 validate_file_with_registry(path, config, ®istry)
365}
366
367pub 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
391pub fn validate_project(path: &Path, config: &LintConfig) -> LintResult<ValidationResult> {
393 let registry = ValidatorRegistry::with_defaults();
394 validate_project_with_registry(path, config, ®istry)
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 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
455pub 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 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 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 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 let max_files = config.max_files_to_validate;
490
491 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 if limit_exceeded.load(Ordering::SeqCst) {
526 return Vec::new();
527 }
528
529 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 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 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 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 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 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 if config.is_rule_enabled("AGM-006") {
583 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 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 let mut instruction_files = instruction_file_paths.lock().unwrap().clone();
638 instruction_files.sort();
639
640 if instruction_files.len() > 1 {
641 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(
944 detect_file_type(Path::new("my-plugin.claude-plugin/plugin.json")),
945 FileType::Plugin
946 );
947 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 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 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 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 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 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(), ®istry).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 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 let temp = tempfile::TempDir::new().unwrap();
1267
1268 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 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 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 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 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 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 let temp = tempfile::TempDir::new().unwrap();
1363
1364 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 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 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 let temp = tempfile::TempDir::new().unwrap();
1404
1405 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 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 let temp = tempfile::TempDir::new().unwrap();
1434
1435 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 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 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 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 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 #[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 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 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 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 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 assert!(
1558 mcp_diagnostics.iter().any(|d| d.rule == "MCP-002"),
1559 "Should report MCP-002 for empty name"
1560 );
1561 }
1562
1563 #[test]
1566 fn test_validate_agents_md_with_claude_features() {
1567 let temp = tempfile::TempDir::new().unwrap();
1568
1569 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 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 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 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 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 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 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 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 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 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 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 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 #[test]
1750 fn test_agm_006_nested_agents_md() {
1751 let temp = tempfile::TempDir::new().unwrap();
1752
1753 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 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 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 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 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 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 #[test]
1902 fn test_xp_004_conflicting_package_managers() {
1903 let temp = tempfile::TempDir::new().unwrap();
1904
1905 std::fs::write(
1907 temp.path().join("CLAUDE.md"),
1908 "# Project\n\nUse `npm install` for dependencies.",
1909 )
1910 .unwrap();
1911
1912 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 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 #[test]
1969 fn test_xp_005_conflicting_tool_constraints() {
1970 let temp = tempfile::TempDir::new().unwrap();
1971
1972 std::fs::write(
1974 temp.path().join("CLAUDE.md"),
1975 "# Project\n\nallowed-tools: Read Write Bash",
1976 )
1977 .unwrap();
1978
1979 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 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 #[test]
2035 fn test_xp_006_no_precedence_documentation() {
2036 let temp = tempfile::TempDir::new().unwrap();
2037
2038 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 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 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 #[test]
2124 fn test_xp_004_three_files_conflicting_managers() {
2125 let temp = tempfile::TempDir::new().unwrap();
2126
2127 std::fs::write(
2129 temp.path().join("CLAUDE.md"),
2130 "# Project\n\nUse `npm install` for dependencies.",
2131 )
2132 .unwrap();
2133
2134 std::fs::write(
2136 temp.path().join("AGENTS.md"),
2137 "# Project\n\nUse `pnpm install` for dependencies.",
2138 )
2139 .unwrap();
2140
2141 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 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 std::fs::write(
2173 temp.path().join("CLAUDE.md"),
2174 "# Project\n\nUse `npm install` for dependencies.",
2175 )
2176 .unwrap();
2177
2178 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 std::fs::write(
2203 temp.path().join("CLAUDE.md"),
2204 "# Project\n\nallowed-tools: Read Write Bash",
2205 )
2206 .unwrap();
2207
2208 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 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 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 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 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 std::fs::write(
2290 temp.path().join("CLAUDE.md"),
2291 "# Project\n\nallowed-tools: Read Write BASH",
2292 )
2293 .unwrap();
2294
2295 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 std::fs::write(
2322 temp.path().join("CLAUDE.md"),
2323 "# Project\n\nallowed-tools: Read Write Bash",
2324 )
2325 .unwrap();
2326
2327 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 #[test]
2351 fn test_ver_001_warns_when_no_versions_pinned() {
2352 let temp = tempfile::TempDir::new().unwrap();
2353
2354 std::fs::write(temp.path().join("CLAUDE.md"), "# Project\n\nInstructions.").unwrap();
2356
2357 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 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 #[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 fn get_fixtures_dir() -> PathBuf {
2542 workspace_root().join("tests").join("fixtures")
2543 }
2544
2545 #[test]
2546 fn test_validate_fixtures_directory() {
2547 let fixtures_dir = get_fixtures_dir();
2550
2551 let config = LintConfig::default();
2552 let result = validate_project(&fixtures_dir, &config).unwrap();
2553
2554 let skill_diagnostics: Vec<_> = result
2556 .diagnostics
2557 .iter()
2558 .filter(|d| d.rule.starts_with("AS-"))
2559 .collect();
2560
2561 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 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 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 let mcp_diagnostics: Vec<_> = result
2588 .diagnostics
2589 .iter()
2590 .filter(|d| d.rule.starts_with("MCP-"))
2591 .collect();
2592
2593 assert!(
2595 !mcp_diagnostics.is_empty(),
2596 "Expected MCP diagnostics from tests/fixtures/mcp/*.mcp.json files"
2597 );
2598
2599 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 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 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 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 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 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 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 let fixtures_dir = get_fixtures_dir();
2736
2737 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 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 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 #[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 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); let scoped_validators = registry.validators_for(FileType::CopilotScoped);
2830 assert_eq!(scoped_validators.len(), 2); }
2832
2833 #[test]
2834 fn test_validate_copilot_fixtures() {
2835 let fixtures_dir = get_fixtures_dir();
2838 let copilot_dir = fixtures_dir.join("copilot");
2839
2840 let config = LintConfig::default();
2841
2842 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 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 let fixtures_dir = get_fixtures_dir();
2873 let copilot_invalid_dir = fixtures_dir.join("copilot-invalid");
2874 let config = LintConfig::default();
2875
2876 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 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 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 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 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 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 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 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 let file_path = github_dir.join("copilot-instructions.md");
2984 std::fs::write(&file_path, "").unwrap();
2985
2986 let config = LintConfig::default();
2987 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 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 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 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 #[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 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 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); let legacy_validators = registry.validators_for(FileType::CursorRulesLegacy);
3089 assert_eq!(legacy_validators.len(), 3); }
3091
3092 #[test]
3093 fn test_validate_cursor_fixtures() {
3094 let fixtures_dir = get_fixtures_dir();
3096 let cursor_dir = fixtures_dir.join("cursor");
3097
3098 let config = LintConfig::default();
3099
3100 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 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 let fixtures_dir = get_fixtures_dir();
3131 let cursor_invalid_dir = fixtures_dir.join("cursor-invalid");
3132 let config = LintConfig::default();
3133
3134 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 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 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 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 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 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 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 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 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 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 #[test]
3310 fn test_pe_rules_dispatched() {
3311 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 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, ®istry).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 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, ®istry).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 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 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 let abs_path = std::fs::canonicalize(temp.path()).unwrap();
3378 let result = validate_project(&abs_path, &config).unwrap();
3379
3380 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 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 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 let result = validate_project(temp.path(), &config).unwrap();
3418
3419 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 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 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 #[test]
3549 fn test_files_checked_with_no_diagnostics() {
3550 let temp = tempfile::TempDir::new().unwrap();
3552
3553 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 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 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 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 let temp = tempfile::TempDir::new().unwrap();
3592
3593 std::fs::write(temp.path().join("main.rs"), "fn main() {}").unwrap();
3595 std::fs::write(temp.path().join("package.json"), "{}").unwrap();
3596
3597 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 assert_eq!(
3609 result.files_checked, 1,
3610 "files_checked should only count recognized file types"
3611 );
3612 }
3613
3614 #[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(®istry);
3626 thread::spawn(move || {
3627 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 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(®istry);
3670 let path = temp_path.join(format!("skill-{}", i)).join("SKILL.md");
3671 thread::spawn(move || validate_file_with_registry(&path, &config, ®istry))
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 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 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 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, ®istry);
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 #[test]
3785 fn test_file_count_limit_enforced() {
3786 let temp = tempfile::TempDir::new().unwrap();
3787
3788 for i in 0..15 {
3790 std::fs::write(temp.path().join(format!("file{}.md", i)), "# Content").unwrap();
3791 }
3792
3793 let mut config = LintConfig::default();
3795 config.max_files_to_validate = Some(10);
3796
3797 let result = validate_project(temp.path(), &config);
3798
3799 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 for i in 0..5 {
3816 std::fs::write(temp.path().join(format!("file{}.md", i)), "# Content").unwrap();
3817 }
3818
3819 let mut config = LintConfig::default();
3821 config.max_files_to_validate = Some(10);
3822
3823 let result = validate_project(temp.path(), &config);
3824
3825 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 for i in 0..15 {
3839 std::fs::write(temp.path().join(format!("file{}.md", i)), "# Content").unwrap();
3840 }
3841
3842 let mut config = LintConfig::default();
3844 config.max_files_to_validate = None;
3845
3846 let result = validate_project(temp.path(), &config);
3847
3848 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 let temp = tempfile::TempDir::new().unwrap();
3870
3871 for i in 0..20 {
3873 std::fs::write(temp.path().join(format!("file{}.md", i)), "# Content").unwrap();
3874 }
3875
3876 let mut config = LintConfig::default();
3878 config.max_files_to_validate = Some(25);
3879
3880 let result = validate_project(temp.path(), &config);
3881
3882 assert!(
3884 result.is_ok(),
3885 "Concurrent validation should handle file counting correctly"
3886 );
3887
3888 let validation_result = result.unwrap();
3890 assert_eq!(
3891 validation_result.files_checked, 20,
3892 "Should count all validated files"
3893 );
3894 }
3895
3896 #[test]
3899 #[ignore] fn test_validation_scales_to_10k_files() {
3901 use std::time::Instant;
3904
3905 let temp = tempfile::TempDir::new().unwrap();
3906
3907 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 assert!(
3923 result.is_ok(),
3924 "Should handle 10,000 files: {:?}",
3925 result.err()
3926 );
3927
3928 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 static LOCALE_MUTEX: Mutex<()> = Mutex::new(());
3957
3958 #[test]
3960 fn test_english_translations_load() {
3961 let _lock = LOCALE_MUTEX.lock().unwrap();
3962 rust_i18n::set_locale("en");
3963
3964 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 #[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 #[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 #[test]
4023 fn test_fallback_to_english() {
4024 let _lock = LOCALE_MUTEX.lock().unwrap();
4025 rust_i18n::set_locale("fr"); 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 #[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 #[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 #[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"); 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 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 #[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}