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",
64                "php", "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> = Lazy::new(|| {
127    Regex::new(r"(?m)(?:~/|/home/\w+|/Users/\w+|/root/|/tmp/|C:\\Users\\)").unwrap()
128});
129
130/// Check instruction files for machine-local path references.
131///
132/// Flags paths like `~/`, `/home/user/`, `/Users/user/`, `/tmp/` that won't
133/// resolve on other machines. These should use repo-relative paths or
134/// declared dependency references instead.
135pub fn check_context_invariant(rel: &str, content: &str, config: &AuditConfig) -> Vec<Issue> {
136    if !is_agent_file(rel, config) {
137        return vec![];
138    }
139
140    let mut issues = Vec::new();
141    let mut in_code_fence = false;
142
143    for (i, line) in content.lines().enumerate() {
144        let trimmed = line.trim();
145
146        // Track code fences -- skip content inside them
147        if trimmed.starts_with("```") {
148            in_code_fence = !in_code_fence;
149            continue;
150        }
151        if in_code_fence {
152            continue;
153        }
154
155        // Check for machine-local paths
156        if let Some(m) = MACHINE_LOCAL_RE.find(line) {
157            issues.push(Issue {
158                file: rel.to_string(),
159                line: i + 1,
160                end_line: 0,
161                message: format!(
162                    "Machine-local path \"{}\" \u{2014} use repo-relative path instead",
163                    m.as_str()
164                ),
165                warning: true,
166            });
167        }
168    }
169
170    issues
171}
172
173/// Check if instruction files are older than source code.
174pub fn check_staleness(files: &[PathBuf], root: &Path, config: &AuditConfig) -> Vec<Issue> {
175    let mut newest_mtime = std::time::SystemTime::UNIX_EPOCH;
176    let mut newest_src = PathBuf::new();
177
178    fn scan_sources(
179        dir: &Path,
180        extensions: &[&str],
181        skip_dirs: &[&str],
182        newest: &mut std::time::SystemTime,
183        newest_path: &mut PathBuf,
184    ) {
185        if let Ok(entries) = std::fs::read_dir(dir) {
186            for entry in entries.flatten() {
187                let path = entry.path();
188                if path.is_dir() {
189                    if let Some(name) = path.file_name().and_then(|n| n.to_str())
190                        && skip_dirs.contains(&name)
191                    {
192                        continue;
193                    }
194                    scan_sources(&path, extensions, skip_dirs, newest, newest_path);
195                } else if let Some(ext) = path.extension().and_then(|e| e.to_str())
196                    && extensions.contains(&ext)
197                    && let Ok(meta) = path.metadata()
198                    && let Ok(mtime) = meta.modified()
199                    && mtime > *newest
200                {
201                    *newest = mtime;
202                    *newest_path = path;
203                }
204            }
205        }
206    }
207
208    let mut found_any = false;
209    for source_dir in &config.source_dirs {
210        let dir = root.join(source_dir);
211        if dir.exists() {
212            found_any = true;
213            scan_sources(
214                &dir,
215                &config.source_extensions,
216                &config.skip_dirs,
217                &mut newest_mtime,
218                &mut newest_src,
219            );
220        }
221    }
222
223    if !found_any {
224        return vec![];
225    }
226
227    let mut issues = Vec::new();
228    for doc in files {
229        if let Ok(meta) = doc.metadata()
230            && let Ok(doc_mtime) = meta.modified()
231            && doc_mtime < newest_mtime
232        {
233            let rel = doc.strip_prefix(root).unwrap_or(doc).to_string_lossy().to_string();
234            let src_rel = newest_src
235                .strip_prefix(root)
236                .unwrap_or(&newest_src)
237                .to_string_lossy()
238                .to_string();
239            issues.push(Issue {
240                file: rel,
241                line: 0,
242                end_line: 0,
243                message: format!("Older than {} \u{2014} may be stale", src_rel),
244                warning: false,
245            });
246        }
247    }
248    issues
249}
250
251/// Check combined line count against budget.
252///
253/// Only counts agent instruction files (AGENTS.md, SKILL.md, optionally CLAUDE.md).
254/// Reference docs (README.md, SPEC.md) are listed but excluded from the budget.
255pub fn check_line_budget(
256    files: &[PathBuf],
257    root: &Path,
258    config: &AuditConfig,
259) -> (Vec<Issue>, Vec<(String, usize)>, usize) {
260    let mut counts = Vec::new();
261    let mut total = 0;
262    for f in files {
263        if let Ok(content) = std::fs::read_to_string(f) {
264            let n = content.lines().count();
265            let rel = f.strip_prefix(root).unwrap_or(f).to_string_lossy().to_string();
266            if is_agent_file(&rel, config) {
267                total += n;
268            }
269            counts.push((rel, n));
270        }
271    }
272    let mut issues = Vec::new();
273    if total > LINE_BUDGET {
274        issues.push(Issue {
275            file: "(all)".to_string(),
276            line: 0,
277            end_line: 0,
278            message: format!("Over line budget: {} lines (max {})", total, LINE_BUDGET),
279            warning: false,
280        });
281    }
282    (issues, counts, total)
283}
284
285// ---------------------------------------------------------------------------
286// Discovery
287// ---------------------------------------------------------------------------
288
289/// Find the project root by walking up from CWD.
290///
291/// Strategy depends on config:
292/// - Pass 1: Check `config.root_markers` in order
293/// - Pass 2: Check for `.git` directory
294/// - Pass 3: Fall back to CWD
295pub fn find_root(config: &AuditConfig) -> PathBuf {
296    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
297
298    // Pass 1: Look for project marker files
299    let mut dir = cwd.as_path();
300    loop {
301        for marker in &config.root_markers {
302            if dir.join(marker).exists() {
303                return dir.to_path_buf();
304            }
305        }
306        match dir.parent() {
307            Some(p) if p != dir => dir = p,
308            _ => break,
309        }
310    }
311
312    // Pass 2: Look for .git directory
313    dir = cwd.as_path();
314    loop {
315        if dir.join(".git").exists() {
316            return dir.to_path_buf();
317        }
318        match dir.parent() {
319            Some(p) if p != dir => dir = p,
320            _ => break,
321        }
322    }
323
324    // Pass 3: Fall back to CWD
325    eprintln!("Warning: no project root marker found, using current directory");
326    cwd
327}
328
329/// Discover all instruction files under the given root.
330///
331/// Searches for:
332/// - Root-level: AGENTS.md, README.md, SPEC.md, and optionally CLAUDE.md
333/// - Glob patterns: .claude/**/SKILL.md, .agents/**/SKILL.md, .agents/**/AGENTS.md, src/**/AGENTS.md
334/// - If `config.include_claude_md`: also .claude/**/CLAUDE.md, src/**/CLAUDE.md
335pub fn find_instruction_files(root: &Path, config: &AuditConfig) -> Vec<PathBuf> {
336    let mut root_patterns = vec!["AGENTS.md", "README.md", "SPEC.md"];
337    if config.include_claude_md {
338        root_patterns.push("CLAUDE.md");
339    }
340
341    let mut found = std::collections::HashSet::new();
342
343    for pattern in &root_patterns {
344        let path = root.join(pattern);
345        if path.exists() {
346            found.insert(path);
347        }
348    }
349
350    // Common glob patterns
351    let mut glob_patterns = vec![
352        ".claude/**/SKILL.md",
353        ".agents/**/SKILL.md",
354        ".agents/**/AGENTS.md",
355        "src/**/AGENTS.md",
356        ".agent/runbooks/*.md",
357        ".claude/skills/**/runbooks/*.md",
358    ];
359
360    if config.include_claude_md {
361        glob_patterns.push(".claude/**/CLAUDE.md");
362        glob_patterns.push("src/**/CLAUDE.md");
363    }
364
365    for pattern in &glob_patterns {
366        if let Ok(entries) = glob::glob(&root.join(pattern).to_string_lossy()) {
367            for entry in entries.flatten() {
368                found.insert(entry);
369            }
370        }
371    }
372
373    let mut result: Vec<PathBuf> = found.into_iter().collect();
374    result.sort();
375    result
376}
377
378// ---------------------------------------------------------------------------
379// Tests
380// ---------------------------------------------------------------------------
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use std::fs;
386    use tempfile::TempDir;
387
388    // --- is_agent_file ---
389
390    #[test]
391    fn is_agent_file_with_claude() {
392        let config = AuditConfig::agent_doc();
393        assert!(is_agent_file("AGENTS.md", &config));
394        assert!(is_agent_file("SKILL.md", &config));
395        assert!(is_agent_file("CLAUDE.md", &config));
396        assert!(is_agent_file("src/AGENTS.md", &config));
397        assert!(is_agent_file(".claude/skills/email/SKILL.md", &config));
398        assert!(is_agent_file("nested/path/CLAUDE.md", &config));
399    }
400
401    #[test]
402    fn is_agent_file_without_claude() {
403        let config = AuditConfig::corky();
404        assert!(is_agent_file("AGENTS.md", &config));
405        assert!(is_agent_file("SKILL.md", &config));
406        assert!(!is_agent_file("CLAUDE.md", &config));
407    }
408
409    #[test]
410    fn is_agent_file_rejects() {
411        let config = AuditConfig::agent_doc();
412        assert!(!is_agent_file("README.md", &config));
413        assert!(!is_agent_file("agents.md", &config));
414        assert!(!is_agent_file("CHANGELOG.md", &config));
415        assert!(!is_agent_file("src/main.rs", &config));
416    }
417
418    // --- check_context_invariant ---
419
420    #[test]
421    fn context_invariant_flags_home_tilde() {
422        let config = AuditConfig::agent_doc();
423        let content = "# Doc\n\nSee ~/some/path for config.\n";
424        let issues = check_context_invariant("CLAUDE.md", content, &config);
425        assert_eq!(issues.len(), 1);
426        assert!(issues[0].message.contains("Machine-local path"));
427        assert!(issues[0].warning);
428    }
429
430    #[test]
431    fn context_invariant_flags_home_absolute() {
432        let config = AuditConfig::agent_doc();
433        let content = "# Doc\n\nConfig at /home/brian/.config/foo.\n";
434        let issues = check_context_invariant("AGENTS.md", content, &config);
435        assert_eq!(issues.len(), 1);
436        assert!(issues[0].message.contains("/home/brian"));
437    }
438
439    #[test]
440    fn context_invariant_flags_macos_users() {
441        let config = AuditConfig::agent_doc();
442        let content = "# Doc\n\nSee /Users/alice/project.\n";
443        let issues = check_context_invariant("CLAUDE.md", content, &config);
444        assert_eq!(issues.len(), 1);
445        assert!(issues[0].message.contains("/Users/alice"));
446    }
447
448    #[test]
449    fn context_invariant_skips_code_fences() {
450        let config = AuditConfig::agent_doc();
451        let content = "# Doc\n\n```bash\nexistence --ontology ~/path\n```\n";
452        let issues = check_context_invariant("CLAUDE.md", content, &config);
453        assert!(issues.is_empty());
454    }
455
456    #[test]
457    fn context_invariant_clean_file() {
458        let config = AuditConfig::agent_doc();
459        let content = "# Doc\n\nUse `src/main.rs` for the entry point.\n";
460        let issues = check_context_invariant("AGENTS.md", content, &config);
461        assert!(issues.is_empty());
462    }
463
464    #[test]
465    fn context_invariant_skips_non_agent_files() {
466        let config = AuditConfig::agent_doc();
467        let content = "# Doc\n\nSee ~/config.\n";
468        let issues = check_context_invariant("README.md", content, &config);
469        assert!(issues.is_empty());
470    }
471
472    // --- check_line_budget ---
473
474    #[test]
475    fn check_line_budget_under() {
476        let tmp = TempDir::new().unwrap();
477        let root = tmp.path();
478        fs::write(root.join("AGENTS.md"), "line1\nline2\nline3\n").unwrap();
479
480        let config = AuditConfig::corky();
481        let files = vec![root.join("AGENTS.md")];
482        let (issues, counts, total) = check_line_budget(&files, root, &config);
483        assert!(issues.is_empty());
484        assert_eq!(total, 3);
485        assert_eq!(counts.len(), 1);
486        assert_eq!(counts[0].0, "AGENTS.md");
487        assert_eq!(counts[0].1, 3);
488    }
489
490    #[test]
491    fn check_line_budget_over() {
492        let tmp = TempDir::new().unwrap();
493        let root = tmp.path();
494        let content = "line\n".repeat(1001);
495        fs::write(root.join("AGENTS.md"), &content).unwrap();
496
497        let config = AuditConfig::corky();
498        let files = vec![root.join("AGENTS.md")];
499        let (issues, _, total) = check_line_budget(&files, root, &config);
500        assert_eq!(total, 1001);
501        assert_eq!(issues.len(), 1);
502        assert!(issues[0].message.contains("Over line budget"));
503    }
504
505    #[test]
506    fn check_line_budget_multiple_files() {
507        let tmp = TempDir::new().unwrap();
508        let root = tmp.path();
509        fs::write(root.join("AGENTS.md"), "a\nb\n").unwrap();
510        fs::write(root.join("SKILL.md"), "c\nd\ne\n").unwrap();
511
512        let config = AuditConfig::corky();
513        let files = vec![root.join("AGENTS.md"), root.join("SKILL.md")];
514        let (_, counts, total) = check_line_budget(&files, root, &config);
515        assert_eq!(total, 5);
516        assert_eq!(counts.len(), 2);
517    }
518
519    #[test]
520    fn check_line_budget_excludes_non_agent_files() {
521        let tmp = TempDir::new().unwrap();
522        let root = tmp.path();
523        fs::write(root.join("AGENTS.md"), "a\nb\n").unwrap();
524        let big_spec = "line\n".repeat(2000);
525        fs::write(root.join("SPEC.md"), &big_spec).unwrap();
526        fs::write(root.join("README.md"), "readme\n").unwrap();
527
528        let config = AuditConfig::corky();
529        let files = vec![
530            root.join("AGENTS.md"),
531            root.join("SPEC.md"),
532            root.join("README.md"),
533        ];
534        let (issues, counts, total) = check_line_budget(&files, root, &config);
535        // Only AGENTS.md counts toward budget (2 lines)
536        assert_eq!(total, 2);
537        assert!(issues.is_empty());
538        // All files listed in counts
539        assert_eq!(counts.len(), 3);
540    }
541
542    // --- check_staleness ---
543
544    #[test]
545    fn check_staleness_doc_newer_than_src() {
546        let tmp = TempDir::new().unwrap();
547        let root = tmp.path();
548        let src = root.join("src");
549        fs::create_dir_all(&src).unwrap();
550        fs::write(src.join("main.rs"), "fn main() {}").unwrap();
551        std::thread::sleep(std::time::Duration::from_millis(50));
552        fs::write(root.join("CLAUDE.md"), "# Doc").unwrap();
553
554        let config = AuditConfig::agent_doc();
555        let files = vec![root.join("CLAUDE.md")];
556        let issues = check_staleness(&files, root, &config);
557        assert!(issues.is_empty());
558    }
559
560    #[test]
561    fn check_staleness_doc_older_than_src() {
562        let tmp = TempDir::new().unwrap();
563        let root = tmp.path();
564        let src = root.join("src");
565        fs::create_dir_all(&src).unwrap();
566        fs::write(root.join("CLAUDE.md"), "# Doc").unwrap();
567        std::thread::sleep(std::time::Duration::from_millis(50));
568        fs::write(src.join("main.rs"), "fn main() {}").unwrap();
569
570        let config = AuditConfig::agent_doc();
571        let files = vec![root.join("CLAUDE.md")];
572        let issues = check_staleness(&files, root, &config);
573        assert_eq!(issues.len(), 1);
574        assert!(issues[0].message.contains("may be stale"));
575    }
576
577    #[test]
578    fn check_staleness_no_src_dir() {
579        let tmp = TempDir::new().unwrap();
580        let root = tmp.path();
581        fs::write(root.join("CLAUDE.md"), "# Doc").unwrap();
582
583        let config = AuditConfig::agent_doc();
584        let files = vec![root.join("CLAUDE.md")];
585        let issues = check_staleness(&files, root, &config);
586        assert!(issues.is_empty());
587    }
588
589    // --- find_instruction_files ---
590
591    #[test]
592    fn find_instruction_files_root_patterns_with_claude() {
593        let tmp = TempDir::new().unwrap();
594        let root = tmp.path();
595        fs::write(root.join("CLAUDE.md"), "# Doc").unwrap();
596        fs::write(root.join("README.md"), "# Readme").unwrap();
597        fs::write(root.join("AGENTS.md"), "# Agents").unwrap();
598
599        let config = AuditConfig::agent_doc();
600        let files = find_instruction_files(root, &config);
601        assert_eq!(files.len(), 3);
602        assert!(files.iter().any(|f| f.ends_with("CLAUDE.md")));
603        assert!(files.iter().any(|f| f.ends_with("README.md")));
604        assert!(files.iter().any(|f| f.ends_with("AGENTS.md")));
605    }
606
607    #[test]
608    fn find_instruction_files_root_patterns_without_claude() {
609        let tmp = TempDir::new().unwrap();
610        let root = tmp.path();
611        fs::write(root.join("CLAUDE.md"), "# Doc").unwrap();
612        fs::write(root.join("README.md"), "# Readme").unwrap();
613        fs::write(root.join("AGENTS.md"), "# Agents").unwrap();
614
615        let config = AuditConfig::corky();
616        let files = find_instruction_files(root, &config);
617        assert_eq!(files.len(), 2);
618        assert!(!files.iter().any(|f| f.ends_with("CLAUDE.md")));
619    }
620
621    #[test]
622    fn find_instruction_files_glob_patterns() {
623        let tmp = TempDir::new().unwrap();
624        let root = tmp.path();
625
626        fs::create_dir_all(root.join(".claude/skills/email")).unwrap();
627        fs::write(root.join(".claude/skills/email/SKILL.md"), "# Skill").unwrap();
628
629        fs::create_dir_all(root.join(".claude/settings")).unwrap();
630        fs::write(root.join(".claude/settings/CLAUDE.md"), "# Claude").unwrap();
631
632        fs::create_dir_all(root.join("src/agent")).unwrap();
633        fs::write(root.join("src/agent/CLAUDE.md"), "# Agent").unwrap();
634        fs::write(root.join("src/agent/AGENTS.md"), "# Agents").unwrap();
635
636        let config = AuditConfig::agent_doc();
637        let files = find_instruction_files(root, &config);
638        assert_eq!(files.len(), 4);
639    }
640
641    #[test]
642    fn find_instruction_files_empty() {
643        let tmp = TempDir::new().unwrap();
644        let config = AuditConfig::agent_doc();
645        let files = find_instruction_files(tmp.path(), &config);
646        assert!(files.is_empty());
647    }
648
649    #[test]
650    fn find_instruction_files_sorted() {
651        let tmp = TempDir::new().unwrap();
652        let root = tmp.path();
653        fs::write(root.join("README.md"), "# R").unwrap();
654        fs::write(root.join("CLAUDE.md"), "# C").unwrap();
655        fs::write(root.join("AGENTS.md"), "# A").unwrap();
656
657        let config = AuditConfig::agent_doc();
658        let files = find_instruction_files(root, &config);
659        let names: Vec<_> = files.iter().map(|f| f.file_name().unwrap()).collect();
660        assert!(names.windows(2).all(|w| w[0] <= w[1]));
661    }
662
663    #[test]
664    fn find_instruction_files_discovers_spec_md() {
665        let tmp = TempDir::new().unwrap();
666        let root = tmp.path();
667        fs::write(root.join("SPEC.md"), "# Spec").unwrap();
668        fs::write(root.join("AGENTS.md"), "# Agents").unwrap();
669
670        let config = AuditConfig::corky();
671        let files = find_instruction_files(root, &config);
672        assert!(files.iter().any(|f| f.ends_with("SPEC.md")));
673        assert_eq!(files.len(), 2);
674    }
675
676    #[test]
677    fn find_instruction_files_discovers_runbooks() {
678        let tmp = TempDir::new().unwrap();
679        let root = tmp.path();
680
681        fs::create_dir_all(root.join(".agent/runbooks")).unwrap();
682        fs::write(root.join(".agent/runbooks/precommit.md"), "# Precommit").unwrap();
683        fs::write(
684            root.join(".agent/runbooks/prerelease.md"),
685            "# Prerelease",
686        )
687        .unwrap();
688
689        fs::create_dir_all(root.join(".claude/skills/email/runbooks")).unwrap();
690        fs::write(
691            root.join(".claude/skills/email/runbooks/send.md"),
692            "# Send",
693        )
694        .unwrap();
695
696        let config = AuditConfig::corky();
697        let files = find_instruction_files(root, &config);
698        assert_eq!(files.len(), 3);
699        assert!(files.iter().any(|f| f.ends_with("precommit.md")));
700        assert!(files.iter().any(|f| f.ends_with("prerelease.md")));
701        assert!(files.iter().any(|f| f.ends_with("send.md")));
702    }
703
704    #[test]
705    fn find_instruction_files_deduplicates() {
706        let tmp = TempDir::new().unwrap();
707        let root = tmp.path();
708        fs::write(root.join("CLAUDE.md"), "# Doc").unwrap();
709
710        let config = AuditConfig::agent_doc();
711        let files = find_instruction_files(root, &config);
712        assert_eq!(files.len(), 1);
713    }
714}