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