use std::path::Path;
use std::sync::LazyLock;
use regex::Regex;
use crate::analysis::{line_column, push};
use crate::project::{PackageManager, ProjectFacts};
use crate::scanner::SourceFile;
use crate::{Diagnostic, Severity};
static FENCE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?ms)^```(?:bash|sh|shell|zsh|fish|console)\s*\n(.*?)^```\s*$")
.expect("valid shell fence regex")
});
static RUN_INLINE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?im)\b(?:run|execute|command)\s*:?[ \t]+`([^`\n]+)`")
.expect("valid inline command regex")
});
static RUN_LINE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?im)^\s*(?:run|execute)\s+((?:npm|pnpm|yarn|bun|cargo|pytest|python|go|git|docker|make|just|scripts/|\./)[^`\n.]*)",
)
.expect("valid prose command regex")
});
static PACKAGE_MANAGER_PROSE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\b(?:always\s+|must\s+)?use\s+(npm|pnpm|yarn|bun)\b")
.expect("valid package-manager instruction regex")
});
static DESTRUCTIVE_RM_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?:^|\s)(?:sudo\s+)?rm\s+-[^\n]*r[^\n]*f[^\n]*(?:/|\$home|~)")
.expect("valid destructive rm regex")
});
static CURL_PIPE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"curl\b[^\n|]*\|\s*(?:sudo\s+)?(?:sh|bash)\b").expect("valid curl pipe regex")
});
#[derive(Debug)]
struct Command<'a> {
text: &'a str,
offset: usize,
}
pub fn check(
root: &Path,
file: &SourceFile,
project: &ProjectFacts,
diagnostics: &mut Vec<Diagnostic>,
) {
check_package_manager_prose(file, project, diagnostics);
for command in extract(&file.content) {
check_danger(file, &command, diagnostics);
check_package_manager(file, &command, project, diagnostics);
check_package_script(file, &command, project, diagnostics);
check_binary(root, file, &command, diagnostics);
check_tooling(file, &command, project, diagnostics);
}
}
fn check_package_manager_prose(
file: &SourceFile,
project: &ProjectFacts,
diagnostics: &mut Vec<Diagnostic>,
) {
let Some(expected) = project.package_manager else {
return;
};
for capture in PACKAGE_MANAGER_PROSE_RE.captures_iter(&file.content) {
let Some(manager) = capture.get(1) else {
continue;
};
if manager.as_str().eq_ignore_ascii_case(expected.command()) {
continue;
}
let (line, column) = line_column(&file.content, manager.start());
push(
diagnostics,
Diagnostic::new(
"AGL601",
Severity::Warning,
&file.relative_path,
format!(
"Repository uses {}, but this instruction requires {}.",
expected.command(),
manager.as_str().to_ascii_lowercase()
),
)
.at(line, column)
.suggest(format!(
"Use `{}` consistently with repository metadata.",
expected.command()
)),
);
}
}
fn extract(text: &str) -> Vec<Command<'_>> {
let mut commands = Vec::new();
for capture in FENCE_RE.captures_iter(text) {
let Some(block) = capture.get(1) else {
continue;
};
let mut offset = block.start();
for line in block.as_str().lines() {
let command = line.trim();
let leading = line.len().saturating_sub(line.trim_start().len());
if !command.is_empty() && !command.starts_with('#') && !command.starts_with('$') {
commands.push(Command {
text: command,
offset: offset + leading,
});
} else if let Some(command) = command.strip_prefix("$ ") {
commands.push(Command {
text: command,
offset: offset + leading + 2,
});
}
offset += line.len() + 1;
}
}
for capture in RUN_INLINE_RE.captures_iter(text) {
if let Some(command) = capture.get(1) {
commands.push(Command {
text: command.as_str(),
offset: command.start(),
});
}
}
for capture in RUN_LINE_RE.captures_iter(text) {
if let Some(command) = capture.get(1) {
commands.push(Command {
text: command.as_str().trim(),
offset: command.start(),
});
}
}
commands.sort_by_key(|command| command.offset);
commands.dedup_by_key(|command| command.offset);
commands
}
fn check_danger(file: &SourceFile, command: &Command<'_>, diagnostics: &mut Vec<Diagnostic>) {
let lower = command.text.to_ascii_lowercase();
let reason = if lower.contains("sudo rm -rf") || DESTRUCTIVE_RM_RE.is_match(&lower) {
Some("Recursive forced deletion may permanently remove files outside the intended scope.")
} else if lower.contains("git reset --hard") {
Some("`git reset --hard` permanently discards uncommitted tracked changes.")
} else if lower.contains("git clean -fdx") || lower.contains("git clean -xdf") {
Some("`git clean -fdx` permanently deletes ignored and untracked files.")
} else if CURL_PIPE_RE.is_match(&lower) {
Some("Piping downloaded code directly to a shell bypasses review and integrity checks.")
} else {
None
};
if let Some(reason) = reason {
let (line, column) = line_column(&file.content, command.offset);
push(
diagnostics,
Diagnostic::new("AGL501", Severity::Warning, &file.relative_path, reason)
.at(line, column)
.reference(short_command(command.text))
.suggest("Require explicit review or replace this with a narrowly scoped, recoverable command."),
);
}
}
fn check_package_manager(
file: &SourceFile,
command: &Command<'_>,
project: &ProjectFacts,
diagnostics: &mut Vec<Diagnostic>,
) {
let Some(expected) = project.package_manager else {
return;
};
let Some(actual) = command_manager(command.text) else {
return;
};
if actual == expected {
return;
}
let (line, column) = line_column(&file.content, command.offset);
push(
diagnostics,
Diagnostic::new(
"AGL601",
Severity::Warning,
&file.relative_path,
format!(
"Repository uses {}, but this instruction invokes {}.",
expected.command(),
actual.command()
),
)
.at(line, column)
.suggest(format!(
"Use `{}` consistently with the repository lockfile and packageManager metadata.",
expected.command()
)),
);
}
fn check_package_script(
file: &SourceFile,
command: &Command<'_>,
project: &ProjectFacts,
diagnostics: &mut Vec<Diagnostic>,
) {
if project.package_scripts.is_empty() {
return;
}
let Ok(words) = shell_words::split(command.text) else {
return;
};
let script = match words.as_slice() {
[manager, run, script, ..]
if matches!(manager.as_str(), "npm" | "pnpm" | "yarn" | "bun") && run == "run" =>
{
Some(script)
}
[manager, script, ..]
if matches!(manager.as_str(), "pnpm" | "yarn" | "bun") && !known_subcommand(script) =>
{
Some(script)
}
_ => None,
};
let Some(script) = script else { return };
if project.package_scripts.contains(script) {
return;
}
let (line, column) = line_column(&file.content, command.offset);
let suggestion = project
.package_scripts
.iter()
.filter_map(|candidate| {
let distance = strsim::levenshtein(script, candidate);
(distance <= 2).then_some((candidate, distance))
})
.min_by_key(|(_, distance)| *distance)
.map(|(candidate, _)| format!("Did you mean script `{candidate}`?"));
let mut diagnostic = Diagnostic::new(
"AGL503",
Severity::Error,
&file.relative_path,
format!("Package script `{script}` is not defined in package.json."),
)
.at(line, column)
.reference(script);
if let Some(suggestion) = suggestion {
diagnostic = diagnostic.suggest(suggestion);
}
push(diagnostics, diagnostic);
}
fn check_binary(
root: &Path,
file: &SourceFile,
command: &Command<'_>,
diagnostics: &mut Vec<Diagnostic>,
) {
let first_segment = command
.text
.split(['|', ';'])
.next()
.unwrap_or(command.text)
.trim();
let Ok(words) = shell_words::split(first_segment) else {
return;
};
let Some(binary) = words
.iter()
.find(|word| !word.contains('=') && !word.starts_with('-'))
else {
return;
};
if is_shell_builtin(binary) || command_manager(binary).is_some() {
return;
}
let is_path = binary.contains('/');
if is_path
&& binary.starts_with("scripts/")
&& matches!(
Path::new(binary)
.extension()
.and_then(|value| value.to_str()),
Some("sh" | "fish" | "py")
)
{
return;
}
let exists = if is_path {
let path = Path::new(binary);
(if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
})
.is_file()
} else {
which::which(binary).is_ok()
};
if exists {
return;
}
let common = [
"cargo", "rustup", "git", "docker", "pytest", "python", "python3", "make", "just", "npx",
"npm", "pnpm", "yarn", "bun", "go", "node",
];
let close = common
.iter()
.filter_map(|candidate| {
let distance = strsim::levenshtein(binary, candidate);
(distance <= 2).then_some((*candidate, distance))
})
.min_by_key(|(_, distance)| *distance);
let (line, column) = line_column(&file.content, command.offset);
let mut diagnostic = Diagnostic::new(
"AGL502",
Severity::Warning,
&file.relative_path,
format!("Command executable `{binary}` is not available locally."),
)
.at(line, column)
.reference(binary);
if let Some((candidate, _)) = close {
diagnostic = diagnostic.suggest(format!("Did you mean `{candidate}`?"));
}
push(diagnostics, diagnostic);
}
fn check_tooling(
file: &SourceFile,
command: &Command<'_>,
project: &ProjectFacts,
diagnostics: &mut Vec<Diagnostic>,
) {
let lower = command.text.to_ascii_lowercase();
let mismatch = if (lower.starts_with("pytest") || lower.starts_with("python -m pytest"))
&& !project.has_python
{
Some("Instruction requires pytest, but no Python project metadata was found.")
} else if lower.starts_with("cargo ") && !project.has_rust {
Some("Instruction requires Cargo, but no Cargo.toml was found.")
} else if lower.starts_with("go test") && !project.has_go {
Some("Instruction requires Go tests, but no go.mod was found.")
} else {
None
};
if let Some(message) = mismatch {
let (line, column) = line_column(&file.content, command.offset);
push(
diagnostics,
Diagnostic::new("AGL603", Severity::Warning, &file.relative_path, message)
.at(line, column)
.suggest("Remove stale tooling guidance or add the missing project metadata."),
);
}
}
fn command_manager(command: &str) -> Option<PackageManager> {
let first = command.split_whitespace().next()?;
match first {
"npm" | "npx" => Some(PackageManager::Npm),
"pnpm" | "pnpx" => Some(PackageManager::Pnpm),
"yarn" => Some(PackageManager::Yarn),
"bun" | "bunx" => Some(PackageManager::Bun),
_ => None,
}
}
fn known_subcommand(value: &str) -> bool {
[
"add", "install", "remove", "exec", "dlx", "create", "init", "publish", "pack", "config",
"help", "version",
]
.contains(&value)
}
fn is_shell_builtin(value: &str) -> bool {
[
"cd", "export", "set", "source", ".", "test", "if", "for", "while", "echo", "printf",
"read", "true", "false",
]
.contains(&value)
}
fn short_command(value: &str) -> String {
if value.chars().count() <= 100 {
value.to_owned()
} else {
format!("{}…", value.chars().take(100).collect::<String>())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_fences_are_extracted() {
let text = "Run this:\n```bash\ncargo test\n# comment\n```";
let commands = extract(text);
assert_eq!(commands.len(), 1);
assert_eq!(commands[0].text, "cargo test");
}
#[test]
fn manager_is_detected() {
assert_eq!(command_manager("pnpm test"), Some(PackageManager::Pnpm));
assert_eq!(command_manager("cargo test"), None);
}
}