<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: HideyukiMORI</title>
    <description>The latest articles on DEV Community by HideyukiMORI (@hideyukimori).</description>
    <link>https://dev.to/hideyukimori</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg</url>
      <title>DEV Community: HideyukiMORI</title>
      <link>https://dev.to/hideyukimori</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hideyukimori"/>
    <language>en</language>
    <item>
      <title>Two AI Agents Shared a Todo File and a Line Vanished</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Sat, 08 Aug 2026 08:06:55 +0000</pubDate>
      <link>https://dev.to/hideyukimori/two-ai-agents-shared-a-todo-file-and-a-line-vanished-22jd</link>
      <guid>https://dev.to/hideyukimori/two-ai-agents-shared-a-todo-file-and-a-line-vanished-22jd</guid>
      <description>&lt;p&gt;A line disappeared from my todo file.&lt;/p&gt;

&lt;p&gt;Not corrupted. Not moved to the archive where completed items go. Just absent — present in one version, missing in the next, with nothing in between reporting a failure.&lt;/p&gt;

&lt;p&gt;I run several AI coding agents in parallel, one per repository, and they share a single plain-text todo file as their cross-repo coordination point. I've &lt;a href="https://dev.to/hideyukimori/a-todotxt-shared-by-a-human-and-ai-agents-why-plain-text-beat-a-saas-board-for-my-workflow-5dbh"&gt;written before about why plain text beat a SaaS board for this&lt;/a&gt;. This post is about the failure mode I hadn't accounted for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the bug
&lt;/h2&gt;

&lt;p&gt;Every writer to that file — a CLI, a terminal UI, and the agents themselves — does the same thing: read the whole file, change one line in memory, write the whole file back.&lt;/p&gt;

&lt;p&gt;Three processes doing full-file read-modify-write on one path, with no locking anywhere.&lt;/p&gt;

&lt;p&gt;That's a lost update, and it's textbook. If session A reads the file, session B reads it, A writes, then B writes, B's copy never contained A's change and now the file doesn't either. No error surfaces because nothing went wrong at the filesystem level. Both writes succeeded. One of them just described a world that no longer existed by the time it landed.&lt;/p&gt;

&lt;p&gt;I had built a coordination mechanism whose entire job was to be shared, and left out the only part that makes sharing safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that was harder than the fix
&lt;/h2&gt;

&lt;p&gt;Here's what I actually want to pass on, because the concurrency bug is the boring half.&lt;/p&gt;

&lt;p&gt;When I noticed the file looked wrong, I did the obvious thing: diff it against the last backup. Which produced a wall of differences, because between those two points several agents had legitimately edited many lines — rewording items, compressing finished work, updating deadlines. Almost every line had changed &lt;em&gt;somehow&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So "what's different" was useless. Nearly everything was different. The question I actually needed to answer was narrower: &lt;strong&gt;is there anything that exists in the old version and has no counterpart in the new one?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's a set-difference question, not a line-diff question. And answering it meant picking a distinctive word from each candidate line — a repo name, an error string, a ticket number — and searching for that word across the current file. Most of the "missing" lines turned up immediately in reworded form. One didn't. That one was really gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A line diff tells you what changed. It doesn't tell you what's missing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your recovery instinct is &lt;code&gt;diff old new&lt;/code&gt;, it will work beautifully on a file nobody edits and drown you on a file that several writers touch all day.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed, and what I haven't
&lt;/h2&gt;

&lt;p&gt;The honest status: the analysis is done, the fix isn't shipped. The candidates are file locking around every writer, or a modification-time check before each write that aborts if the file moved underneath you, or simply backing up more than once a day — because right now that daily snapshot is the only restore point I have, which is itself a finding.&lt;/p&gt;

&lt;p&gt;I'm not going to pretend I've closed this. What I did do immediately was change my recovery procedure, since that cost nothing and would have saved the hour I spent chasing phantom deletions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same bug, one layer down
&lt;/h2&gt;

&lt;p&gt;What makes this worth writing up rather than filing away is that I hit the identical structure the same week, in a product, in a database.&lt;/p&gt;

&lt;p&gt;One of my apps had just gained a scheduler that sends payment reminders on a timer. Multiple workers, one queue of things to send — so exactly one worker must own a given run. The obvious implementation is: check whether the lock row is free, and if it is, claim it. Two lines of code, and a window between them where another worker can do the same check and reach the same conclusion. Same lost update, dressed as an invoice going out twice.&lt;/p&gt;

&lt;p&gt;SQLite was one of the supported backends and it has no advisory locks, so the design couldn't lean on a database primitive. That constraint turned out to be a gift, because it forced the version that's actually testable: a plain table where &lt;strong&gt;acquiring is a single conditional &lt;code&gt;UPDATE&lt;/code&gt;, and a first-time claim is a single &lt;code&gt;INSERT&lt;/code&gt; on the primary key&lt;/strong&gt;. No check-then-act window, because there's no gap between checking and acting — the database's own atomicity does the arbitration. Releasing verifies a holder token, so a worker can't release someone else's lock. A TTL reclaims locks from workers that died holding them. Six tests, verified against three database engines.&lt;/p&gt;

&lt;p&gt;The todo file and the scheduler are the same bug at different altitudes. The database one got a rigorous fix because it was going to touch customers. The file one is still open because it only bites me.&lt;/p&gt;

&lt;p&gt;I notice that's a real ordering, and also that it's how the file ended up unprotected in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;If more than one process writes a whole file, you have a lost-update bug, whether or not you've seen it yet. Agent sessions count as processes. They're just processes that write convincing commit messages.&lt;/p&gt;

&lt;p&gt;Make claiming a resource one statement, not two. Any "check, then act" pair has a gap in it, and the gap is where two workers agree they both won.&lt;/p&gt;

&lt;p&gt;When you're recovering a multi-writer file, don't ask what changed — ask what has no counterpart. Pick a distinctive token from each old line and search for it in the new one. Reworded is not deleted, and a diff can't tell them apart.&lt;/p&gt;

&lt;p&gt;The lock table, the single-statement claim, and its six tests are here: &lt;a href="https://github.com/hideyukiMORI/nene-clear/pull/406" rel="noopener noreferrer"&gt;nene-clear#406&lt;/a&gt;. The todo file still has no lock.&lt;/p&gt;

&lt;p&gt;What's the shared file in your setup that nobody has put a lock on?&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;


</description>
      <category>ai</category>
      <category>devops</category>
      <category>discuss</category>
      <category>sqlite</category>
    </item>
    <item>
      <title>My Contribution Graph Broke and My Data Was Fine: Three Queries Told Me</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Wed, 05 Aug 2026 16:48:23 +0000</pubDate>
      <link>https://dev.to/hideyukimori/my-contribution-graph-broke-and-my-data-was-fine-three-queries-told-me-3b7j</link>
      <guid>https://dev.to/hideyukimori/my-contribution-graph-broke-and-my-data-was-fine-three-queries-told-me-3b7j</guid>
      <description>&lt;p&gt;My GitHub profile said &lt;strong&gt;Something went wrong&lt;/strong&gt; where the contribution graph should have been. The activity timeline underneath it was empty too.&lt;/p&gt;

&lt;p&gt;Two explanations arrive uninvited when that happens. Either something is wrong with my account, or GitHub is having an outage. Both are worth ruling out before you start refreshing and hoping, because they imply completely different responses — and as it turned out, neither was true.&lt;/p&gt;

&lt;p&gt;What follows is the whole diagnosis. It took about five minutes and three queries, and the method generalises well past this particular breakage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find the actual request behind the broken thing
&lt;/h2&gt;

&lt;p&gt;A rendered page that fails tells you almost nothing. You want the specific call it makes.&lt;/p&gt;

&lt;p&gt;The contribution graph is drawn from &lt;code&gt;contributionsCollection&lt;/code&gt; in GitHub's GraphQL API, so I asked for it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api graphql &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{
  user(login: "your-account") {
    contributionsCollection { contributionCalendar { totalContributions } }
  }
}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That returned &lt;strong&gt;502 Bad Gateway&lt;/strong&gt; after about 11 seconds. Three attempts, three 502s.&lt;/p&gt;

&lt;p&gt;This is already worth something. A reproducible 502 with a long latency is a server-side timeout, not a browser problem — which means refreshing, clearing cache, and trying another browser are all guaranteed to waste your time. I stopped doing any of that immediately.&lt;/p&gt;

&lt;p&gt;But "one query is slow" doesn't yet tell me whether my account is broken.&lt;/p&gt;
&lt;h2&gt;
  
  
  Control 1: is the account itself healthy?
&lt;/h2&gt;

&lt;p&gt;Ask the same account for something trivial:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api graphql &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{ user(login: "your-account") { login createdAt } }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Instant response. The account resolves, the record exists, GraphQL is happy to talk about it.&lt;/p&gt;

&lt;p&gt;So the account isn't damaged or suspended. Whatever is failing is specific to the expensive query, not to me as a user. One suspect eliminated.&lt;/p&gt;
&lt;h2&gt;
  
  
  Control 2: is GitHub itself down?
&lt;/h2&gt;

&lt;p&gt;Run the &lt;em&gt;same expensive query&lt;/em&gt; against a different account. I used Linus Torvalds, because it's a public account with a lot of activity and the query is read-only:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api graphql &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{
  user(login: "torvalds") {
    contributionsCollection { contributionCalendar { totalContributions } }
  }
}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Returned fine.&lt;/p&gt;

&lt;p&gt;This is the control that matters most, and it's the one people skip. Same endpoint, same query shape, different input — and it works. So the aggregation machinery is up. The failure needs &lt;em&gt;my&lt;/em&gt; data to happen.&lt;/p&gt;

&lt;p&gt;Second suspect eliminated. By now I know the problem lives in the intersection of this query and this account, which is a much smaller place to look than "GitHub is broken."&lt;/p&gt;
&lt;h2&gt;
  
  
  Control 3: shrink the input until it works
&lt;/h2&gt;

&lt;p&gt;The default &lt;code&gt;contributionsCollection&lt;/code&gt; window is a trailing year. So I asked for a week:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api graphql &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{
  user(login: "your-account") {
    contributionsCollection(from: "2026-07-12T00:00:00Z", to: "2026-07-19T00:00:00Z") {
      totalCommitContributions
      totalPullRequestContributions
      restrictedContributionsCount
    }
  }
}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;It returned immediately: &lt;strong&gt;2,024 contributions in seven days&lt;/strong&gt; — 560 commits, 541 pull requests, 457 in private repositories.&lt;/p&gt;

&lt;p&gt;That single response answers both remaining questions at once.&lt;/p&gt;

