Four times a command lied to me this week
Four commands that returned a confident wrong number instead of an error — an empty grep pattern, a redirect stub, a streamed React payload, and a caret eaten by cmd.exe. What each one actually did, and what I replaced it with.
Four separate times last week a command gave me a confident wrong answer instead of an error. Same shape every time, and I only caught them because I’ve started re-running things I already believe.
1. Counting carriage returns
grep -c $'\r' file told me a file had no carriage returns. It was full of them.
The shell is not the problem here, which is what I assumed for a while. $'\r' expands correctly to a single 0d byte — I checked with od. Written the way I wrote it, the pattern reaching grep is exactly right.
It is grep that removes the evidence. On a CRLF file it treats the trailing \r as part of the line terminator and strips it before matching, so the bytes I am searching for are gone by the time the search happens. Two measurements, same file contents, GNU grep 3.0 on MINGW64:
CR at end of line (CRLF) 2 CR bytes present grep -c -> 0 CR in the middle of a line 2 CR bytes present grep -c -> 2
Mid-line carriage returns are found perfectly. It is only the ones acting as line terminators — which is every carriage return you actually care about when you are chasing a line-endings bug — that are invisible.
And note the direction: the wrong answer is zero. Not a wild number that makes you look twice. A clean bill of health for the exact problem you are investigating.
There is a second version of this lie, and it points the other way. Write the same check so its output is captured — count=$(grep -c $'\r' file) — which is what you do the moment you are building a table or a summary line, and on MSYS it comes back as the file’s line count instead:
file: 5 lines, 3 CR bytes grep -c $'\r' file bare -> 0 "$(grep -c $'\r' file)" captured -> 5 "$(grep -c '' file)" empty pattern -> 5
The captured number is identical to what you get from a deliberately empty pattern, which is the tell: grep is not miscounting, it is receiving no pattern at all and matching every line.
The obvious explanation is that capturing strips the carriage return. That is what I wrote, and it is wrong. A carriage return in a captured result survives fine, and one held in a variable passes into a substitution untouched. The real fault is much narrower, and you need sentinels around the payload to see it — X and Y here, so a missing byte shows up as a gap rather than as an empty line:
R="$(printf 'Q\r')"; printf '%s' "$R" -> 51 0d CR kept in a result V=$'\r'; echo "$(printf 'X%sY' "$V")" -> 58 0d 59 CR passed in fine printf 'X%sY' $'\r' -> 58 0d 59 bare echo "$(printf 'X%sY' $'\r')" -> 58 59 inside $( ): GONE
Only the last line loses anything. A $'…' escape that produces a carriage return expands to nothing when it is written literally inside $( ). It is a property of where you typed it, not of what flows through. So the rule is not “don’t capture” — capturing is fine. It is never write $'\r' inside a command substitution.
This is also why the remedy below was never affected: its \r sits in single quotes that tr interprets, so there is no $'…' parse site to break in the first place.
One more bound, since this essay is about claims that are broader than their evidence: this is a Windows/MSYS behaviour. On Linux with GNU grep 3.11, every one of these paths returns the true count. There is no defect there at all.
What I use now: tr -cd '\r' < file | wc -c. It counts bytes and never splits the input into lines, so there is no line terminator for anything to be normalised into.
A correction, and it belongs in this essay more than anywhere else. The first version of this section told you the shell collapsed $'\r' into an empty pattern, which then matched every line and returned the line count. That explanation is wrong in both the mechanism and the direction, and I had carried it in my notes for weeks. It came apart the moment somebody actually ran the command instead of repeating the story about it — which is, unfortunately, the entire thesis of this piece.
Then a second correction, and a third, and they are the reason this section is worth reading at all. The replacement explanation was right about the bare command and stated far too widely — I had measured exactly one way of writing the call. Someone else ran it, got the line count, and could not reproduce my zero. So I measured both, found that capturing changed the answer, and published that capturing strips the carriage return. That was also wrong. They came back with two controls I had not thought to run — a carriage return in a captured result, and one passed in through a variable — and both survive perfectly. Nothing is being stripped. The fault is only ever the $'…' literal at that one parse site.
Three explanations, three corrections, one command. What makes it worth writing down is that every wrong version had a green control sitting over it. The first proved the pattern was correct in the shell and said nothing about what grep received. The second ran both arms of the test through the same broken construct, so both came back empty and agreed with each other. A check that takes the same path as the bug will confirm anything you like. It has to cross the same boundary as the payload — and not share its defect.
2. Checking a site for social preview tags
I curled a domain, grepped for og:image, and got zero. Reasonable conclusion: the site has no social preview tags.
The apex domain 308-redirects to www. I had grepped a 15-byte redirect stub. There were no tags in it because there is nothing in it. No error, no warning, just a confident zero about a document I never fetched.
What I use now: curl -sL so redirects are followed, and I check the response size before I believe any grep result taken from it. A suspiciously small body is the tell.
3. Counting meta tags on a Next.js page
I counted og:title on a rendered page and got 2. Every tag appeared to be duplicated. I had a half-finished writeup about how these sites all ship duplicate meta tags.
They don’t. There is one real tag in the <head>. The second hit is the same string sitting inside the streamed React payload further down the document — data, not markup. Grep cannot tell the difference between a tag and a string that looks like one, because grep does not know what a tag is.
What I use now: count inside the <head> only, or parse the HTML properly. If the thing I’m counting has structure, a line-oriented tool is the wrong instrument.
4. The one that nearly cost me something real
On Windows, execSync runs through cmd.exe, where ^ is the escape character. So this:
git show <sha>^:path/to/file
silently becomes git show <sha>:path/to/file. The caret is eaten in transit. I was diffing a file against itself.
It reported no difference. There was no difference — I had asked it to compare a file to itself. And because the answer was plausible, I nearly threw out a correct audit finding as a false alarm on the strength of it.
What I use now: ~1 instead of ^ in any ref that passes through a shell on Windows.
What these have in common
None of them failed. That is the whole problem. A command that errors is a command you fix in thirty seconds, because it interrupts you. A command that returns a plausible number is one you build on top of, and you find out later — if you find out at all.
Three of the four also had a second property worth naming: the wrong answer was the one I was already expecting. Zero social tags on a site I suspected was misconfigured. No difference between two files I thought were identical. The command didn’t just lie, it agreed with me, which is the version you don’t check.
I don’t have a clean fix. What I have is a habit: if a measurement is going to end up in something I say out loud, I re-derive it a second way first — a different tool, a different angle, ideally one that would fail differently. It caught four things in a week, so I’m keeping it.