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