&lt;p&gt;The failure is a function of window size, so it's an aggregation timeout — the backend can't finish summing a year of this account's activity inside its own limit. And &lt;strong&gt;the underlying data is intact&lt;/strong&gt;, because a seven-day slice of it just came back with real numbers in it. Nothing is corrupt or missing. Only the summing-up is failing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A view that won't render and data that's gone are different problems, and one query tells them apart.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That distinction was the whole point of the exercise. If the data had been damaged I'd have had an urgent problem. Instead I had a cosmetic one, and I could go back to work.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why it happened, and what I did about it
&lt;/h2&gt;

&lt;p&gt;The cause is unglamorous: the day before, I'd pushed an unusually heavy burst of work across a lot of repositories at once. A trailing-year aggregation over a high-activity account spanning dozens of repos — private ones included — is simply an expensive thing to compute, and that burst pushed it past whatever budget the backend allows.&lt;/p&gt;

&lt;p&gt;I want to be careful here, because I can't see GitHub's internals. That's the explanation most consistent with the evidence I have — window size determines failure, other accounts are unaffected — not something I confirmed from the inside.&lt;/p&gt;

&lt;p&gt;What I did about it: nothing.&lt;/p&gt;

&lt;p&gt;That was the actual decision, and it was only available to me because of control 3. Once you know the data is fine and the failure is a load-shaped timeout on a view, the correct response is to leave it alone and let the aggregation get cheaper as the burst falls out of the trailing window. There was no fix to apply. There was a wrong action available — filing a support ticket about data loss, or worse, "repairing" something — and skipping it was the win.&lt;/p&gt;

&lt;p&gt;Eleven days later I ran the same year-long query again. It came back in &lt;strong&gt;2.3 seconds&lt;/strong&gt; with 21,253 contributions. It healed exactly the way the evidence said it would.&lt;/p&gt;
&lt;h2&gt;
  
  
  The method, minus my specific problem
&lt;/h2&gt;

&lt;p&gt;Three questions, in this order, each answerable with one call:&lt;/p&gt;

&lt;p&gt;Does a cheap request to the same subject work? If yes, the subject is fine and you're looking at a query problem, not an account problem.&lt;/p&gt;

&lt;p&gt;Does the same expensive request work for a different subject? If yes, the service is fine and the failure needs your particular data.&lt;/p&gt;

&lt;p&gt;Does the expensive request work on a smaller input? If yes, it's a scale or timeout issue — and crucially, you have just proved the data underneath is readable.&lt;/p&gt;

&lt;p&gt;None of that is clever. What makes it useful is that each answer eliminates an entire class of cause, so five minutes of it beats an afternoon of refreshing the page and reading status pages that say all systems operational.&lt;/p&gt;

&lt;p&gt;And the third question does double duty: it's the one that tells you whether you're facing an emergency or an inconvenience. That's usually the thing you actually need to know first.&lt;/p&gt;

&lt;p&gt;What's the last "everything is broken" you had that turned out to be one query being asked for too much at once?&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>github</category>
      <category>api</category>
      <category>graphql</category>
      <category>debugging</category>
    </item>
    <item>
      <title>I Made My First Sync Do Nothing on Purpose: Proving Idempotence Before It Can Hurt</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Wed, 05 Aug 2026 10:32:58 +0000</pubDate>
      <link>https://dev.to/hideyukimori/i-made-my-first-sync-do-nothing-on-purpose-proving-idempotence-before-it-can-hurt-42n9</link>
      <guid>https://dev.to/hideyukimori/i-made-my-first-sync-do-nothing-on-purpose-proving-idempotence-before-it-can-hurt-42n9</guid>
      <description>&lt;p&gt;The most dangerous moment in a sync's life is the first time you run it.&lt;/p&gt;

&lt;p&gt;I write my posts in a git repository and push them to a publishing platform through its API. The sync is one-way and it sends whole articles: fetch everything that's live, compare against what's in the repo, and &lt;code&gt;PUT&lt;/code&gt; the ones that differ.&lt;/p&gt;

&lt;p&gt;Think about what that does on run number one, when the repo has never been reconciled against live. Every article where my local copy is stale gets overwritten with the stale copy. All at once. On published posts that people have already read.&lt;/p&gt;

&lt;p&gt;The usual mitigations are eyeballing a diff, or enabling it for one article first. Neither of them proves anything about the other articles. I wanted a first run whose &lt;em&gt;correctness was structural&lt;/em&gt; rather than checked by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seed from live, not from your repo
&lt;/h2&gt;

&lt;p&gt;The trick is almost too simple to write down: &lt;strong&gt;build the initial state of the mirror by downloading what's already published, not by using what you have locally.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the mirror starts as an exact copy of live, then logically the first sync must find zero differences and change nothing. The first run becomes a no-op by construction.&lt;/p&gt;

&lt;p&gt;And that turns the first run into an experiment. If it really does nothing, then my fetching, my normalisation, and my comparison all agree with the platform's own idea of what those articles contain. If it tries to write something, my comparison is wrong — and I've learned that &lt;em&gt;before&lt;/em&gt; the write happens, on a run I already expected to be silent.&lt;/p&gt;

&lt;p&gt;I called it canary zero: the canary that proves the machinery works by not moving.&lt;/p&gt;

&lt;p&gt;Here's the real log from that first run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;⏭ 03-dev-building-a-typed-cms-for-business-data (id=4052442) 差分なし → no-op
⏭ 04-dev-building-self-hosted-business-tools-for-japan (id=4024309) 差分なし → no-op
⏭ 09-dev-disposable-tenant-demo (id=4099005) 差分なし → no-op
⏭ 11-dev-introducing-nene2-ai-readable-business-apis (id=3957239) 差分なし → no-op
⏭ 12-dev-mcp-should-not-touch-your-database (id=3989175) 差分なし → no-op
⏭ 14-dev-prod-fix-not-hostage (id=4188474) 差分なし → no-op
⏭ 16-dev-shared-todo-txt-human-ai (id=4168967) 差分なし → no-op

合計 PUT=0 no-op=7 新規skip=0 ファイル欠=0 不一致=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Seven articles, zero writes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I verified that something worked correctly by looking at a log where nothing happened.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before even that, the sync had a dry-run mode — do everything except the write, and print what it &lt;em&gt;would&lt;/em&gt; have sent. Idempotence is a property you want to demonstrate before the first &lt;code&gt;PUT&lt;/code&gt; exists in your history, not after.&lt;/p&gt;
&lt;h2&gt;
  
  
  Normalisation is not preprocessing. It's the definition of "different."
&lt;/h2&gt;

&lt;p&gt;The comparison only means something if you've decided what counts as a difference, and this is where the design earns its keep.&lt;/p&gt;

&lt;p&gt;Mine is deliberately thin — line endings, trailing whitespace, runs of blank lines, and the quotes the platform strips from the frontmatter title:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\n{3,}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;(?m)^(title:\s*)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(.*)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\s*$&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\1\2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Then the dry run found something I would never have predicted.&lt;/p&gt;

&lt;p&gt;The platform &lt;strong&gt;adds language labels to unlabelled code fences&lt;/strong&gt;. I write a bare &lt;code&gt;&lt;/code&gt;`&lt;code&gt;&lt;/code&gt;, it looks at the contents, decides that's shell or plaintext or conf, and stores it that way. So the version I sent and the version it returns are permanently different, through no edit by any human.&lt;/p&gt;

&lt;p&gt;The consequence isn't cosmetic. Every sync would see a difference, &lt;code&gt;PUT&lt;/code&gt; the article, get back the labelled version, and see a difference again on the next run. &lt;strong&gt;A permanent write loop against published articles, driven entirely by an artifact of the comparison.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix is one line — ignore the fence label when comparing:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`python&lt;br&gt;
s = re.sub(r"(?m)^(`&lt;/code&gt;)[\w.#+-]*", r"\1", s)&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Note what it does &lt;em&gt;not&lt;/em&gt; do. It only affects comparison. What gets sent is still exactly what's in the repo, so the platform keeps applying its own labels and I never fight it. Two safeguards for one rule.&lt;/p&gt;

&lt;p&gt;After that, the run went back to &lt;code&gt;PUT=0 no-op=7&lt;/code&gt;. Zero churn, verified the same way as before: by nothing happening.&lt;/p&gt;

&lt;p&gt;This is the part I'd generalise. Normalisation feels like a preprocessing detail, something you sprinkle on before a diff. It isn't. &lt;strong&gt;Every rule you add or omit is you deciding what "changed" means&lt;/strong&gt;, and if you never decide, your comparison will happily measure something you didn't intend — in my case, the platform's syntax highlighter.&lt;/p&gt;
&lt;h2&gt;
  
  
  When you can't get the proof, remove the target instead
&lt;/h2&gt;

&lt;p&gt;I run a second mirror to a different platform, and that one has a native git integration — I push, it publishes. No API calls, so no comparison, so no no-op log. The canary-zero trick simply isn't available.&lt;/p&gt;

&lt;p&gt;Rather than pretend it was fine, I changed what the initialisation protected: &lt;strong&gt;connect the integration with zero articles in the repository&lt;/strong&gt;, then add them back one at a time.&lt;/p&gt;

&lt;p&gt;If there's nothing to overwrite, the first run can't overwrite anything. I couldn't prove the machinery was safe, so I made the blast radius empty and grew it deliberately — one article, then another, then a few.&lt;/p&gt;

&lt;p&gt;Same goal, different mechanism, because the mechanisms have different properties. I think that's the more useful takeaway than either trick on its own: decide what the initialisation has to guarantee, then pick the technique that can actually deliver it &lt;em&gt;here&lt;/em&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The other edge of the same knife
&lt;/h2&gt;

&lt;p&gt;One honest postscript, because a sync this decisive cuts both ways.&lt;/p&gt;

&lt;p&gt;It sends whole articles, which means the publish flag in my repo is authoritative too. I once pushed a stale local copy of a &lt;strong&gt;published&lt;/strong&gt; post that still said &lt;code&gt;published: false&lt;/code&gt;, and the sync faithfully unpublished a live article.&lt;/p&gt;

&lt;p&gt;That's not a separate bug. It's the same property that makes the design work — the repo is the truth and the platform is made to match it — applied to an input I hadn't checked. The more self-healing your sync is, the more precisely it will reproduce a mistake in its input. Mine now has a documented pre-push check for exactly this.&lt;/p&gt;
&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;Initialise from the live system, so the first run is a no-op by construction. Then treat that no-op as your proof that the comparison is correct — it's the only test you get to run before the writes start.&lt;/p&gt;

&lt;p&gt;Write the dry-run mode first. Idempotence demonstrated after your first production write is a postmortem, not a test.&lt;/p&gt;

&lt;p&gt;Treat normalisation as the definition of "different," not as cleanup. Ask what the platform silently changes about your content, because whatever that is will look like an edit forever.&lt;/p&gt;

