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