This morning’s continuation fix left two gaps in the guard hook’s parser for a follow-up, and this was it. The hook found heredocs with a regex run over each physical line, so it could not tell code from a quoted string or a comment. In echo "use <<EOF for a heredoc" it saw an operator inside the quotes, went looking for an EOF line that never came, and treated everything after it as the body: a git stash pop on the next line passed every guard. Four readers shared that regex and had to agree on where bodies were. The second gap was smaller: shlex reads <( as one token, which the parser took for a redirection that drops the next word, so cat <(git stash pop) read as cat stash pop.
The fix is one scanner, layout(), that reads the command once the way the shell will, and all four readers now take their bodies from it. I asked Git Bash and PowerShell instead of trusting what I remembered, and some answers surprised me. A terminator line with a trailing space does not end a heredoc. In an unquoted heredoc, bash joins a body line that ends in a backslash to the next before it looks for the terminator. A heredoc left open inside $(...) takes its body from the lines below; one inside backticks does not. A # after a subshell’s closing paren starts a comment, but after $(...) it is part of a word. The delimiter can be any shell word, so <<'END-OF-BODY' is a heredoc the old regex never saw. That made Python’s 1 << 20 inside a body a hazard: let the scanner miss one real heredoc, and that shift becomes a phantom swallowing everything below. So a heredoc with no terminator is read as commands as well as data, as a backstop, except by the override reader, for which data lifts nothing.
Most of the time went into making the checks able to fail. A differential fuzz builds random heredoc-shaped scripts, runs them in the real shell and checks every command the shell ran against what the guard saw. My first PowerShell version reported nothing hidden even against the old parser. PowerShell writes errors as CLIXML, where a marker arrives as M1003_x000D_ and a word-boundary match never fires. Once it could fail, it found a bug of mine, a delimiter word that swallowed the closing backtick. Breaking each of 26 safeguards in turn then turned a test red every time. Replayed over all 21,041 retained commands, no verdict changed. The fuzz also found two gaps outside this fix, filed separately as #808 and #809.