&lt;p&gt;And if the proof isn't available for your mechanism, shrink what can be damaged until the first run is safe by arithmetic instead.&lt;/p&gt;

&lt;p&gt;What does your deploy target quietly rewrite after you hand it your content?&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>devops</category>
      <category>api</category>
      <category>githubactions</category>
      <category>testing</category>
    </item>
    <item>
      <title>My Fact-Check Caught the Viral Claim and Missed My Own Notes</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Tue, 04 Aug 2026 09:44:25 +0000</pubDate>
      <link>https://dev.to/hideyukimori/my-fact-check-caught-the-viral-claim-and-missed-my-own-notes-5gea</link>
      <guid>https://dev.to/hideyukimori/my-fact-check-caught-the-viral-claim-and-missed-my-own-notes-5gea</guid>
      <description>&lt;p&gt;I thought I had verified it. Six days later I found out I hadn't — not all of it.&lt;/p&gt;

&lt;p&gt;A post crossed my feed claiming that a very high share of engineers at one AI company run self-improving agent loops. I don't repeat numbers I haven't sourced, so before reacting I went looking for where it came from. That wasn't caution for its own sake: if the claim were true, I'd have had to rebuild how I run my own agent fleet.&lt;/p&gt;

&lt;p&gt;I couldn't find that sentence in the public record.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually found
&lt;/h2&gt;

&lt;p&gt;Three separate, real things — none of which was the claim.&lt;/p&gt;

&lt;p&gt;An engineer there is reported as saying he no longer writes code by hand, with a record day of about 150 pull requests. I only ever reached secondhand accounts of that, so I'm passing it along as a report, not as a measurement.&lt;/p&gt;

&lt;p&gt;Anthropic's leadership has publicly estimated that 90% or more of their code is written by Claude, including scripts and experimental code. That's an estimate, and the source says so.&lt;/p&gt;

&lt;p&gt;And there's a published measurement: as of May 2026, more than 80% of the code merged into their codebase was authored by Claude.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every source described how much code the model writes. None described how many humans run the loop.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The viral version had swapped the subject — code became engineers — and borrowed "90%" from a different sentence about a different thing. Nobody had to lie for that to happen. A number just drifted one noun to the left.&lt;/p&gt;

&lt;h2&gt;
  
  
  Absence is the hard part
&lt;/h2&gt;

&lt;p&gt;I want to be careful about what I'm claiming, because this is where fact-checks usually overreach.&lt;/p&gt;

&lt;p&gt;I searched from several angles, pulled the top sources, and had each claim challenged by verifiers whose job was to &lt;em&gt;refute&lt;/em&gt; it rather than confirm it. The verdict was unanimous. But unanimous about what? Not that the sentence doesn't exist. Only that I couldn't reach it.&lt;/p&gt;

&lt;p&gt;I've been burned by this exact gap before. I once read a &lt;code&gt;grep&lt;/code&gt; returning zero hits as proof that something wasn't there, and it was — my pattern was wrong. I've also had a search API's "no results" turn out to be a display cutoff rather than an empty set. Zero hits means your search stopped, not that the world is empty.&lt;/p&gt;

&lt;p&gt;So the sentence I'll defend is "I couldn't find it in the public record," and not "it does not exist." Those are different claims, and only one of them is something I can actually support.&lt;/p&gt;

&lt;p&gt;The search did hand me something useful, though. A published analysis of about 400,000 Claude Code sessions from roughly 235,000 people, between October 2025 and April 2026, found that people make about 70% of the planning decisions but only about 20% of the execution ones. I keep a human gate on every go/no-go call in my setup, and I'd half-assumed that made me slow to automate. Turns out it's roughly where the measured division of labour already sits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then my own note failed the same check
&lt;/h2&gt;

&lt;p&gt;Six days later I was about to post a short version of all this. My rule is to re-fetch primary sources before anything with a number in it goes out, even when the research was already verified — days passing counts as drift.&lt;/p&gt;

&lt;p&gt;Four claims in the draft. Three held. One didn't.&lt;/p&gt;

&lt;p&gt;My research notes had recorded something like "only 0–20% of tasks can be fully delegated." That phrasing appears nowhere in the study. There's no "delegable" anywhere in it, and no 0–20% range. The most likely explanation is that I read "humans make about 20% of execution decisions" and quietly rewrote it as "0–20% of work can be delegated" — which is a different statement about a different thing.&lt;/p&gt;

&lt;p&gt;Which is to say: the same swap I had just caught in someone else's number, in my own summary, six days later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The check I built for other people's numbers never ran on my own.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What caught it wasn't the elaborate part. Not the multi-angle search, not the adversarial verifiers. It was one plain re-fetch of the source document, at the last possible moment, because a rule said to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap wasn't verification. It was the return path.
&lt;/h2&gt;

&lt;p&gt;Here's the part that actually changed how I work.&lt;/p&gt;

&lt;p&gt;When I fixed that line, I fixed it in the outgoing post. The research note that produced the post stayed wrong. It sat there for six days, untouched since the day it was written, with no correction task filed against it — and I only noticed because I went back to write this article and checked.&lt;/p&gt;

&lt;p&gt;My published work had a gate. The notes that fed it did not. Corrections travelled outward to the artifact and stopped, when the thing that needed fixing was upstream of everything the artifact would ever become.&lt;/p&gt;

&lt;p&gt;That's fixed now, and I'd rather show it than assert it. The note carries a strikethrough on the wrong figure, a dated correction marker with the right one, and a line saying how it was caught — because silently editing a note leaves no evidence that anything was ever wrong. A second person re-fetched the source independently and confirmed the original text contains neither the word nor the range. And the missing step got written down as a rule: when you correct a number, immediately grep the whole workspace for the same claim and kill every copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;For any number-shaped claim, confirm &lt;strong&gt;the subject and the denominator&lt;/strong&gt; in a primary source before you let it move your roadmap. Most viral statistics aren't fabricated — they're real numbers describing something adjacent.&lt;/p&gt;

&lt;p&gt;"I couldn't find it" and "it doesn't exist" are different sentences, and only one of them is verifiable. Say the one you can support.&lt;/p&gt;

&lt;p&gt;Run your verification on your own notes, not just on other people's claims — and make sure the correction travels back to where the note lives, not just to what you published.&lt;/p&gt;

&lt;p&gt;Where do your corrections go: into the post, or back into the note?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; &lt;a href="https://www.anthropic.com/institute/recursive-self-improvement" rel="noopener noreferrer"&gt;When AI builds itself&lt;/a&gt; (the &amp;gt;80% measurement and the 90%+ leadership estimate) · &lt;a href="https://www.anthropic.com/research/claude-code-expertise" rel="noopener noreferrer"&gt;How Claude Code is used in practice&lt;/a&gt; (the ~400,000-session analysis and the 70%/20% split)&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;


</description>
      <category>ai</category>
      <category>discuss</category>
      <category>productivity</category>
      <category>learning</category>
    </item>
    <item>
      <title>My Upload Check Trusted the Attacker's Word: 8 Bytes Fixed It</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Fri, 31 Jul 2026 15:12:50 +0000</pubDate>
      <link>https://dev.to/hideyukimori/my-upload-check-trusted-the-attackers-word-8-bytes-fixed-it-540m</link>
      <guid>https://dev.to/hideyukimori/my-upload-check-trusted-the-attackers-word-8-bytes-fixed-it-540m</guid>
      <description>&lt;p&gt;A QA pass sent my document uploader a Windows executable that declared itself &lt;code&gt;application/pdf&lt;/code&gt;. It was accepted, stored, and listed like any other document.&lt;/p&gt;

&lt;p&gt;The MIME allowlist that was supposed to stop it had run, matched, and passed. It wasn't buggy. It was reading a value the uploader had written.&lt;/p&gt;

&lt;p&gt;This is the companion to &lt;a href="https://dev.to/hideyukimori/half-my-tests-failed-and-none-were-broken-my-shell-poisoned-phpunit-14ej"&gt;my Bug Smash post about environment variables poisoning a test run&lt;/a&gt; — that one was about my machine lying to me. This one is about my &lt;em&gt;input&lt;/em&gt; lying to me.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the check actually covered
&lt;/h2&gt;

&lt;p&gt;The uploader had what looked like several layers of protection. Laid out by who controls each value, the picture changes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it checked&lt;/th&gt;
&lt;th&gt;Who controls it&lt;/th&gt;
&lt;th&gt;Stops a spoof?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;accept=".pdf,.jpg,.jpeg,.png"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;file picker filter&lt;/td&gt;
&lt;td&gt;the browser UI&lt;/td&gt;
&lt;td&gt;❌ cosmetic only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Declared MIME allowlist&lt;/td&gt;
&lt;td&gt;a string in the multipart body&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;the uploader&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File extension&lt;/td&gt;
&lt;td&gt;the filename&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;the uploader&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First bytes of the payload&lt;/td&gt;
&lt;td&gt;the actual content&lt;/td&gt;
&lt;td&gt;the file itself&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;nosniff&lt;/code&gt; + &lt;code&gt;attachment&lt;/code&gt; on download&lt;/td&gt;
&lt;td&gt;how the browser treats the response&lt;/td&gt;
&lt;td&gt;the server&lt;/td&gt;
&lt;td&gt;⚠️ mitigation, not a fix&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of those layers were reading values the uploading client had chosen. The allowlist contents were correct — PDF, JPEG, PNG, exactly what the compliance rule says. It just never saw the file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I wasn't validating the file. I was validating a string the uploader chose.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That bottom row deserves a note, because it's the one I could have hidden behind. The download path already sent &lt;code&gt;X-Content-Type-Options: nosniff&lt;/code&gt; and &lt;code&gt;Content-Disposition: attachment&lt;/code&gt; before any of this happened. That mitigation is real, and it predates the bug — which is exactly why it isn't a fix. It changes what a browser does with a file that I already agreed to store. Rejecting at intake is a different question, and I hadn't answered it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it was found
&lt;/h2&gt;

&lt;p&gt;Not by a scanner. By a person following a QA script, escalating one step at a time: send a &lt;code&gt;.exe&lt;/code&gt; (rejected, good), send an &lt;code&gt;.svg&lt;/code&gt; (rejected, good), then send the &lt;code&gt;.exe&lt;/code&gt; bytes with a spoofed &lt;code&gt;application/pdf&lt;/code&gt; content type.&lt;/p&gt;

&lt;p&gt;That third step is the one automated checks tend not to reach, because it requires deciding to lie about your own request.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: read eight bytes
&lt;/h2&gt;

