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