Skip to content

Commit 420d57c

Browse files
Lint: reject a check no shell can parse
Burned a full worker run against a check command that no shell could parse. The manifest linted clean, the worker ran to completion and was paid for, and the only evidence was an opaque `/bin/sh: syntax error near unexpected token `('` after the fact. Because a check that fails to parse can never exit 0, the task can never pass -- every retry attempt is spent before the run fails. The mistake is easy to make by hand and invisible on the page: single quotes nested inside a single-quoted argument, which POSIX shells cannot express. --verify-command 'a && grep -qE '^## Heading (x|y)' out.md' The inner quote closes the argument early and `(x|y)` is then reparsed as bare syntax. lint now runs `sh -n` on each task's check -- which parses without executing, so it is safe on an arbitrary command -- and reports the shell's own diagnosis rather than a bare "invalid", per the house rule that a check should print why it failed. shlex.split() is deliberately not used: it accepts this exact string happily, so it cannot catch the mistake. Where no POSIX shell exists (Windows) the helper returns None and lint stays silent rather than reporting a platform gap as a manifest defect; the accompanying tests assert only shell-independent behaviour on that path. Proof: tests/test_lint.py gains two tests -- one asserting the finding appears and carries the shell's diagnosis, one asserting four valid checks (including correctly-escaped nested quotes) are not flagged. Both fail against unfixed main and pass with this change. Full suite: 255 tests, the single failure being test_deliverables.test_runner_harvests_when_task_passes, which fails identically on pristine main with and without this change.
1 parent a1a91b8 commit 420d57c

2 files changed

Lines changed: 84 additions & 0 deletions

File tree

ringer.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1815,6 +1815,12 @@ def lint_manifest(
18151815
findings.append("manifest: run_name model-scoreboard is reserved for the scoreboard page.")
18161816

18171817
for task in manifest.tasks:
1818+
shell_parse_error = check_shell_parse_error(task.check)
1819+
if shell_parse_error:
1820+
findings.append(
1821+
f"{task.key}: check is not valid shell, so it can never exit 0 and the task "
1822+
f"can never pass — {shell_parse_error}"
1823+
)
18181824
if check_cannot_fail(task.check):
18191825
findings.append(f"{task.key}: check cannot fail, so the task cannot be verified.")
18201826
if check_may_fail_silently(task.check):
@@ -1906,6 +1912,48 @@ def spec_is_file_pointer(spec: str) -> bool:
19061912
return bool(FILE_POINTER_SPEC_RE.search(text))
19071913

19081914

1915+
# `sh -n` parses without executing, so this is safe to run on an arbitrary check.
1916+
SHELL_PARSE_TIMEOUT_S = 10
1917+
1918+
1919+
def check_shell_parse_error(check: str) -> str | None:
1920+
"""Return the shell's own complaint when `check` is not valid shell, else None.
1921+
1922+
A task's check is run through the shell, so a check the shell cannot parse can
1923+
never exit 0: the task can never pass, every retry attempt is spent, and the
1924+
only evidence is an opaque `/bin/sh: syntax error ...` in the run log after the
1925+
workers have already been paid for.
1926+
1927+
`shlex.split()` is deliberately not used here -- it accepts strings a real shell
1928+
rejects, notably single quotes nested inside a single-quoted argument, which is
1929+
the most common way to write an unparseable check by hand:
1930+
1931+
--verify-command 'a && grep -qE '^## Heading (x|y)' out.md'
1932+
1933+
Returns None when no POSIX shell is available (Windows), so lint degrades to
1934+
silence there rather than reporting a platform gap as a manifest defect.
1935+
"""
1936+
shell = shutil.which("sh")
1937+
if not shell:
1938+
return None
1939+
try:
1940+
proc = subprocess.run(
1941+
[shell, "-n"],
1942+
input=check,
1943+
capture_output=True,
1944+
text=True,
1945+
timeout=SHELL_PARSE_TIMEOUT_S,
1946+
)
1947+
except (OSError, subprocess.SubprocessError):
1948+
return None
1949+
if proc.returncode == 0:
1950+
return None
1951+
for line in (proc.stderr or proc.stdout or "").splitlines():
1952+
if line.strip():
1953+
return line.strip()
1954+
return "the shell rejected it"
1955+
1956+
19091957
def check_cannot_fail(check: str) -> bool:
19101958
stripped = strip_shell_comments(check).strip()
19111959
if stripped in {"true", ":", "exit 0"}:

tests/test_lint.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,42 @@ def test_task_fields_must_be_strings(self) -> None:
7777
with self.assertRaisesRegex(ValueError, r"task key must be a string"):
7878
self.manifest([task])
7979

80+
def test_check_must_be_parseable_by_a_shell(self) -> None:
81+
"""A check no shell can parse can never exit 0, so the task can never pass.
82+
83+
Nesting single quotes inside a single-quoted argument is the common way to
84+
write one by accident: the inner quote closes the argument early and the
85+
rest is reparsed as bare syntax. Caught here, it costs seconds; uncaught,
86+
the manifest lints clean, a worker runs to completion and is paid for, and
87+
every retry attempt is burned before the run fails on an opaque
88+
`/bin/sh: syntax error near unexpected token` from the check.
89+
"""
90+
unparseable = (
91+
"verify.py --pattern 'a && grep -qE '^## Heading (x|y)' out.md' --strict"
92+
)
93+
findings = lint_manifest(self.manifest([self.task(check=unparseable)]))
94+
matching = [f for f in findings if f.startswith("one: check is not valid shell")]
95+
self.assertTrue(
96+
matching,
97+
f"expected an unparseable-check finding\nfindings: {findings}",
98+
)
99+
# The finding must carry the shell's own diagnosis -- a bare "invalid"
100+
# leaves the author hunting for which quote broke it.
101+
self.assertRegex(matching[0], r"(?i)syntax error|unexpected")
102+
103+
def test_valid_checks_are_not_flagged_as_unparseable(self) -> None:
104+
for check in (
105+
GOOD_CHECK,
106+
"test -s out.md && grep -qE '^## Heading' out.md",
107+
'verify.py --pattern \'a && grep -qE "^## Heading (x|y)" out.md\' --strict',
108+
"python3 check.py --arg \"nested 'quotes' fine\" || { echo 'FAIL'; exit 1; }",
109+
):
110+
findings = lint_manifest(self.manifest([self.task(check=check)]))
111+
self.assertFalse(
112+
[f for f in findings if "not valid shell" in f],
113+
f"valid check wrongly flagged as unparseable: {check}\nfindings: {findings}",
114+
)
115+
80116
def test_w1_unverifiable_check(self) -> None:
81117
manifest = self.manifest([self.task(check="echo ok && echo done")])
82118
self.assertHasFinding(

0 commit comments

Comments
 (0)