&lt;p&gt;Keep the declared-MIME allowlist as a cheap first gate, then put content sniffing behind it. No new dependencies — no &lt;code&gt;finfo&lt;/code&gt;, no library, &lt;code&gt;composer.json&lt;/code&gt; untouched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;sniffMimeType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$tmpPath&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="nb"&gt;fopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tmpPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'rb'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$handle&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;fread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;fclose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$handle&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$header&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$header&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%PDF-'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'application/pdf'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\xFF\xD8\xFF&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'image/jpeg'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\x89&lt;/span&gt;&lt;span class="s2"&gt;PNG&lt;/span&gt;&lt;span class="se"&gt;\r\n\x1A\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'image/png'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Eight bytes because the PNG signature is eight bytes long; the other two are shorter prefixes of the same read. The call site is four lines:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$sniffed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sniffMimeType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tmpPath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sniffed&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sniffed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ALLOWED_MIME_TYPES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MimeTypeNotAllowedException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$sniffed&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;'unknown'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two decisions in there matter more than the signatures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Undecidable means rejected.&lt;/strong&gt; An unreadable file, an empty file, or anything whose first bytes don't match a known signature all return &lt;code&gt;null&lt;/code&gt;, and &lt;code&gt;null&lt;/code&gt; fails. A sniffer that returns "unknown" and then shrugs is not a gate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The two rejections say different things.&lt;/strong&gt; A disallowed declaration gets &lt;code&gt;File type 'x' is not allowed. Only PDF, JPEG, and PNG are accepted.&lt;/code&gt; A spoof gets &lt;code&gt;Declared file type 'application/pdf' does not match the file content ('unknown'). Only genuine PDF, JPEG, and PNG files are accepted.&lt;/code&gt; Collapsing those into one message would have been less code and would have thrown away the only signal that distinguishes a confused user from someone probing you.&lt;/p&gt;

&lt;p&gt;One thing I did not do: SVG is rejected, not sanitized. An SVG renamed and re-declared as PNG fails the signature check and never reaches storage. If you need to &lt;em&gt;accept&lt;/em&gt; SVG, this post doesn't help you — that's a different problem with a much larger surface.&lt;/p&gt;
&lt;h2&gt;
  
  
  The test that couldn't be written
&lt;/h2&gt;

&lt;p&gt;Here's the part that changed how I read my own test suites.&lt;/p&gt;

&lt;p&gt;Before the fix, the test helper passed &lt;code&gt;tmpPath: '/tmp/fake-upload'&lt;/code&gt;. That's a string. There was no file at that path. There had never been a file at that path.&lt;/p&gt;

&lt;p&gt;So a content-based test wasn't merely missing from the suite. &lt;strong&gt;It was impossible to write.&lt;/strong&gt; The validation logic stopped at the declared value because the fixtures had no content for it to go on to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The check was as deep as my fixtures were.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The line that unlocked everything was &lt;code&gt;tempnam()&lt;/code&gt; — create a real temporary file, write real bytes into it, delete it in &lt;code&gt;tearDown()&lt;/code&gt;. Once fixtures were files instead of strings, the new cases wrote themselves: a spoofed &lt;code&gt;.exe&lt;/code&gt; asserting the mismatch message, an SVG declared as PNG, and genuine JPEG and PNG regressions to prove the gate still lets real documents through.&lt;/p&gt;
&lt;h2&gt;
  
  
  What's still open
&lt;/h2&gt;

&lt;p&gt;The vulnerability is closed at intake, and I'd rather end on the part I haven't finished than on the part I have.&lt;/p&gt;

&lt;p&gt;The end-to-end test that originally caught this still carries its discovery-time assertion:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;listed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;modalText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;upload resolved (accepted or errored)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeTruthy&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Read that carefully. It passes when the spoofed file is accepted and listed, and it passes when the upload errors out. It was written to &lt;em&gt;observe&lt;/em&gt; what happened, back when nobody knew. It would go green whether or not my fix works.&lt;/p&gt;

&lt;p&gt;I closed the vulnerability and left the test that found it unable to prove it. A test written to demonstrate a bug does not become a regression test until you flip its expectation.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I'd tell past me
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;If the value you're validating arrived in the request body, you're validating the attacker's input, not the file.&lt;/li&gt;
&lt;li&gt;Undecidable must mean rejected. "Unknown, so I'll allow it" is not a gate.&lt;/li&gt;
&lt;li&gt;Fixtures set the ceiling on what your tests can check. A fixture that isn't a file can never catch a content bug.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What's still in your upload path that the uploader gets to declare?&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>bugsmash</category>
      <category>devchallenge</category>
      <category>security</category>
      <category>php</category>
    </item>
    <item>
      <title>Fact-Checking My Own Blog Posts Turned Into Product QA: 3 Real Bugs</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Fri, 31 Jul 2026 15:08:02 +0000</pubDate>
      <link>https://dev.to/hideyukimori/fact-checking-my-own-blog-posts-turned-into-product-qa-3-real-bugs-f7p</link>
      <guid>https://dev.to/hideyukimori/fact-checking-my-own-blog-posts-turned-into-product-qa-3-real-bugs-f7p</guid>
      <description>&lt;p&gt;I was fact-checking a sentence in one of my own blog posts — "the API returns &lt;code&gt;no-store&lt;/code&gt;" — and found out the API did no such thing.&lt;/p&gt;

&lt;p&gt;So I didn't edit the sentence. I fixed the product.&lt;/p&gt;

&lt;p&gt;I have a rule for myself: before I publish anything technical, I go through it and check every factual claim against the actual code. Not to impress the reader — to keep myself honest, because it's embarrassingly easy to write something that &lt;em&gt;sounds&lt;/em&gt; true. This week that habit stopped being about the writing. Reviewing four posts turned up &lt;strong&gt;three real bugs, in the product, not the posts.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: the sentence was right, the product was late
&lt;/h2&gt;

&lt;p&gt;The post was about an auth-header quirk on shared hosting, and it claimed the API returned &lt;code&gt;Cache-Control: no-store&lt;/code&gt; on authenticated responses. Reasonable thing to claim. Also, when I actually checked, not true — the header wasn't being set.&lt;/p&gt;

&lt;p&gt;Because of how the app worked around a proxy that strips the standard &lt;code&gt;Authorization&lt;/code&gt; header, the usual "responses tied to Authorization aren't shared-cacheable" protection didn't apply. Which meant an authenticated response could, in theory, land in a shared cache.&lt;/p&gt;

&lt;p&gt;I had two options: soften the blog sentence, or make it true. I added a small middleware that sets &lt;code&gt;no-store&lt;/code&gt; at the outermost layer of the pipeline, left a couple of tests behind, and shipped it. The sentence is now correct because the product changed to match it — not the other way around.&lt;/p&gt;

&lt;p&gt;That's the case I like most. &lt;strong&gt;My writing was ahead of my code, and checking the writing is what surfaced it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: "nothing accumulates" — except the thing that did
&lt;/h2&gt;

&lt;p&gt;Another post described a disposable-demo setup: click a link, get a throwaway tenant, and an hourly sweep deletes everything, so nothing piles up. I went to verify the "nothing piles up" claim literally.&lt;/p&gt;

&lt;p&gt;Nearly nothing piled up. But each demo tenant left behind a small per-tenant stamp file — a throttle bookkeeping artifact — that the sweep wasn't cleaning. Every demo click added one and never removed it. Not a leak that would take the server down soon, but a direct contradiction of a sentence I was about to publish.&lt;/p&gt;

&lt;p&gt;The sweep now removes those stamps when it deletes a tenant, and self-heals any orphans from tenants that are already gone. Claim restored to true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: the demo was showing the future
&lt;/h2&gt;

&lt;p&gt;While writing the English version of that demo post, I clicked through the seeded data the way a reader would. The demo plants realistic history — a few invoices, some payments — so it looks like a live business.&lt;/p&gt;

&lt;p&gt;Open it in the first half of a month and some of that "history" was dated in the &lt;em&gt;future&lt;/em&gt;: a payment recorded for a date that hadn't happened yet, a paid invoice due next week. The seed used fixed day-of-month values, so early in the month they landed ahead of today.&lt;/p&gt;

&lt;p&gt;The person most likely to notice that is an accountant — exactly the audience the demo is for. The fix clamps every seeded event date to "today or earlier," while leaving genuinely future dates (like due dates) alone. I added a test that seeds on the first of the month — the worst case — and confirmed it failed before the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Being honest about the yield
&lt;/h2&gt;

&lt;p&gt;Four posts, three product fixes — but I don't want to oversell the hit rate. Two of the four posts had &lt;strong&gt;zero&lt;/strong&gt; product bugs; their claims were already true, and checking them just cost me an hour and bought me confidence. The three bugs clustered in two of the posts, not one per article. If I dressed this up as "every blog post hides a bug," that would be its own small fabrication.&lt;/p&gt;

&lt;p&gt;The point isn't a reliable yield. It's the lens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why prose catches what tests miss
&lt;/h2&gt;

&lt;p&gt;Tests check the assertions you &lt;em&gt;thought&lt;/em&gt; to write. Prose makes a different kind of claim, and a more dangerous one: &lt;strong&gt;"the system does X,"&lt;/strong&gt; stated plainly enough for a stranger to falsify.&lt;/p&gt;

&lt;p&gt;Your code doesn't say &lt;code&gt;no-store&lt;/code&gt;. Your blog post does. Your test suite never asserted "nothing accumulates" or "no dates are in the future" — but your paragraph did, out loud, to the whole internet. Writing forces you to compress behavior into flat declarative sentences, and flat declarative sentences are exactly the thing you can walk over to the code and check.&lt;/p&gt;

&lt;p&gt;So publishing under a rule of "every claim gets verified against the code" isn't just hygiene for the post. It's a QA pass driven by a question your tests never asked: &lt;em&gt;is what I'm telling people actually true right now?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And when the answer is "no, but it should be" — like the &lt;code&gt;no-store&lt;/code&gt; case — the honest fix isn't to edit the sentence down. It's to make the sentence true.&lt;/p&gt;

&lt;p&gt;Do you check your posts against the code before you hit publish — and has it ever turned up a bug in the thing you were writing about?&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;


</description>
      <category>testing</category>
      <category>ai</category>
      <category>discuss</category>
      <category>opensource</category>
    </item>
    <item>
      <title>My Generator Hid a Lint Error: CI Never Checks What Nobody Commits</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Thu, 30 Jul 2026 15:49:46 +0000</pubDate>
      <link>https://dev.to/hideyukimori/my-generator-hid-a-lint-error-ci-never-checks-what-nobody-commits-3186</link>
      <guid>https://dev.to/hideyukimori/my-generator-hid-a-lint-error-ci-never-checks-what-nobody-commits-3186</guid>
      <description>&lt;p&gt;The lint error lived in a file my CI had never seen. It &lt;em&gt;couldn't&lt;/em&gt; have seen it — the file only exists after you run a generator with a particular flag, and nobody had ever committed that flag's output.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/hideyukiMORI/NENE2" rel="noopener noreferrer"&gt;NENE2&lt;/a&gt;, my framework for typed business apps, ships frontend scaffolding generators: &lt;code&gt;gen:entity&lt;/code&gt; builds the typed API layer for a resource, &lt;code&gt;gen:feature&lt;/code&gt; builds a state-machine-shaped feature on top. They're template-based and deterministic on purpose — deterministic output is what makes generated code reviewable and lets AI agents use the generators safely.&lt;/p&gt;

