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",
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 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> = Lazy::new(|| {
127 Regex::new(r"(?m)(?:~/|/home/\w+|/Users/\w+|/root/|/tmp/|C:\\Users\\)").unwrap()
128});
129
130pub 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 if trimmed.starts_with("```") {
148 in_code_fence = !in_code_fence;
149 continue;
150 }
151 if in_code_fence {
152 continue;
153 }
154
155 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
173pub 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
251pub 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
285pub fn find_root(config: &AuditConfig) -> PathBuf {
296 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
297
298 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 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 eprintln!("Warning: no project root marker found, using current directory");
326 cwd
327}
328
329pub 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 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#[cfg(test)]
383mod tests {
384 use super::*;
385 use std::fs;
386 use tempfile::TempDir;
387
388 #[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 #[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 #[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 assert_eq!(total, 2);
537 assert!(issues.is_empty());
538 assert_eq!(counts.len(), 3);
540 }
541
542 #[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 #[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}