Munchable has a house style rule about punctuation: no em dashes in anything a visitor can read. Commas, a colon, or a rewritten sentence, but not that character. You can see the result on the longest prose we publish, munchable.app/privacy and munchable.app/terms, and on any of the 516 generated and hand-written pages behind them.

The rule is enforced by a unit test rather than by review, because copy reaches a page from several directions at once: JSX text, metadata strings, JSON-LD, alt text, the shared legal notices, the emails a script sends. The test is 63 lines and it has been green for weeks.

This week I asked a different question: not "does the test pass" but "where is that character, actually". The answer is 454 occurrences in TypeScript source and 211 more in the markdown docs, and the test can see 90 of them.

The test, in full enough detail

Two decisions do all the work:

// Every directory whose source can end up as words on a page or in an email. const VISIBLE_DIRS = ['app', 'components', 'lib/content', 'lib/email', 'scripts']; const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.mjs', '.txt']; 

and

/** Lines that are wholly comment, including the body of a block comment. */ function isComment(line: string, inBlock: boolean): boolean { const trimmed = line.trim(); return inBlock || trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*'); } 

Five directories, all relative to the web app, and comments exempt. The test's own comment says why the second one is deliberate: comments never render. It also says why the check is line based rather than a parse: it stays cheap and needs no build step.

Both of those are defensible. Neither is the same statement as "the codebase contains no em dash in visible copy", which is what a green tick quietly starts to feel like after a few weeks.

Counting the character

IN SCOPE (what the test reads) app/ 71 occurrences in 25 files components/ 4 in 3 lib/content/ 3 in 2 lib/email/ 6 in 5 scripts/ 6 in 4 total 90 in 39 files, 0 outside a comment OUT OF SCOPE apps/web (whole app) 292 in 76 files, 1 outside a comment apps/mobile 63 in 20 files, 0 outside a comment packages/* 99 in 27 files, 0 outside a comment docs/*.md 211 in 9 files 

So the test reads 90 of the 454 occurrences in source, and the rule it is enforcing is unbroken in all 454. The character is in comments, and the one exception is this:

console.error( `[webhook-idempotency] Redis not configured ... cannot de-duplicate ${provider} event.`, ); 

(The character itself is elided there, because this post follows the same rule.)

That line lives in lib/api/, which is not one of the five directories. It is a server log line, so no visitor reads it and the rule's intent is intact. It is also the only string literal in the entire repository with the character in it, which makes it a precise demonstration of what the directory list is doing: the list is not a description of where visible copy lives, it is the definition of where the rule applies.

Three holes, in the order they matter

The scope is a guess that has to keep being right. lib/content and lib/email are in the list because that is where copy lived when the test was written. Since then the shared packages have grown, and one of them produces the short reason lines the app shows next to a verdict. That is visible copy by any definition, in a workspace the test does not walk. It is clean today, which I know because I measured it and not because anything checks it. The mobile app is in the same position: 63 occurrences, every one a comment, zero enforcement.

The check cannot see an entity. It looks for the character, so — in JSX would sail through. That hole has been exploited before, by me, sixteen times, when a search and replace left the entities behind. Today there is exactly one dash entity left in the repo:

<p className="admin-value is-placeholder">&ndash;</p> 

It is the placeholder in an admin card while the number loads, and it is an en dash being used as a glyph rather than as punctuation. A character-level rule has no way to tell those apart, which is the useful lesson in it: the fix is not a cleverer regex, it is that the rule applies to prose and somebody has to decide what is prose. Adding the entities to the pattern and exempting this one line is the honest version.

Comments are exempt, and I am keeping it that way. 454 occurrences in source, essentially all of them in docstrings and block comments that explain a decision. Banning the character there would be a rule about my internal writing, enforced by CI, with no reader on the other end of it.

The one way it bites is worth naming, because it is how two of the strings in that earlier entity cleanup got there: a sentence that explains a thing well in a docstring is exactly the sentence you reach for when you need a paragraph of user-facing copy, and it arrives with its punctuation. The rule holds at the boundary, as long as the boundary is wide enough to catch the paste.

What I am changing

Not the comment exemption, and not the line-based cheapness: a test you can run in well under a second with no build step is the reason this rule is enforced at all rather than remembered.

The scope and the pattern, though, both want widening. Walk every workspace rather than five directories in one app, treat a string literal as the unit of interest, add the entity spellings, and keep a named allowlist for the deliberate glyph. That is a few more lines in a 63-line file, and it turns "this rule holds where we looked" into "this rule holds".

The general version, for anyone enforcing a copy rule in code: the valuable part of a lint like this is not the pattern, it is the file list. Write down why each directory is in it, then go and count the thing you are banning across the whole repository. The ratio between what your rule reads and what your repository contains is the honest measure of how much the green tick is worth.