All notes

When --write and --check disagree, the formatter is the bug

CI failed a format check on two lesson files. I ran the formatter. The check failed again, on the two files I had just formatted. That is not a formatting problem — that is the formatter telling you it cannot round-trip your content, and I had given it write access to 539 lessons.

The obvious move when a Prettier check goes red is --write. I ran it, committed, and watched --check fail on the same two paths. Ran it again. Different output, still failing.

Two passes producing two different results means the formatter is not converging. Prettier's MDX parser is not idempotent over code embedded in JSX props, and my lessons are full of exactly that — a SandboxExercise with a C snippet in an attribute, a Quiz whose options contain Python. Each pass re-mangled the previous pass's output. --write and --check could never agree because they were asking about a file that changed every time it was read.

I spent an hour trying to make the two commands agree. The actual question was why I wanted them to.

What it had already done

The pre-commit hook had been running that same formatter on every commit for weeks. Prettier was reading _ and * inside code samples as Markdown emphasis and rewriting them:

uint32_t        becomes   uint32*t
/* comment */   becomes   /_ comment _/
self._store     becomes   self.\_store

It also flattened the indentation out of Python and C. Across 236 lesson files. All committed. All passing CI.

Passing, because the corruption is syntactically valid Markdown. Nothing downstream had an opinion. The build was fine. The type-checker was fine. The pages rendered. The only thing wrong was that a beginner reading a lesson on fixed-width integers would be shown a type that does not exist, in a language where the difference between uint32_t and uint32*t is the difference between a value and a pointer.

That is the part I keep coming back to. The blast radius was pedagogy, and pedagogy has no compiler.

The repair that would have made it worse

The instinct is a find-and-replace. Turn every *t back into _t, every /_ back into /*, and go to lunch.

This corpus teaches programming. So it legitimately contains, in uncorrupted files:

  • regular expressions like /\*/g
  • math written as mu*dt
  • **bold** in lessons whose subject is Markdown emphasis

Every one of those matches a corruption signature exactly. A blind pass would have repaired 236 files and broken an unknown number of correct ones, and I would have had no way to tell the two populations apart afterward — the second failure would have looked exactly like the first.

A regex knows what a string looks like. It does not know what language the string is in. That distinction was the whole problem, and it is why the fix took context rather than patterns.

Three layers

Kill the cause. MDX is excluded from Prettier entirely. Not configured, not narrowed with an override — removed. A formatter that cannot round-trip your content does not get write access to it. That is not a workaround; consistent whitespace in prose files was never worth a tool with unsupervised commit rights over the curriculum.

Repair with context. Every fix was resolved against the lesson's own uncorrupted surroundings and the semantics of the language in the block, then re-checked with the real toolchain rather than by eye — black for the Python snippets, build and typecheck for everything else. If a repaired file did not survive the tool that actually understands that language, it was not repaired.

Close the detection gap. The first-pass signatures were incomplete, which I only learned by sweeping the entire corpus for each mangling pattern and adjudicating the survivors by hand. That sweep found mangled identifiers like compute*parametric_var — a single underscore eaten mid-name, in a file the earlier patterns had cleared. If I had trusted the first sweep I would have shipped a corpus that was mostly fixed, which is the worst of the three states, because it retires the alarm.

236 files restored. No lesson's pedagogy rewritten in the process.

What I took from it

Non-convergence is a signal, not an inconvenience. When a tool run twice gives two answers, stop using it and start reading it. It is telling you it does not model your input. I treated that as friction for an hour before I heard it.

Automation that writes needs a check that can see what it wrote. The hook had commit rights over 539 files and the only gate on its output was the same tool that produced it. There was no independent reader in the loop, so the damage accumulated at exactly the rate I committed.

Silent corruption sets the schedule, not you. A crash is a deadline you get to see. This ran for weeks, in a repository with a full CI pipeline, because every automated observer I had was asking a question the failure did not answer.

verso