Munchable checks packaged food against digestive conditions, one rule set each. For most of its life it checked seven. Then it checked eight, and the commit that did it is the most instructive thing in the repository:
63 files changed, 2,926 insertions(+), 128 deletions(-) By area:
| Area | Files |
|---|---|
apps/web | 33 |
packages/rules-engine | 16 |
apps/mobile | 8 |
docs/ | 4 |
packages/content, packages/reels | 2 |
What I expected was the 16 in the engine package and some pages. What I did not expect was the long tail: twelve files in that commit changed by exactly one line each, one insertion and one deletion, and ten of those lines were a number spelled out in English.
The ten lines
- 'All seven conditions, reconciled', + 'All eight conditions, reconciled', - Five scans a month are free, across all seven conditions.{' '} + Five scans a month are free, across all eight conditions.{' '} - description: 'Five scans a month across all seven conditions.', + description: 'Five scans a month across all eight conditions.', - * Whole foods reviewed as having nothing to say to any of the seven conditions + * Whole foods reviewed as having nothing to say to any of the eight conditions A paywall bullet in the phone app. A line of small print under a condition guide. A description inside a structured-data builder. A doc comment in a data module in a shared package. Also the pricing table, the FAQ, the cancel page, the about page, a component's own comment, and the publisher description that feeds a search snippet.
Every one of them is correct prose. Every one of them is a copy of a fact that lives somewhere else, and the fact had moved.
This is the ordinary shape of content debt and it does not feel like debt while you are writing it. Nobody adds a config value for the word "seven" in a sentence. You are writing a sentence.
The test failed to notice, in the most annoying way possible
There was a test asserting that a recipe cleared for everything says "all seven conditions". When the eighth arrived, the string the code produced changed and the string the test expected did not, so the test should have gone red.
It did not, because the expected value in the test was also the literal word. Both sides said seven. The test agreed with the code about a thing that was now wrong in the world.
/** * "all eight conditions", counted from the registry rather than written down. * * The count was the literal word "seven" here, and in the test that checked * this line, and in a dozen pieces of copy besides. Adding a condition left * every one of them wrong, and the test was wrong in the same direction as the * code so it still passed. Spelled out from the registry, it cannot be. */ const COUNT_WORDS = [ 'zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve', ] as const; export function countWord(n: number): string { return COUNT_WORDS[n] ?? String(n); } export const ALL_CONDITIONS_LINE = `all ${countWord(CONDITION_IDS.length)} conditions`; CONDITION_IDS is Object.keys(RULE_SETS). The sentence now counts the registry, and the test imports the same constant the page renders, which is a weaker test of the words and a much stronger test of the arithmetic. That trade is almost always right for a number you did not choose.
The phone app got the same treatment, in a sentence shown when a condition search finds nothing:
const COUNT_WORDS = ['no', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten']; export const CONDITIONS_NOT_LISTED = `Munchable checks ${ COUNT_WORDS[CONDITIONS.length] ?? CONDITIONS.length } digestive conditions today. If yours is not here, it cannot be checked yet.`; Two copies of the same lookup table, in two packages, and the first entry differs: 'zero' on the web, 'no' in the app. That is not an oversight. "all zero conditions" is nonsense and "Munchable checks no digestive conditions today" is a grammatical sentence, so the arrays are different because the sentences are. Sharing them would mean sharing the wrong word in one of the two places. The honest version of DRY sometimes has two arrays in it.
And the debt is not paid off. Sixteen files in the repo still contain the literal phrase "eight conditions" today. The two that count it are the two that had a test near them. That is usually how it goes: the fix lands where the pain was felt, and the rest waits for the ninth condition.
90 URLs from one condition
The structural part. The site publishes 516 URLs and they are not evenly spread, because each condition multiplies an index of ingredients into a family of question pages.
| Family | Pages |
|---|---|
| Gluten questions (the new one) | 88 |
| Bile acid malabsorption | 67 |
| Low FODMAP | 64 |
| SIBO | 64 |
| Lactose | 52 |
| IBD | 48 |
| Gastroparesis | 34 |
| Reflux | 44 |
Plus eight guides, 38 recipes, and nine fixed pages. The eighth condition arrived as 88 question pages, one guide and one reference page: 90 new URLs, which grew the site by a fifth, and made the newest family the largest one on it.
That ratio is the thing I would tell anyone planning a content-driven product. The cost of a new vertical is not the page you write for it. It is the page family it generates, every index it has to appear in, and every sentence anywhere in two apps that quietly states how many verticals there are.
Everything else in the commit
One migration, two lines of SQL. Plus a 1,376-line snapshot JSON the ORM writes for itself and seven lines of journal. The schema change for an entire new condition is two lines, which is a good sign about the schema and a slightly comic diff.
Four docs, 143 added lines. The architecture, the schema spec and the product plan all describe the set of conditions, so all three were wrong the moment the eighth existed. Docs in a repo are copies of facts too, and they have the same failure mode as the copy in the UI, minus the test that might have caught it.
One new test file in the follow-up commit, 94 lines, and it exists for a failure mode that nothing else can see. The new family's pages are built from a list of label words rather than from the keys of a rule map, which means a word can go quietly dead in two ways: a typo or a renamed id stops it resolving at all, and a re-keyed vocabulary stops it reaching an entry. Either way the page simply stops existing. The build passes, the sitemap is valid, and a URL that somebody has bookmarked is a 404. So the test asserts that every promised word resolves, reaches its data, has a page, and that the page answers the question in its own URL.
It also pins the boring thing that bites once per project:
assert.equal(entry?.name, "Brewer's yeast"); assert.equal(entry?.slug, 'brewer-s-yeast'); An apostrophe in a name becomes a hyphen in a slug, and the only way that stays true is for something to say so out loud.
Have a look at the result
The pages this commit produced are live and they are the useful part to poke at: the condition index with its eight entries, the guide for the new one, the reference list that the 88 question pages hang off, and one of those question pages. The recipe library is where the counted sentence shows up. Open the congee and the line under "who this suits" reads "all eight conditions", because it counted the registry rather than because I typed a word.
63 files, and the ones I remember are the twelve that changed by a single word.