use crate::rules::tool_shell;
use crate::rules::{Confirmed, Context, Evidence, Finding, Rule, Stance, Trend};
use crate::shell::{Parsed, Simple, Word};
use std::path::{Path, PathBuf};
pub const RULE: Rule = Rule {
id: "glob-no-match",
default_stance: Stance::Advise,
evidence: Evidence {
per_1000: 40.8,
measured: "2026-09-09",
trend: Trend::Flat(5),
},
examine,
confirm: Some(confirm),
};
const ASKS_WHETHER: &[&str] = &[
"ls", "stat", "test", "[", "echo", "printf", "file", "du", "tree", "exa", "eza",
];
fn globs(cmd: &Simple) -> Vec<&Word> {
cmd.operands()
.into_iter()
.filter(|w| {
!w.quoted && !w.expanded && !w.text.contains('$') && tool_shell::has_glob(&w.text)
})
.collect()
}
fn examine(parsed: &Parsed) -> Option<Finding> {
for cmd in parsed.clauses() {
let Some(program) = cmd.program() else {
continue;
};
if program == "case" || program == "[[" {
continue;
}
let patterns = globs(cmd);
let Some(first) = patterns.first() else {
continue;
};
if ASKS_WHETHER.contains(&program) && patterns.len() == cmd.operands().len() {
continue;
}
return Some(Finding {
reason: format!(
"`{}` is expanded by zsh before `{program}` runs, and a pattern that matches \
nothing is an error there — `no matches found` — raised before the command \
starts, so nothing in this clause runs. `2>/dev/null` cannot hide it, and a \
later clause carries the exit status: the call reports success and returns \
nothing.",
first.text
),
remedy: format!(
"Quote the pattern and let the program walk the tree (`--include='{}'`, `find \
-name '…'`, `git ls-files '…'`), name the files you know exist in their own \
clause, or prefix the command with `setopt nullglob;` when an empty match is \
acceptable.",
first.text.rsplit('/').next().unwrap_or(&first.text)
),
span: cmd.at..cmd.end,
});
}
None
}
fn confirm(ctx: &Context, f: &Finding) -> Confirmed {
if let Err(why) = tool_shell::zsh() {
return Confirmed::No(why);
}
let cwd = ctx.cwd_at(f.span.start);
if !cwd.is_dir() {
return Confirmed::No("the directory the command moves to does not exist");
}
let Some(cmd) = ctx.parsed.clauses().iter().find(|c| c.at == f.span.start) else {
return Confirmed::No("the clause could not be found again");
};
let mut budget = Budget(4_000);
for w in globs(cmd) {
match expand(&cwd, &w.text, &mut budget) {
Some(false) => return Confirmed::Yes,
Some(true) => continue,
None => return Confirmed::No("the pattern is too wide to expand safely"),
}
}
Confirmed::No("every pattern matches something")
}
struct Budget(usize);
fn expand(cwd: &Path, pattern: &str, budget: &mut Budget) -> Option<bool> {
let (base, rest) = if let Some(rest) = pattern.strip_prefix('/') {
(PathBuf::from("/"), rest)
} else if pattern == "~" || pattern.starts_with("~/") {
let home = std::env::var_os("HOME").map(PathBuf::from)?;
(
home,
pattern.strip_prefix('~').unwrap().trim_start_matches('/'),
)
} else {
(cwd.to_path_buf(), pattern)
};
let wants_dir = rest.ends_with('/');
let segments: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect();
walk(&base, &segments, wants_dir, budget)
}
fn walk(base: &Path, segments: &[&str], wants_dir: bool, budget: &mut Budget) -> Option<bool> {
let Some((seg, rest)) = segments.split_first() else {
return Some(if wants_dir {
base.is_dir()
} else {
base.exists()
});
};
if !tool_shell::has_glob(seg) {
return walk(&base.join(seg), rest, wants_dir, budget);
}
if *seg == "**" {
if walk(base, rest, wants_dir, budget)? {
return Some(true);
}
for entry in children(base, budget)? {
let hidden = entry.file_name().to_string_lossy().starts_with('.');
if entry.path().is_dir() && !hidden && walk(&entry.path(), segments, wants_dir, budget)?
{
return Some(true);
}
}
return Some(false);
}
for entry in children(base, budget)? {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with('.') && !seg.starts_with('.') {
continue;
}
if matches(seg.as_bytes(), name.as_bytes()) && walk(&entry.path(), rest, wants_dir, budget)?
{
return Some(true);
}
}
Some(false)
}
fn children(dir: &Path, budget: &mut Budget) -> Option<Vec<std::fs::DirEntry>> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return Some(out);
};
for entry in entries.flatten() {
if budget.0 == 0 {
return None;
}
budget.0 -= 1;
out.push(entry);
}
Some(out)
}
fn matches(pat: &[u8], name: &[u8]) -> bool {
match pat.split_first() {
None => name.is_empty(),
Some((b'*', rest)) => (0..=name.len()).any(|i| matches(rest, &name[i..])),
Some((b'?', rest)) => !name.is_empty() && matches(rest, &name[1..]),
Some((b'[', rest)) => {
let Some(close) = rest.iter().position(|&b| b == b']') else {
return name.first() == Some(&b'[') && matches(rest, &name[1..]);
};
let (class, after) = (&rest[..close], &rest[close + 1..]);
let Some((&c, tail)) = name.split_first() else {
return false;
};
class_matches(class, c) && matches(after, tail)
}
Some((b'\\', rest)) if !rest.is_empty() => {
name.first() == Some(&rest[0]) && matches(&rest[1..], &name[1..])
}
Some((&p, rest)) => name.first() == Some(&p) && matches(rest, &name[1..]),
}
}
fn class_matches(class: &[u8], c: u8) -> bool {
let (negate, body) = match class.first() {
Some(b'!') | Some(b'^') => (true, &class[1..]),
_ => (false, class),
};
let mut i = 0;
let mut hit = false;
while i < body.len() {
if i + 2 < body.len() && body[i + 1] == b'-' {
hit |= body[i] <= c && c <= body[i + 2];
i += 3;
} else {
hit |= body[i] == c;
i += 1;
}
}
hit != negate
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shell::lex;
fn fires(command: &str) -> bool {
examine(&lex(command)).is_some()
}
#[test]
fn an_unquoted_glob_operand_fires() {
assert!(fires(
"grep -rn x scripts/seed.ts app/lib/db/migrations/*.sql 2>/dev/null | head"
));
assert!(fires("cat .claude/*.md 2>/dev/null | head -150"));
assert!(fires(
"rg -n -A12 'kustomize:' .forgejo/workflows/*.yml .github/workflows/*.yml"
));
assert!(fires("head -30 .github/workflows/*.yml | head -50"));
assert!(fires(
"grep -l x build/client/assets/*.css build/server/assets/*.css"
));
assert!(fires("for f in src/*.rs; do head -3 $f; done"));
assert!(fires("ls -la app/ node_modules/*.x"));
}
#[test]
fn a_quoted_pattern_or_an_existence_question_is_silent() {
assert!(!fires("ls .amont* 2>/dev/null"));
assert!(!fires("ls -d /Users/x/Developer/*-wt-forgejo"));
assert!(!fires("ls kubernetes/homelab/apps/argo*"));
assert!(!fires("echo *.sql"));
assert!(!fires("git ls-files '*.md' | head -20"));
assert!(!fires("find . -name '*.go' | xargs grep -l TODO"));
assert!(!fires(
"rg -n --glob '*.yml' 'runs-on' .forgejo/workflows | head"
));
assert!(!fires("echo 'app/lib/db/migrations/*.sql'"));
assert!(!fires("bash -c 'cat .claude/*.md' 2>/dev/null | head"));
assert!(!fires("grep -rn x app/ --include=*.ts | head"));
assert!(!fires("cat $DIR/*.rs"));
assert!(!fires("git tag -l 'v1.*' | tail -3"));
assert!(!fires(
"case \"$st\" in completed*) break;; *) sleep 5;; esac"
));
assert!(!fires("[[ $f == *.rs ]] && echo rust"));
}
#[test]
fn the_matcher_reads_zsh_patterns() {
assert!(matches(b"*.rs", b"main.rs"));
assert!(!matches(b"*.rs", b"main.ts"));
assert!(matches(b"Menu*.js", b"MenuItem.js"));
assert!(matches(b"?.txt", b"a.txt"));
assert!(!matches(b"?.txt", b"ab.txt"));
assert!(matches(b"[a-c]*.md", b"beta.md"));
assert!(!matches(b"[!a-c]*.md", b"beta.md"));
assert!(matches(b"type-presets.css.*", b"type-presets.css.d.ts"));
}
#[test]
fn expansion_walks_the_tree_and_respects_dotfiles() {
let dir = std::env::temp_dir().join(format!("amont-agent-glob-{}", std::process::id()));
std::fs::create_dir_all(dir.join("src/inner")).unwrap();
std::fs::write(dir.join("src/main.rs"), "").unwrap();
std::fs::write(dir.join("src/inner/lib.rs"), "").unwrap();
std::fs::write(dir.join(".hidden.md"), "").unwrap();
let mut b = Budget(1000);
assert_eq!(expand(&dir, "src/*.rs", &mut b), Some(true));
assert_eq!(expand(&dir, "src/*.ts", &mut b), Some(false));
assert_eq!(expand(&dir, "src/**/lib.rs", &mut b), Some(true));
assert_eq!(expand(&dir, "*.md", &mut b), Some(false));
assert_eq!(expand(&dir, ".*.md", &mut b), Some(true));
assert_eq!(expand(&dir, "src/inner*/", &mut b), Some(true));
assert_eq!(expand(&dir, "nope/*.rs", &mut b), Some(false));
assert_eq!(expand(&dir, "src/*", &mut Budget(0)), None);
std::fs::remove_dir_all(&dir).unwrap();
}
}