use crate::rules::{Confirmed, Context, Evidence, Finding, Rule, Stance, Trend};
use crate::shell::{Parsed, Simple};
pub const RULE: Rule = Rule {
id: "foreground-poll",
default_stance: Stance::Advise,
evidence: Evidence {
per_1000: 13.6,
measured: "2026-09-05",
trend: Trend::Flat(8),
},
examine,
confirm: Some(confirm),
};
const LOOPS: &[&str] = &["until", "while", "for"];
fn is_sleep(cmd: &Simple) -> bool {
let mut words = cmd
.words
.iter()
.filter(|w| !w.quoted)
.map(|w| w.text.as_str());
match words.next() {
Some("sleep") => true,
Some("do") => words.next() == Some("sleep"),
_ => false,
}
}
fn examine(parsed: &Parsed) -> Option<Finding> {
let clauses = parsed.clauses();
for cmd in clauses {
if cmd.program() == Some("gh")
&& cmd.subcommand() == Some("run")
&& cmd.operands().get(1).is_some_and(|w| w.text == "watch")
{
return Some(finding(cmd.at, cmd.end));
}
}
let head = clauses
.iter()
.position(|c| c.program().is_some_and(|p| LOOPS.contains(&p)))?;
let sleep = clauses.iter().skip(head + 1).find(|c| is_sleep(c))?;
let end = clauses
.iter()
.skip(head + 1)
.find(|c| {
c.words
.first()
.is_some_and(|w| !w.quoted && w.text == "done")
})
.map(|c| c.end)
.unwrap_or(sleep.end);
Some(finding(clauses[head].at, end))
}
fn finding(at: usize, end: usize) -> Finding {
Finding {
reason: "the Bash tool kills a foreground command at its timeout (ten minutes by \
default) and reports only the kill; a polling loop or `gh run watch` \
written to outlast a CI run pays the whole wait and is cut off one poll \
short of the answer — measured: 96 commands killed at the cap."
.to_string(),
remedy: "Run the same wait with `run_in_background: true` (one notification when \
it exits, no clock), or use the harness's own completion notice for work \
it started. Keep a foreground command well inside the timeout."
.to_string(),
span: at..end,
}
}
fn confirm(ctx: &Context, _f: &Finding) -> Confirmed {
if ctx.background {
Confirmed::No("the call already runs in the background")
} else {
Confirmed::Yes
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shell::lex;
fn fires(command: &str) -> bool {
examine(&lex(command)).is_some()
}
#[test]
fn a_polling_loop_has_the_shape() {
assert!(fires(
"until [ \"$(gh pr checks 247 2>/dev/null | grep -c pending)\" = \"0\" ]; do sleep 30; done; echo done"
));
assert!(fires("while true; do sleep 5; done"));
assert!(fires(
"for i in $(seq 1 18); do n=$(gh pr checks 247 | grep -c pending); if [ \"$n\" = \"0\" ]; then break; fi; sleep 30; done"
));
assert!(fires(
"cd /repo && until grep -q '^exit=' push.log; do sleep 10; done; tail -3 push.log"
));
}
#[test]
fn gh_run_watch_is_a_poll_too() {
assert!(fires("gh run watch 33107191361 --exit-status"));
assert!(fires(
"cd ~/x && gh run watch 1 > /dev/null 2>&1; gh run view 1 --json conclusion"
));
}
#[test]
fn a_loop_that_does_not_sleep_is_a_loop() {
assert!(!fires("for f in a b c; do echo $f; done"));
assert!(!fires("for w in x y; do kubectl get workflow $w; done"));
}
#[test]
fn a_single_sleep_is_not_a_poll() {
assert!(!fires("sleep 2; gh pr checks 229"));
assert!(!fires("sleep 20 && gh run list --limit 1"));
}
#[test]
fn a_background_call_is_not_confirmed() {
let parsed = lex("while true; do sleep 5; done");
let f = examine(&parsed).expect("fires");
let ctx = Context {
cwd: std::path::Path::new("/"),
parsed: &parsed,
background: true,
};
assert!(matches!(confirm(&ctx, &f), Confirmed::No(_)));
let ctx = Context {
background: false,
..ctx
};
assert!(matches!(confirm(&ctx, &f), Confirmed::Yes));
}
#[test]
fn the_span_covers_the_loop() {
let src = "git fetch -q; until [ -f done ]; do sleep 1; done; echo ok";
let f = examine(&lex(src)).expect("fires");
assert_eq!(
src[f.span.clone()].trim().trim_end_matches(';'),
"until [ -f done ]; do sleep 1; done"
);
}
}