&lt;p&gt;And like most generators, they have flags. &lt;code&gt;gen:entity &amp;lt;noun&amp;gt; --write&lt;/code&gt; adds mutation hooks and write handlers on top of the read-only default. Flags mean branches. Branches mean output shapes that may exist nowhere in your repo.&lt;/p&gt;

&lt;p&gt;That's where the bug was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;The entity mutations template emitted this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;UseMutationResult&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Noun&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;AppError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The third type argument, &lt;code&gt;unknown&lt;/code&gt;, is exactly the parameter's default — so &lt;code&gt;@typescript-eslint/no-unnecessary-type-arguments&lt;/code&gt; flags it. Which means every single output of &lt;code&gt;gen:entity &amp;lt;noun&amp;gt; --write&lt;/code&gt; failed lint, in a codebase where CI runs ESLint with &lt;code&gt;--max-warnings 0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And CI was green the whole time. The generator's determinism tests passed — they verify the templates produce stable, expected output. The committed exemplar for the &lt;em&gt;default&lt;/em&gt; archetype passed lint, because it's real committed code and CI lints it like everything else. But the &lt;code&gt;--write&lt;/code&gt; branch's output had never been committed anywhere, so no type-checker and no linter had ever run over a single line of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A determinism test proves your generator is consistent. It will happily prove it's consistently wrong.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is a cousin of a broader failure mode I keep running into: gates that glow green while checking nothing in the branch that matters.&lt;/p&gt;
&lt;h2&gt;
  
  
  How it surfaced
&lt;/h2&gt;

&lt;p&gt;Not by auditing. By building the next feature.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://github.com/hideyukiMORI/NENE2/pull/1580" rel="noopener noreferrer"&gt;PR #1580&lt;/a&gt; I was adding a &lt;code&gt;--mutation&lt;/code&gt; archetype to &lt;code&gt;gen:feature&lt;/code&gt; — a four-state union (&lt;code&gt;idle | submitting | error | success&lt;/code&gt;) for form-style features. That archetype consumes what &lt;code&gt;gen:entity --write&lt;/code&gt; produces, so for once, verifying the new thing forced me to &lt;em&gt;actually generate&lt;/em&gt; the old thing.&lt;/p&gt;

&lt;p&gt;The verification was a full rehearsal on real output: run &lt;code&gt;gen:entity payment&lt;/code&gt; and &lt;code&gt;gen:feature submit-payment payment --mutation&lt;/code&gt;, then put the generated files through the same gauntlet as committed code — type-check, ESLint with &lt;code&gt;--max-warnings 0&lt;/code&gt;, Prettier, and the generated transition tests against a real mock server.&lt;/p&gt;

&lt;p&gt;Lint went red on code no human had written and no CI had judged. There it was.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;One line in the template. Drop the third type argument — &lt;code&gt;TVariables&lt;/code&gt; defaults to &lt;code&gt;unknown&lt;/code&gt; anyway, so the emitted types are identical and behavior doesn't change. The PR body records the before/after; the generated output now comes out type-check clean, lint clean at &lt;code&gt;--max-warnings 0&lt;/code&gt;, with its four transition tests passing (4/4, exercised against mocked 200 and 500 responses).&lt;/p&gt;

&lt;p&gt;The fix took a minute. The bug had unlimited shelf life. That asymmetry is the whole story.&lt;/p&gt;
&lt;h2&gt;
  
  
  The general lesson: enumerate your generator's branches
&lt;/h2&gt;

&lt;p&gt;If your codebase has a generator with flags or optional archetypes, each branch of its output is code you're shipping to your future self — and CI's default posture toward it is total blindness. Two ways to close the gap:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keep a committed exemplar per branch.&lt;/strong&gt; Generate one real instance of every archetype/flag combination and commit it as living code. CI then type-checks, lints, and tests it forever, for free. Drift between templates and exemplar shows up as a normal red build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a generate-and-check smoke to CI.&lt;/strong&gt; Generate every branch into a temp dir, run type-check + lint + tests over the output, throw it away. No repo noise; costs CI minutes instead.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Either works. What doesn't work is what I had: determinism tests that hold the generator's output stable while nothing ever asks whether that output is &lt;em&gt;valid&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Full honesty about where I actually am: PR #1580 did option 2 &lt;em&gt;manually, once&lt;/em&gt; — a rehearsal, not a gate. The PR body itself flags a committed exemplar for the mutation branch as a follow-up. So the branch that bit me is verified today and unguarded tomorrow, and I'm writing this partly so I can't quietly forget that.&lt;/p&gt;
&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every generator flag is a code branch CI has never met. Green CI says nothing about output nobody commits.&lt;/li&gt;
&lt;li&gt;Determinism/golden tests check stability, not validity — pair them with a committed exemplar per branch, or a generate → type-check → lint → test smoke.&lt;/li&gt;
&lt;li&gt;The cheapest audit is a rehearsal: generate each archetype for real and run your normal &lt;code&gt;check&lt;/code&gt; over the output. My first rehearsal of an old flag found a lint error of unknown age.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Primary sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;NENE2 &lt;a href="https://github.com/hideyukiMORI/NENE2/pull/1580" rel="noopener noreferrer"&gt;PR #1580&lt;/a&gt; (merge &lt;code&gt;c2b5311&lt;/code&gt;) — the &lt;code&gt;--mutation&lt;/code&gt; archetype, the rehearsal, and the one-line template fix&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Related: &lt;a href="https://dev.to/hideyukimori/i-built-a-tiny-php-framework-for-ai-readable-business-apis-48eo"&gt;I built a tiny PHP framework for AI-readable business APIs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have a generator in your codebase: when did the output of &lt;em&gt;every one&lt;/em&gt; of its flags last pass your linter? I'd honestly love to know if anyone gates this properly.&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>bugsmash</category>
      <category>devchallenge</category>
      <category>typescript</category>
      <category>testing</category>
    </item>
    <item>
      <title>My Committed Bundle Went Stale: CI Rebuilt It and Never Compared</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Thu, 30 Jul 2026 15:38:47 +0000</pubDate>
      <link>https://dev.to/hideyukimori/my-committed-bundle-went-stale-ci-rebuilt-it-and-never-compared-3p7m</link>
      <guid>https://dev.to/hideyukimori/my-committed-bundle-went-stale-ci-rebuilt-it-and-never-compared-3p7m</guid>
      <description>&lt;p&gt;I found out that a bundle committed to one of my repos had gone stale because an unrelated PR left my git tree dirty.&lt;/p&gt;

&lt;p&gt;Not a test. Not a review. A dirty tree, noticed by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/hideyukiMORI/nene-concierge" rel="noopener noreferrer"&gt;nene-concierge&lt;/a&gt; is one of my self-hosted business tools. Its repo commits a built frontend artifact: &lt;code&gt;public_html/admin/app.js&lt;/code&gt;, the admin bundle, produced by esbuild via &lt;code&gt;npm run build&lt;/code&gt;. The &lt;code&gt;public_html/&lt;/code&gt; tree is the deployable root, generated files included.&lt;/p&gt;

&lt;p&gt;Committing build output is a choice with a known tradeoff. I knew the tradeoff. I just wasn't paying for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke
&lt;/h2&gt;

