I changed one string in one file, and git recorded 25 renames to keep the site consistent with it. Of the 179 tests guarding that site, exactly one noticed anything was wrong.
The string was a slug. An article had shipped as smoke-test-article, which described how it got written rather than what it found, so an hour after publishing I renamed it to test-gap-image-pipeline. The plan was small: change the URL, leave the image filenames alone as invisible churn nobody would ever read.
That plan did not survive the first test run.
One slug edit produced a 43-file commit
The rename landed as 972c799. Here is what git recorded, counted from git show 972c799 --name-status --format="":
43 files total
25 renamed
17 modified
1 added
Twenty-two of the twenty-five renames are image files under images/posts/. The other three are the Markdown source, the generated page directory, and the generator script, all of which carry the slug in their own filenames:
R088 content/posts/smoke-test-article.md -> content/posts/test-gap-image-pipeline.md
R076 posts/smoke-test-article/index.html -> posts/test-gap-image-pipeline/index.html
R094 scripts/gen-smoke-test-article-images.mjs -> scripts/gen-test-gap-image-pipeline-images.mjs
None of that was a decision. Twenty-two image renames are the mechanical consequence of one convention in data.jsx, and I did not choose to make them so much as discover I had to.

The convention that turns a slug into 22 filenames
Hero images on this site are not declared per post. They are derived, in data.jsx:81:
const base = `/images/posts/${post.slug}/editorial-${post.slug}-hero`;
The slug appears twice, once as a directory and once inside the filename, and the build fans that base out into PNG, WebP and three width candidates for both the landscape and the portrait master. Body diagrams work the other way. They are literal strings, typed by hand into the Markdown:
<img src="/images/posts/test-gap-image-pipeline/editorial-test-gap-image-pipeline-served.png" alt="...">
So one rename hits two mechanisms with opposite failure modes. The derived path re-points itself the instant the slug changes, silently, at a file that does not exist. The literal path does not move at all, and keeps resolving to whatever the old directory still holds.
I reran the half-done rename to see which half is loud
I did not want to quote the original failure from memory, so I reproduced it today against current HEAD in a throwaway worktree. Change the slug, move nothing else:
git worktree add /tmp/cc-slug HEAD --detach
git mv content/posts/test-gap-image-pipeline.md content/posts/image-pipeline-test-gap.md
# data.jsx: slug and articleSource updated, images/posts/ left untouched
npx vitest run tests/broken-images.test.mjs

Three missing references, all hero, all from the one page. The full capture is archived at docs/news-research/slug-rename-derived-paths-repro.txt. The 2026-08-07 log entry recorded five for the original mid-rename state; today's tree produces three, and I am quoting the run I can show you rather than the one I cannot.
The catch is what did not fail. Running the whole suite on that same tree:
Test Files 1 failed | 40 passed (41)
Tests 1 failed | 178 passed (179)
One test out of 179. tests/broken-images.test.mjs was written months earlier for an unrelated defect, a <source> element pointing at a WebP that was never generated. It walks every src and srcset in the generated HTML and asserts the file exists. It has no concept of a slug, no knowledge that hero paths are derived, and no opinion about renaming. It caught this because a derived path resolves to a filename, and a missing filename is the one thing it does know how to see.
The silent half is the half that reaches readers
While the suite was busy failing on three images, a second consequence went through untouched. Another article links to the renamed one:
content/posts/tests-that-check-mention-not-truth.md:46
... [written up in full](/posts/test-gap-image-pipeline/) ...
That is prose. Nothing derives it, so nothing updated it, and nothing checked it. I rebuilt the site in the worktree and the link is still in the output verbatim, now pointing at a URL the site no longer generates. Forty test files passed over it.
It gets slightly worse. The generator writes posts/<slug>/index.html but does not remove the directory it wrote under the old name, so the stale posts/test-gap-image-pipeline/ was still sitting there, committed, serving the old page. The dead link would not have 404'd. It would have quietly served a duplicate of the article at a URL with no canonical pointing to it, which is the version of this failure a link checker also misses.
A slug is a primary key that happens to look like a URL
That is the whole lesson, and it is not really about slugs. A slug reads like a display string, something you can improve later when a better title occurs to you. It behaves like a primary key: other things are keyed on it, and some of them derive their own identity from it rather than storing a reference.
The cost of changing one is never the redirect. It is every path derived from it, and derived paths are exactly the ones you never wrote down, so they are absent from the checklist you make before starting.
What I cannot claim is a general law. This is one repository, one rename, one test suite of 179, and I have not gone looking for the same shape in a database schema or an API. The 43-file commit is also inflated by generated artifacts that a repo ignoring its own build output would not carry.
What I would defend is the working rule I now use. Before renaming anything that other things are keyed on, grep for the interpolated form, not just the literal one. In this repo that is one command:
grep -rn 'images/posts/\${\|editorial-\${' --include="*.mjs" --include="*.jsx" .
Thirteen lines across five files answer it today, and those are the rename. Everything a grep for the plain slug finds will announce itself when you miss it. Everything only the interpolated grep finds will not.
