Skip to main content

agent_kit/
audit_common.rs

1//! Shared audit primitives for cross-cutting validation.
2//!
3//! These functions apply to all content types and domain crates,
4//! not just instruction files. Moved from `instruction-files` to
5//! enable reuse across the agent toolkit.
6
7use once_cell::sync::Lazy;
8use regex::Regex;
9use std::path::{Path, PathBuf};
10
11// ---------------------------------------------------------------------------
12// Types
13// ---------------------------------------------------------------------------
14
15/// Configuration for instruction file discovery and auditing.
16///
17/// Different projects can customize behavior by providing different configs.
18#[derive(Debug, Clone)]
19pub struct AuditConfig {
20    /// Project root marker files, checked in order.
21    /// agent-doc uses many (Cargo.toml, package.json, etc.); corky uses only Cargo.toml.
22    pub root_markers: Vec<&'static str>,
23
24    /// Whether to include CLAUDE.md in root-level discovery and agent file checks.
25    /// agent-doc: true, corky: false.
26    pub include_claude_md: bool,
27
28    /// Source file extensions to check for staleness comparison.
29    /// agent-doc: broad (rs, ts, py, etc.); corky: just "rs".
30    pub source_extensions: Vec<&'static str>,
31
32    /// Source directories to scan for staleness.
33    /// agent-doc: ["src", "lib", "app", ...]; corky: just ["src"].
34    pub source_dirs: Vec<&'static str>,
35
36    /// Directories to skip when scanning for source files.
37    pub skip_dirs: Vec<&'static str>,
38}
39
40impl AuditConfig {
41    /// Config matching agent-doc's current behavior: broad project detection,
42    /// includes CLAUDE.md, scans many source extensions.
43    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    /// Config matching corky's current behavior: Cargo.toml-only root detection,
83    /// excludes CLAUDE.md from audit, scans only .rs files.
84    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
95/// An issue found during auditing.
96pub 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
104/// Check if a file path refers to an agent instruction file.
105pub 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
119// ---------------------------------------------------------------------------
120// Checks
121// ---------------------------------------------------------------------------
122
123/// Default line budget for combined instruction files.
124pub 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
129/// Check instruction files for machine-local path references.
130///
131/// Flags paths like `~/`, `/home/user/`, `/Users/user/`, `/tmp/` that won't
132/// resolve on other machines. These should use repo-relative paths or
133/// declared dependency references instead.
134pub 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        // Track code fences -- skip content inside them
146        if trimmed.starts_with("```") {
147            in_code_fence = !in_code_fence;
148            continue;
149        }
150        if in_code_fence {
151            continue;
152        }
153
154        // Check for machine-local paths
155        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
172/// Check if instruction files are older than source code.
173pub 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
254/// Check combined line count against budget.
255///
256/// Only counts agent instruction files (AGENTS.md, SKILL.md, optionally CLAUDE.md).
257/// Reference docs (README.md, SPEC.md) are listed but excluded from the budget.
258pub 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
292// ---------------------------------------------------------------------------
293// Discovery
294// ---------------------------------------------------------------------------
295
296/// Find the project root by walking up from CWD.
297///
298/// Strategy depends on config:
299/// - Pass 1: Check `config.root_markers` in order
300/// - Pass 2: Check for `.git` directory
301/// - Pass 3: Fall back to CWD
302pub fn find_root(config: &AuditConfig) -> PathBuf {
303    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
304
305    // Pass 1: Look for project marker files
306    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    // Pass 2: Look for .git directory
320    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    // Pass 3: Fall back to CWD
332    eprintln!("Warning: no project root marker found, using current directory");
333    cwd
334}
335
336/// Discover all instruction files under the given root.
337///
338/// Searches for:
339/// - Root-level: AGENTS.md, README.md, SPEC.md, and optionally CLAUDE.md
340/// - Glob patterns: .claude/**/SKILL.md, .agents/**/SKILL.md, .agents/**/AGENTS.md, src/**/AGENTS.md
341/// - If `config.include_claude_md`: also .claude/**/CLAUDE.md, src/**/CLAUDE.md
342pub 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// ---------------------------------------------------------------------------
485// Tests
486// ---------------------------------------------------------------------------
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use std::fs;
492    use tempfile::TempDir;
493
494    // --- is_agent_file ---
495
496    #[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    // --- check_context_invariant ---
525
526    #[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    // --- check_line_budget ---
579
580    #[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        // Only AGENTS.md counts toward budget (2 lines)
642        assert_eq!(total, 2);
643        assert!(issues.is_empty());
644        // All files listed in counts
645        assert_eq!(counts.len(), 3);
646    }
647
648    // --- check_staleness ---
649
650    #[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    // --- find_instruction_files ---
696
697    #[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}