1use once_cell::sync::Lazy;
8use regex::Regex;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone)]
19pub struct AuditConfig {
20 pub root_markers: Vec<&'static str>,
23
24 pub include_claude_md: bool,
27
28 pub source_extensions: Vec<&'static str>,
31
32 pub source_dirs: Vec<&'static str>,
35
36 pub skip_dirs: Vec<&'static str>,
38}
39
40impl AuditConfig {
41 pub fn agent_doc() -> Self {
44 Self {
45 root_markers: vec![
46 "Cargo.toml",
47 "package.json",
48 "pyproject.toml",
49 "setup.py",
50 "go.mod",
51 "Gemfile",
52 "pom.xml",
53 "build.gradle",
54 "CMakeLists.txt",
55 "Makefile",
56 "flake.nix",
57 "deno.json",
58 "composer.json",
59 ],
60 include_claude_md: true,
61 source_extensions: vec![
62 "rs", "ts", "tsx", "js", "jsx", "py", "go", "rb", "java", "kt", "c", "cpp", "h",
63 "hpp", "cs", "swift", "zig", "hs", "ml", "ex", "exs", "clj", "scala", "lua", "php",
64 "sh", "bash", "zsh",
65 ],
66 source_dirs: vec!["src", "lib", "app", "pkg", "cmd", "internal"],
67 skip_dirs: vec![
68 "node_modules",
69 "target",
70 "build",
71 "dist",
72 ".git",
73 "__pycache__",
74 ".venv",
75 "vendor",
76 ".next",
77 "out",
78 ],
79 }
80 }
81
82 pub fn corky() -> Self {
85 Self {
86 root_markers: vec!["Cargo.toml"],
87 include_claude_md: false,
88 source_extensions: vec!["rs"],
89 source_dirs: vec!["src"],
90 skip_dirs: vec!["target", ".git"],
91 }
92 }
93}
94
95pub struct Issue {
97 pub file: String,
98 pub line: usize,
99 pub end_line: usize,
100 pub message: String,
101 pub warning: bool,
102}
103
104pub fn is_agent_file(rel: &str, config: &AuditConfig) -> bool {
106 let name = Path::new(rel)
107 .file_name()
108 .and_then(|n| n.to_str())
109 .unwrap_or("");
110 if name == "AGENTS.md" || name == "SKILL.md" {
111 return true;
112 }
113 if config.include_claude_md && name == "CLAUDE.md" {
114 return true;
115 }
116 false
117}
118
119pub const LINE_BUDGET: usize = 1000;
125
126static MACHINE_LOCAL_RE: Lazy<Regex> =
127 Lazy::new(|| Regex::new(r"(?m)(?:~/|/home/\w+|/Users/\w+|/root/|/tmp/|C:\\Users\\)").unwrap());
128
129pub fn check_context_invariant(rel: &str, content: &str, config: &AuditConfig) -> Vec<Issue> {
135 if !is_agent_file(rel, config) {
136 return vec![];
137 }
138
139 let mut issues = Vec::new();
140 let mut in_code_fence = false;
141
142 for (i, line) in content.lines().enumerate() {
143 let trimmed = line.trim();
144
145 if trimmed.starts_with("```") {
147 in_code_fence = !in_code_fence;
148 continue;
149 }
150 if in_code_fence {
151 continue;
152 }
153
154 if let Some(m) = MACHINE_LOCAL_RE.find(line) {
156 issues.push(Issue {
157 file: rel.to_string(),
158 line: i + 1,
159 end_line: 0,
160 message: format!(
161 "Machine-local path \"{}\" \u{2014} use repo-relative path instead",
162 m.as_str()
163 ),
164 warning: true,
165 });
166 }
167 }
168
169 issues
170}
171
172pub fn check_staleness(files: &[PathBuf], root: &Path, config: &AuditConfig) -> Vec<Issue> {
174 let mut newest_mtime = std::time::SystemTime::UNIX_EPOCH;
175 let mut newest_src = PathBuf::new();
176
177 fn scan_sources(
178 dir: &Path,
179 extensions: &[&str],
180 skip_dirs: &[&str],
181 newest: &mut std::time::SystemTime,
182 newest_path: &mut PathBuf,
183 ) {
184 if let Ok(entries) = std::fs::read_dir(dir) {
185 for entry in entries.flatten() {
186 let path = entry.path();
187 if path.is_dir() {
188 if let Some(name) = path.file_name().and_then(|n| n.to_str())
189 && skip_dirs.contains(&name)
190 {
191 continue;
192 }
193 scan_sources(&path, extensions, skip_dirs, newest, newest_path);
194 } else if let Some(ext) = path.extension().and_then(|e| e.to_str())
195 && extensions.contains(&ext)
196 && let Ok(meta) = path.metadata()
197 && let Ok(mtime) = meta.modified()
198 && mtime > *newest
199 {
200 *newest = mtime;
201 *newest_path = path;
202 }
203 }
204 }
205 }
206
207 let mut found_any = false;
208 for source_dir in &config.source_dirs {
209 let dir = root.join(source_dir);
210 if dir.exists() {
211 found_any = true;
212 scan_sources(
213 &dir,
214 &config.source_extensions,
215 &config.skip_dirs,
216 &mut newest_mtime,
217 &mut newest_src,
218 );
219 }
220 }
221
222 if !found_any {
223 return vec![];
224 }
225
226 let mut issues = Vec::new();
227 for doc in files {
228 if let Ok(meta) = doc.metadata()
229 && let Ok(doc_mtime) = meta.modified()
230 && doc_mtime < newest_mtime
231 {
232 let rel = doc
233 .strip_prefix(root)
234 .unwrap_or(doc)
235 .to_string_lossy()
236 .to_string();
237 let src_rel = newest_src
238 .strip_prefix(root)
239 .unwrap_or(&newest_src)
240 .to_string_lossy()
241 .to_string();
242 issues.push(Issue {
243 file: rel,
244 line: 0,
245 end_line: 0,
246 message: format!("Older than {} \u{2014} may be stale", src_rel),
247 warning: false,
248 });
249 }
250 }
251 issues
252}
253
254pub fn check_line_budget(
259 files: &[PathBuf],
260 root: &Path,
261 config: &AuditConfig,
262) -> (Vec<Issue>, Vec<(String, usize)>, usize) {
263 let mut counts = Vec::new();
264 let mut total = 0;
265 for f in files {
266 if let Ok(content) = std::fs::read_to_string(f) {
267 let n = content.lines().count();
268 let rel = f
269 .strip_prefix(root)
270 .unwrap_or(f)
271 .to_string_lossy()
272 .to_string();
273 if is_agent_file(&rel, config) {
274 total += n;
275 }
276 counts.push((rel, n));
277 }
278 }
279 let mut issues = Vec::new();
280 if total > LINE_BUDGET {
281 issues.push(Issue {
282 file: "(all)".to_string(),
283 line: 0,
284 end_line: 0,
285 message: format!("Over line budget: {} lines (max {})", total, LINE_BUDGET),
286 warning: false,
287 });
288 }
289 (issues, counts, total)
290}
291
292pub fn find_root(config: &AuditConfig) -> PathBuf {
303 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
304
305 let mut dir = cwd.as_path();
307 loop {
308 for marker in &config.root_markers {
309 if dir.join(marker).exists() {
310 return dir.to_path_buf();
311 }
312 }
313 match dir.parent() {
314 Some(p) if p != dir => dir = p,
315 _ => break,
316 }
317 }
318
319 dir = cwd.as_path();
321 loop {
322 if dir.join(".git").exists() {
323 return dir.to_path_buf();
324 }
325 match dir.parent() {
326 Some(p) if p != dir => dir = p,
327 _ => break,
328 }
329 }
330
331 eprintln!("Warning: no project root marker found, using current directory");
333 cwd
334}
335
336pub fn find_instruction_files(root: &Path, config: &AuditConfig) -> Vec<PathBuf> {
343 fn should_skip_dir(path: &Path, skip_dirs: &[&str]) -> bool {
344 path.file_name()
345 .and_then(|n| n.to_str())
346 .is_some_and(|name| skip_dirs.contains(&name))
347 }
348
349 fn collect_named_files_recursive(
350 base_dir: &Path,
351 file_name: &str,
352 skip_dirs: &[&str],
353 found: &mut std::collections::HashSet<PathBuf>,
354 ) {
355 if !base_dir.exists() {
356 return;
357 }
358
359 let mut stack = vec![base_dir.to_path_buf()];
360 while let Some(dir) = stack.pop() {
361 let Ok(entries) = std::fs::read_dir(&dir) else {
362 continue;
363 };
364 for entry in entries.flatten() {
365 let path = entry.path();
366 if path.is_dir() {
367 if should_skip_dir(&path, skip_dirs) {
368 continue;
369 }
370 stack.push(path);
371 } else if path.file_name().and_then(|n| n.to_str()) == Some(file_name) {
372 found.insert(path);
373 }
374 }
375 }
376 }
377
378 fn collect_top_level_markdown_files(
379 dir: &Path,
380 found: &mut std::collections::HashSet<PathBuf>,
381 ) {
382 let Ok(entries) = std::fs::read_dir(dir) else {
383 return;
384 };
385 for entry in entries.flatten() {
386 let path = entry.path();
387 if path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("md") {
388 found.insert(path);
389 }
390 }
391 }
392
393 fn collect_runbook_files_recursive(
394 base_dir: &Path,
395 skip_dirs: &[&str],
396 found: &mut std::collections::HashSet<PathBuf>,
397 ) {
398 if !base_dir.exists() {
399 return;
400 }
401
402 let mut stack = vec![base_dir.to_path_buf()];
403 while let Some(dir) = stack.pop() {
404 let Ok(entries) = std::fs::read_dir(&dir) else {
405 continue;
406 };
407 for entry in entries.flatten() {
408 let path = entry.path();
409 if !path.is_dir() {
410 continue;
411 }
412 if should_skip_dir(&path, skip_dirs) {
413 continue;
414 }
415 if path.file_name().and_then(|n| n.to_str()) == Some("runbooks") {
416 collect_top_level_markdown_files(&path, found);
417 }
418 stack.push(path);
419 }
420 }
421 }
422
423 let mut root_patterns = vec!["AGENTS.md", "README.md", "SPEC.md"];
424 if config.include_claude_md {
425 root_patterns.push("CLAUDE.md");
426 }
427
428 let mut found = std::collections::HashSet::new();
429
430 for pattern in &root_patterns {
431 let path = root.join(pattern);
432 if path.exists() {
433 found.insert(path);
434 }
435 }
436
437 collect_named_files_recursive(
438 &root.join(".claude"),
439 "SKILL.md",
440 &config.skip_dirs,
441 &mut found,
442 );
443 collect_named_files_recursive(
444 &root.join(".agents"),
445 "SKILL.md",
446 &config.skip_dirs,
447 &mut found,
448 );
449 collect_named_files_recursive(
450 &root.join(".agents"),
451 "AGENTS.md",
452 &config.skip_dirs,
453 &mut found,
454 );
455 collect_named_files_recursive(
456 &root.join("src"),
457 "AGENTS.md",
458 &config.skip_dirs,
459 &mut found,
460 );
461 collect_top_level_markdown_files(&root.join(".agent/runbooks"), &mut found);
462 collect_runbook_files_recursive(&root.join(".claude/skills"), &config.skip_dirs, &mut found);
463
464 if config.include_claude_md {
465 collect_named_files_recursive(
466 &root.join(".claude"),
467 "CLAUDE.md",
468 &config.skip_dirs,
469 &mut found,
470 );
471 collect_named_files_recursive(
472 &root.join("src"),
473 "CLAUDE.md",
474 &config.skip_dirs,
475 &mut found,
476 );
477 }
478
479 let mut result: Vec<PathBuf> = found.into_iter().collect();
480 result.sort();
481 result
482}
483
484#[cfg(test)]
489mod tests {
490 use super::*;
491 use std::fs;
492 use tempfile::TempDir;
493
494 #[test]
497 fn is_agent_file_with_claude() {
498 let config = AuditConfig::agent_doc();
499 assert!(is_agent_file("AGENTS.md", &config));
500 assert!(is_agent_file("SKILL.md", &config));
501 assert!(is_agent_file("CLAUDE.md", &config));
502 assert!(is_agent_file("src/AGENTS.md", &config));
503 assert!(is_agent_file(".claude/skills/email/SKILL.md", &config));
504 assert!(is_agent_file("nested/path/CLAUDE.md", &config));
505 }
506
507 #[test]
508 fn is_agent_file_without_claude() {
509 let config = AuditConfig::corky();
510 assert!(is_agent_file("AGENTS.md", &config));
511 assert!(is_agent_file("SKILL.md", &config));
512 assert!(!is_agent_file("CLAUDE.md", &config));
513 }
514
515 #[test]
516 fn is_agent_file_rejects() {
517 let config = AuditConfig::agent_doc();
518 assert!(!is_agent_file("README.md", &config));
519 assert!(!is_agent_file("agents.md", &config));
520 assert!(!is_agent_file("CHANGELOG.md", &config));
521 assert!(!is_agent_file("src/main.rs", &config));
522 }
523
524 #[test]
527 fn context_invariant_flags_home_tilde() {
528 let config = AuditConfig::agent_doc();
529 let content = "# Doc\n\nSee ~/some/path for config.\n";
530 let issues = check_context_invariant("CLAUDE.md", content, &config);
531 assert_eq!(issues.len(), 1);
532 assert!(issues[0].message.contains("Machine-local path"));
533 assert!(issues[0].warning);
534 }
535
536 #[test]
537 fn context_invariant_flags_home_absolute() {
538 let config = AuditConfig::agent_doc();
539 let content = "# Doc\n\nConfig at /home/brian/.config/foo.\n";
540 let issues = check_context_invariant("AGENTS.md", content, &config);
541 assert_eq!(issues.len(), 1);
542 assert!(issues[0].message.contains("/home/brian"));
543 }
544
545 #[test]
546 fn context_invariant_flags_macos_users() {
547 let config = AuditConfig::agent_doc();
548 let content = "# Doc\n\nSee /Users/alice/project.\n";
549 let issues = check_context_invariant("CLAUDE.md", content, &config);
550 assert_eq!(issues.len(), 1);
551 assert!(issues[0].message.contains("/Users/alice"));
552 }
553
554 #[test]
555 fn context_invariant_skips_code_fences() {
556 let config = AuditConfig::agent_doc();
557 let content = "# Doc\n\n```bash\nexistence --ontology ~/path\n```\n";
558 let issues = check_context_invariant("CLAUDE.md", content, &config);
559 assert!(issues.is_empty());
560 }
561
562 #[test]
563 fn context_invariant_clean_file() {
564 let config = AuditConfig::agent_doc();
565 let content = "# Doc\n\nUse `src/main.rs` for the entry point.\n";
566 let issues = check_context_invariant("AGENTS.md", content, &config);
567 assert!(issues.is_empty());
568 }
569
570 #[test]
571 fn context_invariant_skips_non_agent_files() {
572 let config = AuditConfig::agent_doc();
573 let content = "# Doc\n\nSee ~/config.\n";
574 let issues = check_context_invariant("README.md", content, &config);
575 assert!(issues.is_empty());
576 }
577
578 #[test]
581 fn check_line_budget_under() {
582 let tmp = TempDir::new().unwrap();
583 let root = tmp.path();
584 fs::write(root.join("AGENTS.md"), "line1\nline2\nline3\n").unwrap();
585
586 let config = AuditConfig::corky();
587 let files = vec![root.join("AGENTS.md")];
588 let (issues, counts, total) = check_line_budget(&files, root, &config);
589 assert!(issues.is_empty());
590 assert_eq!(total, 3);
591 assert_eq!(counts.len(), 1);
592 assert_eq!(counts[0].0, "AGENTS.md");
593 assert_eq!(counts[0].1, 3);
594 }
595
596 #[test]
597 fn check_line_budget_over() {
598 let tmp = TempDir::new().unwrap();
599 let root = tmp.path();
600 let content = "line\n".repeat(1001);
601 fs::write(root.join("AGENTS.md"), &content).unwrap();
602
603 let config = AuditConfig::corky();
604 let files = vec![root.join("AGENTS.md")];
605 let (issues, _, total) = check_line_budget(&files, root, &config);
606 assert_eq!(total, 1001);
607 assert_eq!(issues.len(), 1);
608 assert!(issues[0].message.contains("Over line budget"));
609 }
610
611 #[test]
612 fn check_line_budget_multiple_files() {
613 let tmp = TempDir::new().unwrap();
614 let root = tmp.path();
615 fs::write(root.join("AGENTS.md"), "a\nb\n").unwrap();
616 fs::write(root.join("SKILL.md"), "c\nd\ne\n").unwrap();
617
618 let config = AuditConfig::corky();
619 let files = vec![root.join("AGENTS.md"), root.join("SKILL.md")];
620 let (_, counts, total) = check_line_budget(&files, root, &config);
621 assert_eq!(total, 5);
622 assert_eq!(counts.len(), 2);
623 }
624
625 #[test]
626 fn check_line_budget_excludes_non_agent_files() {
627 let tmp = TempDir::new().unwrap();
628 let root = tmp.path();
629 fs::write(root.join("AGENTS.md"), "a\nb\n").unwrap();
630 let big_spec = "line\n".repeat(2000);
631 fs::write(root.join("SPEC.md"), &big_spec).unwrap();
632 fs::write(root.join("README.md"), "readme\n").unwrap();
633
634 let config = AuditConfig::corky();
635 let files = vec![
636 root.join("AGENTS.md"),
637 root.join("SPEC.md"),
638 root.join("README.md"),
639 ];
640 let (issues, counts, total) = check_line_budget(&files, root, &config);
641 assert_eq!(total, 2);
643 assert!(issues.is_empty());
644 assert_eq!(counts.len(), 3);
646 }
647
648 #[test]
651 fn check_staleness_doc_newer_than_src() {
652 let tmp = TempDir::new().unwrap();
653 let root = tmp.path();
654 let src = root.join("src");
655 fs::create_dir_all(&src).unwrap();
656 fs::write(src.join("main.rs"), "fn main() {}").unwrap();
657 std::thread::sleep(std::time::Duration::from_millis(50));
658 fs::write(root.join("CLAUDE.md"), "# Doc").unwrap();
659
660 let config = AuditConfig::agent_doc();
661 let files = vec![root.join("CLAUDE.md")];
662 let issues = check_staleness(&files, root, &config);
663 assert!(issues.is_empty());
664 }
665
666 #[test]
667 fn check_staleness_doc_older_than_src() {
668 let tmp = TempDir::new().unwrap();
669 let root = tmp.path();
670 let src = root.join("src");
671 fs::create_dir_all(&src).unwrap();
672 fs::write(root.join("CLAUDE.md"), "# Doc").unwrap();
673 std::thread::sleep(std::time::Duration::from_millis(50));
674 fs::write(src.join("main.rs"), "fn main() {}").unwrap();
675
676 let config = AuditConfig::agent_doc();
677 let files = vec![root.join("CLAUDE.md")];
678 let issues = check_staleness(&files, root, &config);
679 assert_eq!(issues.len(), 1);
680 assert!(issues[0].message.contains("may be stale"));
681 }
682
683 #[test]
684 fn check_staleness_no_src_dir() {
685 let tmp = TempDir::new().unwrap();
686 let root = tmp.path();
687 fs::write(root.join("CLAUDE.md"), "# Doc").unwrap();
688
689 let config = AuditConfig::agent_doc();
690 let files = vec![root.join("CLAUDE.md")];
691 let issues = check_staleness(&files, root, &config);
692 assert!(issues.is_empty());
693 }
694
695 #[test]
698 fn find_instruction_files_root_patterns_with_claude() {
699 let tmp = TempDir::new().unwrap();
700 let root = tmp.path();
701 fs::write(root.join("CLAUDE.md"), "# Doc").unwrap();
702 fs::write(root.join("README.md"), "# Readme").unwrap();
703 fs::write(root.join("AGENTS.md"), "# Agents").unwrap();
704
705 let config = AuditConfig::agent_doc();
706 let files = find_instruction_files(root, &config);
707 assert_eq!(files.len(), 3);
708 assert!(files.iter().any(|f| f.ends_with("CLAUDE.md")));
709 assert!(files.iter().any(|f| f.ends_with("README.md")));
710 assert!(files.iter().any(|f| f.ends_with("AGENTS.md")));
711 }
712
713 #[test]
714 fn find_instruction_files_root_patterns_without_claude() {
715 let tmp = TempDir::new().unwrap();
716 let root = tmp.path();
717 fs::write(root.join("CLAUDE.md"), "# Doc").unwrap();
718 fs::write(root.join("README.md"), "# Readme").unwrap();
719 fs::write(root.join("AGENTS.md"), "# Agents").unwrap();
720
721 let config = AuditConfig::corky();
722 let files = find_instruction_files(root, &config);
723 assert_eq!(files.len(), 2);
724 assert!(!files.iter().any(|f| f.ends_with("CLAUDE.md")));
725 }
726
727 #[test]
728 fn find_instruction_files_glob_patterns() {
729 let tmp = TempDir::new().unwrap();
730 let root = tmp.path();
731
732 fs::create_dir_all(root.join(".claude/skills/email")).unwrap();
733 fs::write(root.join(".claude/skills/email/SKILL.md"), "# Skill").unwrap();
734
735 fs::create_dir_all(root.join(".claude/settings")).unwrap();
736 fs::write(root.join(".claude/settings/CLAUDE.md"), "# Claude").unwrap();
737
738 fs::create_dir_all(root.join("src/agent")).unwrap();
739 fs::write(root.join("src/agent/CLAUDE.md"), "# Agent").unwrap();
740 fs::write(root.join("src/agent/AGENTS.md"), "# Agents").unwrap();
741
742 let config = AuditConfig::agent_doc();
743 let files = find_instruction_files(root, &config);
744 assert_eq!(files.len(), 4);
745 }
746
747 #[test]
748 fn find_instruction_files_empty() {
749 let tmp = TempDir::new().unwrap();
750 let config = AuditConfig::agent_doc();
751 let files = find_instruction_files(tmp.path(), &config);
752 assert!(files.is_empty());
753 }
754
755 #[test]
756 fn find_instruction_files_sorted() {
757 let tmp = TempDir::new().unwrap();
758 let root = tmp.path();
759 fs::write(root.join("README.md"), "# R").unwrap();
760 fs::write(root.join("CLAUDE.md"), "# C").unwrap();
761 fs::write(root.join("AGENTS.md"), "# A").unwrap();
762
763 let config = AuditConfig::agent_doc();
764 let files = find_instruction_files(root, &config);
765 let names: Vec<_> = files.iter().map(|f| f.file_name().unwrap()).collect();
766 assert!(names.windows(2).all(|w| w[0] <= w[1]));
767 }
768
769 #[test]
770 fn find_instruction_files_discovers_spec_md() {
771 let tmp = TempDir::new().unwrap();
772 let root = tmp.path();
773 fs::write(root.join("SPEC.md"), "# Spec").unwrap();
774 fs::write(root.join("AGENTS.md"), "# Agents").unwrap();
775
776 let config = AuditConfig::corky();
777 let files = find_instruction_files(root, &config);
778 assert!(files.iter().any(|f| f.ends_with("SPEC.md")));
779 assert_eq!(files.len(), 2);
780 }
781
782 #[test]
783 fn find_instruction_files_discovers_runbooks() {
784 let tmp = TempDir::new().unwrap();
785 let root = tmp.path();
786
787 fs::create_dir_all(root.join(".agent/runbooks")).unwrap();
788 fs::write(root.join(".agent/runbooks/precommit.md"), "# Precommit").unwrap();
789 fs::write(root.join(".agent/runbooks/prerelease.md"), "# Prerelease").unwrap();
790
791 fs::create_dir_all(root.join(".claude/skills/email/runbooks")).unwrap();
792 fs::write(root.join(".claude/skills/email/runbooks/send.md"), "# Send").unwrap();
793
794 let config = AuditConfig::corky();
795 let files = find_instruction_files(root, &config);
796 assert_eq!(files.len(), 3);
797 assert!(files.iter().any(|f| f.ends_with("precommit.md")));
798 assert!(files.iter().any(|f| f.ends_with("prerelease.md")));
799 assert!(files.iter().any(|f| f.ends_with("send.md")));
800 }
801
802 #[test]
803 fn find_instruction_files_deduplicates() {
804 let tmp = TempDir::new().unwrap();
805 let root = tmp.path();
806 fs::write(root.join("CLAUDE.md"), "# Doc").unwrap();
807
808 let config = AuditConfig::agent_doc();
809 let files = find_instruction_files(root, &config);
810 assert_eq!(files.len(), 1);
811 }
812
813 #[test]
814 fn find_instruction_files_prunes_skip_dirs_before_descending() {
815 let tmp = TempDir::new().unwrap();
816 let root = tmp.path();
817
818 fs::create_dir_all(root.join("src/agent")).unwrap();
819 fs::write(root.join("src/agent/AGENTS.md"), "# Agents").unwrap();
820 fs::create_dir_all(root.join("src/node_modules/pkg")).unwrap();
821 fs::write(
822 root.join("src/node_modules/pkg/AGENTS.md"),
823 "# Ignored Agents",
824 )
825 .unwrap();
826
827 fs::create_dir_all(root.join(".claude/settings")).unwrap();
828 fs::write(root.join(".claude/settings/CLAUDE.md"), "# Claude").unwrap();
829 fs::create_dir_all(root.join(".claude/.venv/cache")).unwrap();
830 fs::write(
831 root.join(".claude/.venv/cache/CLAUDE.md"),
832 "# Ignored Claude",
833 )
834 .unwrap();
835
836 fs::create_dir_all(root.join(".claude/skills/email/runbooks")).unwrap();
837 fs::write(root.join(".claude/skills/email/runbooks/send.md"), "# Send").unwrap();
838 fs::create_dir_all(root.join(".claude/skills/node_modules/pkg/runbooks")).unwrap();
839 fs::write(
840 root.join(".claude/skills/node_modules/pkg/runbooks/ignored.md"),
841 "# Ignored Runbook",
842 )
843 .unwrap();
844
845 fs::create_dir_all(root.join(".agents/team")).unwrap();
846 fs::write(root.join(".agents/team/AGENTS.md"), "# Team Agents").unwrap();
847 fs::create_dir_all(root.join(".agents/vendor/pkg")).unwrap();
848 fs::write(
849 root.join(".agents/vendor/pkg/AGENTS.md"),
850 "# Ignored Vendor Agents",
851 )
852 .unwrap();
853
854 let config = AuditConfig::agent_doc();
855 let files = find_instruction_files(root, &config);
856 let rels: Vec<_> = files
857 .iter()
858 .map(|path| {
859 path.strip_prefix(root)
860 .unwrap()
861 .to_string_lossy()
862 .to_string()
863 })
864 .collect();
865
866 assert!(rels.contains(&"src/agent/AGENTS.md".to_string()));
867 assert!(rels.contains(&".claude/settings/CLAUDE.md".to_string()));
868 assert!(rels.contains(&".claude/skills/email/runbooks/send.md".to_string()));
869 assert!(rels.contains(&".agents/team/AGENTS.md".to_string()));
870 assert!(!rels.contains(&"src/node_modules/pkg/AGENTS.md".to_string()));
871 assert!(!rels.contains(&".claude/.venv/cache/CLAUDE.md".to_string()));
872 assert!(!rels.contains(&".claude/skills/node_modules/pkg/runbooks/ignored.md".to_string()));
873 assert!(!rels.contains(&".agents/vendor/pkg/AGENTS.md".to_string()));
874 }
875}