&lt;p&gt;An earlier refactor (&lt;a href="https://github.com/hideyukiMORI/nene-concierge/issues/178" rel="noopener noreferrer"&gt;#178&lt;/a&gt;/&lt;a href="https://github.com/hideyukiMORI/nene-concierge/pull/179" rel="noopener noreferrer"&gt;#179&lt;/a&gt;) added &lt;code&gt;"type": "module"&lt;/code&gt; to &lt;code&gt;frontend/package.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That one line changed esbuild's CJS interop output. Every helper call in the bundle went from &lt;code&gt;__toESM(x)&lt;/code&gt; to &lt;code&gt;__toESM(x, 1)&lt;/code&gt;. From that day on, the &lt;code&gt;app.js&lt;/code&gt; sitting in &lt;code&gt;main&lt;/code&gt; no longer matched what a clean build produces — a 573-insertion diff of pure drift.&lt;/p&gt;

&lt;p&gt;Nothing failed. Type-check green. Lint green. Tests green. Nothing in the pipeline had any opinion about that file.&lt;/p&gt;

&lt;p&gt;Here's the detail that stings most. My CI runs &lt;code&gt;npm run check&lt;/code&gt;, and &lt;code&gt;check&lt;/code&gt; &lt;em&gt;includes&lt;/em&gt; &lt;code&gt;build&lt;/code&gt;. So CI was rebuilding that bundle on every single run — producing the correct, fresh output in its own workspace — and then throwing it away without ever comparing it to the file committed two directories over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A committed artifact is a claim about your source code, and nothing was checking the claim.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How it surfaced
&lt;/h2&gt;

&lt;p&gt;By luck. While verifying an unrelated PR (&lt;a href="https://github.com/hideyukiMORI/nene-concierge/pull/183" rel="noopener noreferrer"&gt;#183&lt;/a&gt;, a lint-suppressions ratchet gate), I ran the full check locally and &lt;code&gt;git status&lt;/code&gt; came back dirty: &lt;code&gt;npm run build&lt;/code&gt; had rewritten &lt;code&gt;public_html/admin/app.js&lt;/code&gt; under me.&lt;/p&gt;

&lt;p&gt;That PR's body records it as an explicitly out-of-scope finding, the artifact was restored so the ratchet PR stayed clean, and the drift got its own issue: &lt;a href="https://github.com/hideyukiMORI/nene-concierge/issues/185" rel="noopener noreferrer"&gt;#185&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If that ratchet PR hadn't happened to run a build on my machine, I have no idea when I'd have noticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix — and what the fix doesn't fix
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/hideyukiMORI/nene-concierge/pull/186" rel="noopener noreferrer"&gt;PR #186&lt;/a&gt; (merged as &lt;code&gt;b7eb1ea&lt;/code&gt;) is deliberately boring: run &lt;code&gt;npm run build&lt;/code&gt;, commit the output as-is. Machine diff only, zero source changes, two generated files touched (&lt;code&gt;app.js&lt;/code&gt; and its sourcemap).&lt;/p&gt;

&lt;p&gt;Two things made me trust it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency&lt;/strong&gt;: two independent builds, byte-identical output. If your bundler embeds timestamps or random chunk names, a freshness check is off the table until that's fixed — so prove determinism first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean tree after&lt;/strong&gt;: rebuild, then &lt;code&gt;git status&lt;/code&gt; shows nothing. That was the acceptance criterion on the issue.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But notice what this fix is: a resync. It repairs &lt;em&gt;this&lt;/em&gt; drift and does nothing about the &lt;em&gt;next&lt;/em&gt; one. Any future change that shifts compiler output — a bundler upgrade, a tsconfig flag, another package.json line — re-creates the exact same silent lie.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual lesson: freshness must be a test
&lt;/h2&gt;

&lt;p&gt;If a build artifact lives in git, CI has to prove it's fresh. The check is about four lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci &amp;amp;&amp;amp; npm run build&lt;/span&gt;
  &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontend&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Committed artifacts must match a clean build&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;test -z "$(git status --porcelain)" || {&lt;/span&gt;
      &lt;span class="s"&gt;git status; git diff | head -50&lt;/span&gt;
      &lt;span class="s"&gt;echo "::error::Committed build output is stale. Run npm run build and commit."&lt;/span&gt;
      &lt;span class="s"&gt;exit 1&lt;/span&gt;
    &lt;span class="s"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Build from a clean checkout, then assert the tree is still clean. That's it. It would have turned this whole story into a red X on the PR that added &lt;code&gt;"type": "module"&lt;/code&gt;, instead of a discovery-by-accident later.&lt;/p&gt;

&lt;p&gt;Full honesty: as I write this, that gate is &lt;em&gt;not&lt;/em&gt; yet in nene-concierge's workflow — the resync is merged, the guard is the follow-up. I checked the workflow file before writing this paragraph, because claiming a fix you haven't shipped is exactly the kind of lie this article is about.&lt;/p&gt;

&lt;p&gt;And the cleaner alternative deserves saying out loud: if you &lt;em&gt;can&lt;/em&gt; avoid committing artifacts at all — build in CI, build at deploy — do that. An artifact that's never committed can't go stale in the repo. Committing output is sometimes the pragmatic choice when the deploy target is a plain host that just serves files; that convenience is fine, but it isn't free. The freshness check is the price.&lt;/p&gt;
&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A committed artifact is an unverified claim. CI must rebuild and &lt;code&gt;git status --porcelain&lt;/code&gt;-assert it, or the artifact will eventually lie.&lt;/li&gt;
&lt;li&gt;Toolchain config changes (&lt;code&gt;"type": "module"&lt;/code&gt;, bundler bumps) can change output while every semantic check stays green — drift needs no bug to happen.&lt;/li&gt;
&lt;li&gt;Prove build determinism (two builds, byte-identical) before you rely on any freshness comparison.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Primary sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;nene-concierge &lt;a href="https://github.com/hideyukiMORI/nene-concierge/issues/185" rel="noopener noreferrer"&gt;issue #185&lt;/a&gt; and &lt;a href="https://github.com/hideyukiMORI/nene-concierge/pull/186" rel="noopener noreferrer"&gt;PR #186&lt;/a&gt; (merge &lt;code&gt;b7eb1ea&lt;/code&gt;) — the drift and the resync&lt;/li&gt;
&lt;li&gt;nene-concierge &lt;a href="https://github.com/hideyukiMORI/nene-concierge/pull/183" rel="noopener noreferrer"&gt;PR #183&lt;/a&gt; — the out-of-scope note where the drift was first recorded&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Related: &lt;a href="https://dev.to/hideyukimori/click-a-link-get-a-throwaway-tenant-a-zero-signup-demo-for-a-self-hosted-app-1naj"&gt;Click a link, get a throwaway tenant: a zero-signup demo for a self-hosted app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Do you commit build output anywhere — and if so, what would actually fail today if it went stale? If the answer is "nothing," I'd check.&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>bugsmash</category>
      <category>devchallenge</category>
      <category>ci</category>
      <category>esbuild</category>
    </item>
    <item>
      <title>Half My Tests Failed and None Were Broken: My Shell Poisoned PHPUnit</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Wed, 29 Jul 2026 15:00:34 +0000</pubDate>
      <link>https://dev.to/hideyukimori/half-my-tests-failed-and-none-were-broken-my-shell-poisoned-phpunit-14ej</link>
      <guid>https://dev.to/hideyukimori/half-my-tests-failed-and-none-were-broken-my-shell-poisoned-phpunit-14ej</guid>
      <description>&lt;p&gt;For about a day I believed I had roughly 150 broken tests. I was wrong — not one of them was broken. Then I was wrong about the cause. Twice.&lt;/p&gt;

&lt;p&gt;Here's the walk from "our test suite is rotting" to "one stray line in my shell was winning a fight I didn't know it was in."&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;I ran the suite and about half of it was red. Every failure was the same shape: &lt;code&gt;403 org-access-denied&lt;/code&gt;. Tenant-scoped tests, denied across the board.&lt;/p&gt;

&lt;p&gt;It looked exactly like accumulated test debt — the kind of thing you file, sigh at, and schedule for "later." So that's what I did. I opened an issue: &lt;em&gt;~150 failing tests, org access denied.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Correction #1: the tests were fine, my machine wasn't
&lt;/h2&gt;

&lt;p&gt;Before scheduling the cleanup, I ran the suite in a clean environment — a fresh shell with none of my profile loaded.&lt;/p&gt;

&lt;p&gt;All green.&lt;/p&gt;

&lt;p&gt;Not "fewer failures." Zero. The tests were fine. The failures were a property of &lt;strong&gt;my&lt;/strong&gt; environment, not the code. Which is a worse feeling than a broken test, honestly, because it means the call is coming from inside the house.&lt;/p&gt;

&lt;p&gt;The mechanism turned out to be a PHPUnit detail I'd never had to think about: env vars you set in the config &lt;strong&gt;do not override an env var that's already set in your shell&lt;/strong&gt; — not unless you mark them &lt;code&gt;force="true"&lt;/code&gt;. My shell profile exported a variable that the test config also set, and by default, the shell won. So my tests were quietly running against the wrong context, and everything tenant-scoped got denied.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correction #2: I blamed the scary variable, not the guilty one
&lt;/h2&gt;

&lt;p&gt;Here's the part I'm least proud of and find most useful.&lt;/p&gt;

&lt;p&gt;My first theory for &lt;em&gt;which&lt;/em&gt; variable was a JWT signing secret. It's the dangerous-sounding one; of course a bad secret breaks auth. I wrote it up that way.&lt;/p&gt;

&lt;p&gt;It was wrong, and the code says why. A signing secret is &lt;strong&gt;symmetric&lt;/strong&gt;: if signing and verifying both read the same (wrong) value, the tokens are still internally valid. A polluted secret doesn't produce &lt;code&gt;403 org-access-denied&lt;/code&gt; — it produces valid tokens for a wrong-but-consistent world. It couldn't be the cause.&lt;/p&gt;

&lt;p&gt;The actual culprit was boring: a &lt;strong&gt;tenant-slug&lt;/strong&gt; variable. My shell had it set to one thing; the tests minted tokens for another. So the request would resolve one tenant from the env, carry a token for a different tenant, and the access check — correctly — denied it. Every tenant-scoped test, 403.&lt;/p&gt;

&lt;p&gt;I'd spent my first guess on the variable that &lt;em&gt;sounded&lt;/em&gt; like a security problem, when a plain identifier was doing all the damage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix was one attribute
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;force="true"&lt;/code&gt; on the env entries, so the test configuration wins over whatever the shell happens to export. That's it. The suite is now hermetic: it runs the same on my machine, on a colleague's, and in CI, regardless of anyone's shell profile.&lt;/p&gt;

&lt;p&gt;I proved it the honest way — by injecting the bad variable &lt;em&gt;on purpose&lt;/em&gt;. With the injection and no &lt;code&gt;force&lt;/code&gt;, the suite collapses (about 150 failures and a pile of errors). With &lt;code&gt;force&lt;/code&gt;, the same injection does nothing; everything stays green. The fix is verified against the attack, not against my memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I can't tell you
&lt;/h2&gt;

&lt;p&gt;I can't tell you the exact line that was in my shell profile that day. I don't have it pinned down, and I'm not going to invent it to make the story cleaner. What I have is a reproduction — inject the variable, watch it break — and that's what the fix is proven against.&lt;/p&gt;

&lt;p&gt;The count is fuzzy too. It was around 150 failures out of somewhere near 290 tests, and both numbers drifted day to day as the suite changed. "156 out of 291" would look precise and be a small lie. &lt;strong&gt;Half the suite red, zero tests actually broken&lt;/strong&gt; is the true and useful shape of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Make your tests hermetic.&lt;/strong&gt; Test configuration should beat the machine it runs on, unconditionally — &lt;code&gt;force&lt;/code&gt;, or the equivalent in your stack. A suite whose result depends on the developer's shell isn't testing your code; it's testing your dotfiles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When you're debugging, suspect the boring variable.&lt;/strong&gt; I lost real time pointing at a secret because it sounded dangerous, while a plain tenant id sat there quietly denying everything. The scary-looking cause is a great way to feel productive and stay wrong.&lt;/p&gt;

&lt;p&gt;What's the worst "it's the tests" you've had that turned out to be your own environment?&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;


</description>
      <category>bugsmash</category>
      <category>devchallenge</category>
      <category>php</category>
      <category>testing</category>
    </item>
    <item>
      <title>Pilot Then Fan Out: Killing Unknown Blockers in 2 Repos First</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Wed, 29 Jul 2026 14:59:58 +0000</pubDate>
      <link>https://dev.to/hideyukimori/pilot-then-fan-out-killing-unknown-blockers-in-2-repos-first-681</link>
      <guid>https://dev.to/hideyukimori/pilot-then-fan-out-killing-unknown-blockers-in-2-repos-first-681</guid>
      <description>&lt;p&gt;I maintain about a dozen small products that share one homemade framework. Last week I built a conformance linter — a little tool that scans each repo for architectural drift — and I was one command away from wiring it into all of them at once.&lt;/p&gt;

&lt;p&gt;I didn't. I ran it in two repos first.&lt;/p&gt;

&lt;p&gt;That two-repo dry run is the cheapest safety I've added all year, and it caught two things that would otherwise have gone off in every repo at the same time. One of them was the linter being wrong about my own code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why fan-out is tempting, and why it bites
&lt;/h2&gt;

&lt;p&gt;When you have a change that clearly belongs everywhere — a lint rule, a CI step, a dependency bump — the obvious move is to apply it everywhere. One sweep, done.&lt;/p&gt;

&lt;p&gt;The problem is that a shared tool doesn't fail in the tool. It fails in the &lt;strong&gt;consumers&lt;/strong&gt;, and consumers differ in ways the tool's own tests never exercise. So "all green in the framework repo" tells you almost nothing about what happens when a dozen repos actually pull it in.&lt;/p&gt;

&lt;p&gt;If you fan out first and discover the blocker second, you don't get one failure. You get the same failure a dozen times, concurrently, and now you're triaging a wall of red instead of a single problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pilot: two repos, chosen to disagree
&lt;/h2&gt;

&lt;p&gt;The trick isn't "test it somewhere first." It's picking the &lt;em&gt;right&lt;/em&gt; somewhere.&lt;/p&gt;

&lt;p&gt;I have two ways a repo consumes the shared framework:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one pulls it from a &lt;strong&gt;package registry&lt;/strong&gt; at a pinned version,&lt;/li&gt;
&lt;li&gt;the other uses a &lt;strong&gt;local path repo&lt;/strong&gt; — a symlink to a checkout that moves with development.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the pilot was one of each. Not two similar repos — two that maximize the difference along the axis most likely to break. If a change survives both consumption modes, it'll probably survive the rest. If it breaks, it breaks here, cheaply, where I'm paying attention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blocker 1: the local green was a lie
&lt;/h2&gt;

&lt;p&gt;The linter ran fine on my machine in every repo. Then I pushed it to the two pilots' CI and both went red immediately — a hard fatal, exit 255, before the linter did any work.&lt;/p&gt;

&lt;p&gt;The cause: the linter loaded the &lt;strong&gt;framework's own dependencies&lt;/strong&gt; to run. On my machine that resolved, because my local setup symlinks to a full checkout of the framework, dependencies and all. But a consumer doesn't have the framework's private &lt;code&gt;vendor/&lt;/code&gt; tree, and CI does a shallow clone without installing it. So the exact same command that was green locally was a guaranteed fatal in every consumer.&lt;/p&gt;

&lt;p&gt;"Works on my machine" had a specific, mechanical reason to lie: the symlink was hiding the fact that consumers don't get the tool's dependencies. The fix was to resolve against the &lt;em&gt;consumer's&lt;/em&gt; dependency tree instead of the tool's. Small change. But if I'd fanned out first, it would have been a dozen red pipelines at once, all with the same confusing exit 255.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blocker 2: the linter was wrong about my own fleet
&lt;/h2&gt;

&lt;p&gt;The second one was more humbling.&lt;/p&gt;

&lt;p&gt;One of the linter's rules flags hardcoded default secrets — a real thing you want to catch. But my correct, intended pattern for dev secrets routes them through a guarded resolver that refuses to use them in production. The rule saw the literal and screamed, not understanding that this literal was the safe, guarded case.&lt;/p&gt;

&lt;p&gt;So the linter's very first real run produced a &lt;strong&gt;false positive in roughly eleven of my products at once&lt;/strong&gt; — every repo that used the guarded pattern, which is to say every repo doing it &lt;em&gt;right&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That's the nightmare version of a fleet-wide rollout: a tool that's confidently, uniformly wrong. If those false alarms had landed in a dozen repos on the same afternoon, the rational response would have been to distrust the linter and turn it off — killing the whole effort. Instead, the pilot showed me the rule needed an exception for the guarded pattern before anyone else saw a single false alarm.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd have lost by skipping the pilot
&lt;/h2&gt;

&lt;p&gt;I want to be honest about this part: the "dozen simultaneous red pipelines" is a thing that &lt;strong&gt;didn't&lt;/strong&gt; happen, so there's no log of it. It's a projection, not an incident.&lt;/p&gt;

&lt;p&gt;But it's a well-supported projection. Both blockers came from structure shared by &lt;em&gt;every&lt;/em&gt; consumer — the dependency layout and the guarded pattern — so both would have fired everywhere. The pilot didn't get lucky with two repos; it exercised the two conditions that made the failures universal. That's the difference between "I tested it" and "I tested the thing that varies."&lt;/p&gt;

&lt;h2&gt;
  
  
  The method, generalized
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Unknown blockers live in the consumer, not the tool.&lt;/strong&gt; A shared tool's own green suite doesn't cover the environments it will run in. Assume the interesting failures are downstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blast radius = consumers × shared structure.&lt;/strong&gt; If a failure comes from something all consumers share, fanning out multiplies it. Sequence it instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick the pilot to span the variation axis.&lt;/strong&gt; Two repos that differ where breakage is likely beat ten repos that are all the same. Diversity, not count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leave a stop valve.&lt;/strong&gt; Every rollout step — human or agent — should be allowed to halt and escalate when a premise looks wrong, instead of pushing through.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There was a bonus, too. Once the linter was correct and did fan out, it immediately surfaced a genuine latent auth gap in one repo that everyone had walked past. The tool I built to prevent drift turned out to be a decent detector of the drift already there. But it only earned that trust because it didn't cry wolf a dozen times on day one.&lt;/p&gt;

&lt;p&gt;When you roll a change across many repos, what's your pilot — and how do you choose which repos go first?&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;


</description>
      <category>devops</category>
      <category>ai</category>
      <category>discuss</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Why My Install Zip Won't Upload: 89MB, and Half Is a PDF Library's Fonts</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Tue, 28 Jul 2026 14:28:00 +0000</pubDate>
      <link>https://dev.to/hideyukimori/why-my-install-zip-wont-upload-89mb-and-half-is-a-pdf-librarys-fonts-4po5</link>
      <guid>https://dev.to/hideyukimori/why-my-install-zip-wont-upload-89mb-and-half-is-a-pdf-librarys-fonts-4po5</guid>
      <description>&lt;p&gt;My install package is 89MB. A lot of Japanese shared hosts cap uploads somewhere between 2 and 50MB. So the simplest distribution path I offer — download a zip, upload it through a control panel — just doesn't work for a chunk of the people I built it for.&lt;/p&gt;

&lt;p&gt;Docker never told me this. A container doesn't care about a 90MB layer. A shared host's upload form does, and that's a constraint my whole dev setup was structurally blind to.&lt;/p&gt;

&lt;h2&gt;
  
  
  I blamed the wrong fonts
&lt;/h2&gt;

&lt;p&gt;My first instinct was the heading fonts. The app bundles IPAex for headings — a few megabytes of TTF — and that was the obvious suspect. I even had a build report that agreed with me and pointed the finger there.&lt;/p&gt;

&lt;p&gt;Then I actually unzipped the release and measured.&lt;/p&gt;

&lt;p&gt;IPAex wasn't the problem. The heading fonts were about 9MB compressed — real, but not the story. The story was the PDF library's bundled CJK fonts. Two files alone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Sun-ExtA.ttf&lt;/code&gt; — about 22MB on disk&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Sun-ExtB.ttf&lt;/code&gt; — about 17MB on disk&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those two, plus the rest of that library's font directory, were the single largest region of the package by a wide margin — roughly half the compressed zip. I'd been ready to optimize the 9MB I could see instead of the 40MB I couldn't, because I trusted a summary instead of the artifact.&lt;/p&gt;

&lt;p&gt;Lesson one, before anything else: &lt;strong&gt;when something is too big, measure where the bytes actually are.&lt;/strong&gt; Not where a report says. Where &lt;code&gt;unzip -v&lt;/code&gt; says.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the fonts can't just go
&lt;/h2&gt;

&lt;p&gt;The tempting move is "delete the giant fonts." I can't.&lt;/p&gt;

&lt;p&gt;The app generates PDF invoices, and it has to render Japanese — including the rare Extension-B kanji that show up in real personal and place names (the kind where someone's surname uses a character most fonts don't have). Drop those fonts and the invoice renders tofu — little empty boxes — for exactly the customers whose names are unusual. For a compliance document, that's not a cosmetic bug. It's wrong output.&lt;/p&gt;

&lt;p&gt;So the fonts are load-bearing. The size isn't waste; it's the cost of rendering a whole writing system correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design (which I have not shipped)
&lt;/h2&gt;

&lt;p&gt;Here's where I have to be careful, because it would be easy to write this as a finished story. It isn't one.&lt;/p&gt;

&lt;p&gt;What I've &lt;em&gt;designed&lt;/em&gt; — and written up as an architecture decision, not yet built — is a deferred font pack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ship only the minimal fonts the PDF engine needs to boot, plus the small heading set. That gets the base package under typical upload limits.&lt;/li&gt;
&lt;li&gt;Publish the big CJK fonts as a &lt;strong&gt;separate, versioned, signed artifact&lt;/strong&gt;. Verify it on arrival with a SHA-256 and a bundled public key; refuse to use it if the signature doesn't match.&lt;/li&gt;
&lt;li&gt;Fetch it two ways: server-side over HTTPS by default, with a manual upload fallback for hosts that block outbound connections.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail closed.&lt;/strong&gt; Before generating a PDF, check that the required fonts are present. If they're missing and can't be fetched, don't emit a tofu invoice — stop with a clear error.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the plan. The architecture decision is written down and currently marked &lt;em&gt;proposed&lt;/em&gt;; the implementation issue is open; there is no slimmed build yet.&lt;/p&gt;

&lt;p&gt;Which means I'm &lt;strong&gt;not&lt;/strong&gt; going to tell you it dropped to 20MB or 40MB or any number, because I haven't built it and measured it. I've seen enough of my own writing lately claim outcomes that hadn't happened. The honest version is: here's an 89MB problem, here's where the bytes are, and here's the design I'm going to try. If the slimmed number turns out interesting, that's a follow-up post with a real measurement in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway that generalizes
&lt;/h2&gt;

&lt;p&gt;Two things I'll actually carry forward:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distribution size is a first-class constraint, and Docker hides it.&lt;/strong&gt; If you ship anything people install by hand — a plugin, a zip, an appliance — the size limit lives in someone's upload form, and your container-based workflow will never surface it. Put it in CI or a checklist, because you won't feel it otherwise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Optimize the measured bytes, not the visible ones.&lt;/strong&gt; The fonts I could name were not the fonts that mattered. I nearly spent effort on the 9MB in front of me because I hadn't opened the box.&lt;/p&gt;

&lt;p&gt;What's unexpectedly blown up an artifact or image size for you — and when you finally measured, was it the thing you first blamed?&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;


</description>
      <category>php</category>
      <category>opensource</category>
      <category>architecture</category>
      <category>discuss</category>
    </item>
    <item>
      <title>The Codemod Left My Build Red: I Committed Its Output Untouched Anyway</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Tue, 28 Jul 2026 14:26:55 +0000</pubDate>
      <link>https://dev.to/hideyukimori/the-codemod-left-my-build-red-i-committed-its-output-untouched-anyway-3jag</link>
      <guid>https://dev.to/hideyukimori/the-codemod-left-my-build-red-i-committed-its-output-untouched-anyway-3jag</guid>
      <description>&lt;p&gt;This week I ran the same codemod, at the same version, across four of my repos. It left a different gap in each one — and in two repos, its raw output didn't even type-check.&lt;/p&gt;

&lt;p&gt;I committed that output as-is anyway. On purpose. Here's why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The codemod is &lt;code&gt;nene2-a1-hooks-to-model&lt;/code&gt;, from my published standards package (&lt;code&gt;@hideyukimori/nene2-standards@1.1.0&lt;/code&gt;). It does one boring thing: move React hooks from &lt;code&gt;features/*/hooks/&lt;/code&gt; into &lt;code&gt;model/&lt;/code&gt;, per the layering convention my frontends share, and rewrite the imports that point at them.&lt;/p&gt;

&lt;p&gt;I ran it, one-shot via &lt;code&gt;npx&lt;/code&gt;, in four repos: &lt;a href="https://github.com/hideyukiMORI/nene-field" rel="noopener noreferrer"&gt;nene-field&lt;/a&gt;, &lt;a href="https://github.com/hideyukiMORI/nene-suite" rel="noopener noreferrer"&gt;nene-suite&lt;/a&gt;, &lt;a href="https://github.com/hideyukiMORI/nene-profile" rel="noopener noreferrer"&gt;nene-profile&lt;/a&gt;, and &lt;a href="https://github.com/hideyukiMORI/nene-contact" rel="noopener noreferrer"&gt;nene-contact&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four repos, four different gaps
&lt;/h2&gt;

&lt;p&gt;Same tool. Same version. Four outcomes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;nene-field (&lt;a href="https://github.com/hideyukiMORI/nene-field/pull/102" rel="noopener noreferrer"&gt;PR #102&lt;/a&gt;)&lt;/strong&gt;: the clean case. 15 hooks moved across 10 slices, 15 files' imports rewritten, +18/−18 lines, every move detected as a 100%-similarity rename. Nothing to add by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;nene-suite (&lt;a href="https://github.com/hideyukiMORI/nene-suite/pull/389" rel="noopener noreferrer"&gt;PR #389&lt;/a&gt;)&lt;/strong&gt;: the moves were fine (18 files, all renames at 100% similarity), but the codemod &lt;em&gt;reprinted&lt;/em&gt; two files it touched with formatting that disagreed with the repo's Prettier config. A +2/−2 formatting fight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;nene-profile (&lt;a href="https://github.com/hideyukiMORI/nene-profile/pull/120" rel="noopener noreferrer"&gt;PR #120&lt;/a&gt;)&lt;/strong&gt;: &lt;code&gt;hooks/&lt;/code&gt; contained a non-hook file — a shared zod schema. The codemod moved the two hooks that imported it, left the schema behind, and didn't rewrite their relative &lt;code&gt;./preset-schema&lt;/code&gt; imports. Raw output: &lt;code&gt;tsc -b&lt;/code&gt; red, TS2307 × 2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;nene-contact (&lt;a href="https://github.com/hideyukiMORI/nene-contact/pull/401" rel="noopener noreferrer"&gt;PR #401&lt;/a&gt;)&lt;/strong&gt;: 13 hooks and a co-located test moved — and &lt;em&gt;zero&lt;/em&gt; imports rewritten. 24 importer references now pointed at files that weren't there. Raw output alone: type errors, by design of the PR.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So a mechanical migration needed a human (well, a human-supervised agent) to finish it in three of four repos. That's normal. The question is what you do about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The temptation is to patch silently
&lt;/h2&gt;

&lt;p&gt;The easy move: fix the imports, run Prettier, squash, open a green PR titled "applied the codemod."&lt;/p&gt;

&lt;p&gt;Now the diff is a lie. It claims a machine did all of it. Your reviewer — future you, a colleague, or an AI agent reading history to learn "how migrations are done here" — can no longer tell which lines a deterministic tool produced and which lines a tired human typed at 1 a.m.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Commit 1 is what the machine did. Commit 2 is what it couldn't. Never mix them.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern: commit-separated gaps, with rename proof
&lt;/h2&gt;

&lt;p&gt;Every one of these PRs follows the same discipline:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Commit 1: the codemod's raw output, byte-for-byte, zero edits.&lt;/strong&gt; Even if it's red. In nene-contact, commit &lt;code&gt;3b71717&lt;/code&gt; is intentionally broken on its own — the PR body says so out loud, so nobody bisecting later is surprised.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Commit 2 (and 3): mechanical completion only, one kind of mechanical thing per commit.&lt;/strong&gt; In nene-profile that meant two commits: &lt;code&gt;b58aaa9&lt;/code&gt; is a pure &lt;code&gt;git mv&lt;/code&gt; of the leftover schema file, and &lt;code&gt;e0e0e40&lt;/code&gt; is a single import-path line. In nene-suite, commit &lt;code&gt;8044f11&lt;/code&gt; is &lt;em&gt;only&lt;/em&gt; &lt;code&gt;prettier --write&lt;/code&gt; restoring the repo's formatting on the two reprinted files. In nene-contact, commit &lt;code&gt;661f2b0&lt;/code&gt; fixes the 24 importer references and re-runs the formatter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And you paste the proof in the PR body.&lt;/strong&gt; For the &lt;code&gt;git mv&lt;/code&gt; commit, that's rename detection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;git show b58aaa9 &lt;span class="nt"&gt;--find-renames&lt;/span&gt; &lt;span class="nt"&gt;--name-status&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;span class="go"&gt;R100    frontend/src/features/mapping-presets/hooks/preset-schema.ts    frontend/src/features/mapping-presets/model/preset-schema.ts
 1 file changed, 0 insertions(+), 0 deletions(-)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;R100&lt;/code&gt; — 100% similarity, zero insertions, zero deletions. The reviewer doesn't have to trust my adjective "mechanical." Git certifies it.&lt;/p&gt;

&lt;p&gt;For an import fix, the proof is exhaustiveness: in nene-profile I noted that &lt;code&gt;grep -rn "hooks/" frontend/src&lt;/code&gt; returned exactly the one line commit 3 changes. The completion commit isn't "some cleanup" — it's provably &lt;em&gt;the whole gap and nothing else&lt;/em&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  What this buys you
&lt;/h2&gt;

&lt;p&gt;Review time collapses to where it belongs. Commit 1 is audited as "do I trust this tool at this version" — once, cheaply, across every repo it runs in. Commits 2–3 are audited line-by-line, but they're tiny (1 line; +2/−2; 24 one-pattern references) and each does one nameable operation.&lt;/p&gt;

&lt;p&gt;And the gap itself becomes a first-class artifact. Because the profile run isolated exactly what the codemod failed to do, filing it upstream was trivial: &lt;a href="https://github.com/hideyukiMORI/nene2-fleet-tooling/issues/83" rel="noopener noreferrer"&gt;nene2-fleet-tooling#83&lt;/a&gt; documents the leftover-file and unrewritten-import behavior, with the TS2307 reproduction pasted in. That issue is still open — the tool isn't fixed. But the workaround is a documented, repeatable pattern instead of four divergent hand-patches, and when the fix lands, commit 2's exact shape tells us what should disappear from future runs.&lt;/p&gt;

&lt;p&gt;Silent hand-patching produces none of this. It just makes the tool look better than it is.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where I'd skip all this
&lt;/h2&gt;

&lt;p&gt;Honestly: a solo throwaway repo, a one-file move, no reviewers, no agents reading history? Just fix it and squash. The ceremony has a cost.&lt;/p&gt;

&lt;p&gt;The discipline earns its keep the moment anyone — human or model — needs to answer "what did the machine actually do?" from the record. In my case that's every migration, because the record &lt;em&gt;is&lt;/em&gt; what my AI agents learn the house style from.&lt;/p&gt;
&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A codemod PR should let a reviewer audit "machine work" and "human completion" separately — that's a commit boundary, not a PR description.&lt;/li&gt;
&lt;li&gt;Prove the mechanical-ness: &lt;code&gt;git show --find-renames&lt;/code&gt; for moves (&lt;code&gt;R100&lt;/code&gt;), a grep count for import fixes, "formatter only" for reprints.&lt;/li&gt;
&lt;li&gt;When the tool leaves a gap, file it upstream with the isolated diff — the separation gives you the reproduction for free.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Primary sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;nene-profile &lt;a href="https://github.com/hideyukiMORI/nene-profile/pull/120" rel="noopener noreferrer"&gt;PR #120&lt;/a&gt; (commits &lt;code&gt;9c28709&lt;/code&gt; / &lt;code&gt;b58aaa9&lt;/code&gt; / &lt;code&gt;e0e0e40&lt;/code&gt;, rename proof in body) and &lt;a href="https://github.com/hideyukiMORI/nene-profile/issues/119" rel="noopener noreferrer"&gt;issue #119&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;nene-suite &lt;a href="https://github.com/hideyukiMORI/nene-suite/pull/389" rel="noopener noreferrer"&gt;PR #389&lt;/a&gt; (commits &lt;code&gt;e28f0b0&lt;/code&gt; / &lt;code&gt;8044f11&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;nene-contact &lt;a href="https://github.com/hideyukiMORI/nene-contact/pull/401" rel="noopener noreferrer"&gt;PR #401&lt;/a&gt; (merge &lt;code&gt;59f283e&lt;/code&gt;, commits &lt;code&gt;3b71717&lt;/code&gt; / &lt;code&gt;661f2b0&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;nene-field &lt;a href="https://github.com/hideyukiMORI/nene-field/pull/102" rel="noopener noreferrer"&gt;PR #102&lt;/a&gt; (move-only, +18/−18)&lt;/li&gt;
&lt;li&gt;Upstream gap: &lt;a href="https://github.com/hideyukiMORI/nene2-fleet-tooling/issues/83" rel="noopener noreferrer"&gt;nene2-fleet-tooling#83&lt;/a&gt; (open)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Related: &lt;a href="https://dev.to/hideyukimori/i-built-a-tiny-php-framework-for-ai-readable-business-apis-48eo"&gt;I built a tiny PHP framework for AI-readable business APIs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;How do you make codemod PRs reviewable — squash and trust the tool, or separate and prove it? I'd genuinely like to hear where you draw the line.&lt;/p&gt;

&lt;p&gt;── Hideyuki Mori (Ayane International) 🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__3995621"&gt;
    &lt;a href="/hideyukimori" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995621%2Fd22faba8-a5e8-4ded-a514-1558d81cc5db.jpg" alt="hideyukimori image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/hideyukimori"&gt;HideyukiMORI&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/hideyukimori"&gt;Building API-first PHP tools for self-hosted business workflows. Creator of NENE2 and the NeNe OSS series.&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>programming</category>
      <category>git</category>
      <category>refactoring</category>
      <category>codequality</category>
    </item>
  </channel>
</rss>
