use std::path::Path;
use crate::progress::Progress;
use crate::suggest::proposal::{Confidence, Evidence, Proposal, ProposalKind};
use crate::suggest::scan::Scan;
const AGENT_HYGIENE_URI: &str = "alint://bundled/agent-hygiene@v1";
pub fn propose(scan: &Scan, progress: &Progress) -> Vec<Proposal> {
let phase = progress.phase("Scanning for agent-hygiene antipatterns", None);
let mut evidence: Vec<Evidence> = Vec::new();
let backup_hits: Vec<&Path> = scan
.index
.files()
.map(|e| e.path.as_ref())
.filter(|p| has_backup_suffix(p))
.filter(|p| !is_fixture_or_test_path(p))
.collect();
if !backup_hits.is_empty() {
evidence.push(Evidence {
message: format!(
"{} backup-suffix file{} ({})",
backup_hits.len(),
if backup_hits.len() == 1 { "" } else { "s" },
preview_paths(&backup_hits, 3),
),
});
}
let scratch_hits: Vec<&Path> = scan
.index
.files()
.map(|e| e.path.as_ref())
.filter(|p| is_scratch_doc_at_root(p))
.collect();
if !scratch_hits.is_empty() {
evidence.push(Evidence {
message: format!(
"{} scratch / planning doc{} at root ({})",
scratch_hits.len(),
if scratch_hits.len() == 1 { "" } else { "s" },
preview_paths(&scratch_hits, 3),
),
});
}
let console_hits = scan_console_log(scan, &phase);
if !console_hits.is_empty() {
evidence.push(Evidence {
message: format!(
"{} `console.log`-style call{} in non-test source ({})",
console_hits.len(),
if console_hits.len() == 1 { "" } else { "s" },
preview_paths_paths(&console_hits, 3),
),
});
}
phase.finish("Antipattern scan complete");
if evidence.is_empty() {
return Vec::new();
}
vec![Proposal {
id: AGENT_HYGIENE_URI.into(),
kind: ProposalKind::BundledRuleset {
uri: AGENT_HYGIENE_URI.into(),
},
confidence: Confidence::Medium,
summary: "Agent-hygiene leftovers detected — bundled ruleset would catch them.".into(),
evidence,
}]
}
fn has_backup_suffix(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
return false;
};
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
ext.eq_ignore_ascii_case("bak")
|| ext.eq_ignore_ascii_case("orig")
|| ext.eq_ignore_ascii_case("swp")
|| ext.eq_ignore_ascii_case("swo")
|| name.ends_with('~')
}
fn is_scratch_doc_at_root(path: &Path) -> bool {
if path
.parent()
.map(Path::as_os_str)
.is_some_and(|s| !s.is_empty())
{
return false;
}
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
return false;
};
matches!(
name,
"PLAN.md"
| "NOTES.md"
| "ANALYSIS.md"
| "DRAFT.md"
| "SCRATCH.md"
| "WIP.md"
| "TODO.md"
| "PROPOSAL.md"
| "BRAINSTORM.md"
| "IDEAS.md"
| "PLAN.txt"
| "NOTES.txt"
)
}
fn scan_console_log(scan: &Scan, phase: &crate::progress::Phase) -> Vec<std::path::PathBuf> {
use regex::Regex;
let pattern =
Regex::new(r"\bconsole\s*\.\s*(log|debug|trace|info)\s*\(").expect("static regex compiles");
let mut hits = Vec::new();
for entry in scan.text_files() {
let path = &entry.path;
if !is_js_or_ts(path) {
continue;
}
if is_fixture_or_test_path(path) {
continue;
}
phase.set_message(&format!("scanning {}", path.display()));
let full = scan.root.join(path);
let Ok(bytes) = std::fs::read(&full) else {
continue;
};
let Ok(text) = std::str::from_utf8(&bytes) else {
continue;
};
if pattern.is_match(text) {
hits.push(path.to_path_buf());
}
}
hits
}
fn is_js_or_ts(path: &Path) -> bool {
matches!(
path.extension().and_then(|s| s.to_str()),
Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs")
)
}
fn is_test_path(path: &Path) -> bool {
let s = path.to_string_lossy();
s.contains("/test/")
|| s.contains("/tests/")
|| s.contains("/__tests__/")
|| s.contains(".test.")
|| s.contains(".spec.")
}
fn is_fixture_or_test_path(path: &Path) -> bool {
let s = path.to_string_lossy();
is_test_path(path)
|| s.contains("/fixtures/")
|| s.contains("/test-fixtures/")
|| s.contains("/snapshots/")
}
fn preview_paths(paths: &[&Path], max: usize) -> String {
use std::fmt::Write;
let take = paths.iter().take(max).map(|p| p.display().to_string());
let mut joined = take.collect::<Vec<_>>().join(", ");
if paths.len() > max {
let _ = write!(joined, ", +{} more", paths.len() - max);
}
joined
}
fn preview_paths_paths(paths: &[std::path::PathBuf], max: usize) -> String {
use std::fmt::Write;
let take = paths.iter().take(max).map(|p| p.display().to_string());
let mut joined = take.collect::<Vec<_>>().join(", ");
if paths.len() > max {
let _ = write!(joined, ", +{} more", paths.len() - max);
}
joined
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_backup_suffix_variants() {
assert!(has_backup_suffix(Path::new("foo.bak")));
assert!(has_backup_suffix(Path::new("foo.orig")));
assert!(has_backup_suffix(Path::new(".foo.swp")));
assert!(has_backup_suffix(Path::new("README~")));
assert!(!has_backup_suffix(Path::new("foo.rs")));
assert!(!has_backup_suffix(Path::new("my.bakery.ts")));
}
#[test]
fn detects_scratch_doc_at_root_only() {
assert!(is_scratch_doc_at_root(Path::new("PLAN.md")));
assert!(is_scratch_doc_at_root(Path::new("NOTES.md")));
assert!(is_scratch_doc_at_root(Path::new("ANALYSIS.md")));
assert!(!is_scratch_doc_at_root(Path::new("docs/PLAN.md")));
assert!(!is_scratch_doc_at_root(Path::new("scripts/NOTES.md")));
assert!(!is_scratch_doc_at_root(Path::new("plan.md")));
}
#[test]
fn flags_js_ts_extensions_only() {
assert!(is_js_or_ts(Path::new("src/foo.ts")));
assert!(is_js_or_ts(Path::new("src/foo.tsx")));
assert!(is_js_or_ts(Path::new("src/foo.js")));
assert!(is_js_or_ts(Path::new("src/foo.mjs")));
assert!(!is_js_or_ts(Path::new("src/foo.rs")));
}
#[test]
fn skips_test_paths() {
assert!(is_test_path(Path::new("src/__tests__/foo.test.ts")));
assert!(is_test_path(Path::new("packages/api/test/foo.ts")));
assert!(is_test_path(Path::new("src/foo.spec.ts")));
assert!(!is_test_path(Path::new("src/foo.ts")));
}
#[test]
fn empty_repo_proposes_nothing() {
let scan = Scan::for_test(
crate::init::Detection::default(),
alint_core::FileIndex::default(),
Vec::new(),
);
let proposals = propose(&scan, &Progress::null());
assert!(proposals.is_empty());
}
#[test]
fn backup_file_alone_proposes_agent_hygiene() {
let index = alint_core::FileIndex::from_entries(vec![alint_core::FileEntry {
path: Path::new("README.md.bak").into(),
is_dir: false,
size: 0,
}]);
let scan = Scan::for_test(crate::init::Detection::default(), index, Vec::new());
let proposals = propose(&scan, &Progress::null());
assert_eq!(proposals.len(), 1);
assert_eq!(proposals[0].id, AGENT_HYGIENE_URI);
assert_eq!(proposals[0].confidence, Confidence::Medium);
}
}