<?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>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>
    <item>
      <title>An Honesty Guard for AI Subagents: The Fabrication It Caught Was Mine</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Tue, 28 Jul 2026 14:26:48 +0000</pubDate>
      <link>https://dev.to/hideyukimori/an-honesty-guard-for-ai-subagents-the-fabrication-it-caught-was-mine-2nc1</link>
      <guid>https://dev.to/hideyukimori/an-honesty-guard-for-ai-subagents-the-fabrication-it-caught-was-mine-2nc1</guid>
      <description>&lt;p&gt;I run a small fleet of open source business tools, mostly solo, with AI subagents doing a lot of the grunt work: reading across repos, drafting docs, cross-checking claims against the actual code.&lt;/p&gt;

&lt;p&gt;For a while, my problem with that setup wasn't wrong code. It was confident prose.&lt;/p&gt;

&lt;p&gt;An agent would write "this was standardized in the framework" — and it sounded right, and the framework did have something &lt;em&gt;like&lt;/em&gt; it, and if I hadn't gone and looked, I would have shipped a sentence that was simply not true. Not malicious. Just the model smoothing a gap with something plausible.&lt;/p&gt;

&lt;p&gt;So I added one paragraph to the instructions every subagent gets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The paragraph
&lt;/h2&gt;

&lt;p&gt;It's not clever. It's a standing rule, in every task:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cite it or drop it.&lt;/strong&gt; Every claim comes from a named source — a file, a PR, a line. If you can't source it, don't assert it; skip it and say you skipped it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don't link to anchors you haven't confirmed exist.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report what you couldn't do.&lt;/strong&gt; A skipped step is information, not a failure to hide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't touch unrelated changes.&lt;/strong&gt; Check &lt;code&gt;git status&lt;/code&gt; before you start.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's it. No framework, no eval harness. A short list that turns "sound confident" into "show the receipt."&lt;/p&gt;

&lt;h2&gt;
  
  
  What the agents did with it
&lt;/h2&gt;

&lt;p&gt;The interesting part is that the agents started policing &lt;em&gt;themselves&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;In one run, an agent was documenting a feature and was about to reuse a phrasing from a sibling repo — "synchronization was made standard here." It checked, found the sibling had no such thing, and &lt;strong&gt;left the claim out on its own&lt;/strong&gt;, with a note that it couldn't source it.&lt;/p&gt;

&lt;p&gt;In another, it wanted to deep-link to a named section that didn't exist. Instead of inventing the anchor, it &lt;strong&gt;fell back to a file-level link&lt;/strong&gt; and flagged it.&lt;/p&gt;

&lt;p&gt;And the one I keep thinking about: an agent was handed an instruction that said a piece of work was "Phase 2, in progress." It went to check the current state, found that phase had &lt;strong&gt;already shipped days earlier&lt;/strong&gt;, and corrected the instruction I gave it.&lt;/p&gt;

&lt;p&gt;That last one matters. The guard wasn't just stopping the &lt;em&gt;agent&lt;/em&gt; from making things up. It was catching &lt;strong&gt;my&lt;/strong&gt; stale assumptions, because "verify against the record" cuts both ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then it caught me for real
&lt;/h2&gt;

&lt;p&gt;Here's the part that earned the whole thing.&lt;/p&gt;

&lt;p&gt;I had an agent draft a technical article — a failure story about a bug on shared hosting. Every code snippet was correct. Every default value, every config detail, checked out against the repo. It was, technically, flawless.&lt;/p&gt;

&lt;p&gt;The framing was fiction.&lt;/p&gt;

&lt;p&gt;The article told it as a lived production incident: &lt;em&gt;"I shipped the in-memory version, tests were green, and in production it never fired."&lt;/em&gt; Compelling. Also something that never happened. The git history was unambiguous — that component had been file-backed from its very first commit. The in-memory version was never deployed. The "production incident" I was about to narrate in the first person had no trace in the code or the records.&lt;/p&gt;

&lt;p&gt;A guarded review caught it before it went out. I run new drafts through an adversarial pass — a few reviewer personas plus a hard cross-check against the actual repo and my own work logs — and the verdict was unanimous: &lt;strong&gt;the technical content is true, the story is invented.&lt;/strong&gt; The fix was to demote the fake incident to what actually happened — a design decision ("shared hosting can't do in-memory counters, so it was file-backed from day one") — and keep every correct code block.&lt;/p&gt;

&lt;p&gt;Then I checked the sibling articles in the same series. Two more had the same shape: real bugs, but dressed up as production incidents that were actually caught in a staging rehearsal. One was clean — a genuine outage, logged and dated. So I fixed the two, left the real one alone, and moved on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson isn't "trust the AI more"
&lt;/h2&gt;

&lt;p&gt;It's the opposite. The guard works &lt;em&gt;because&lt;/em&gt; it doesn't rely on trust. It makes "cite it or drop it" the path of least resistance, so the cheap move is also the honest one.&lt;/p&gt;

&lt;p&gt;And it reframed what I was even guarding against. I started out worried about agents inventing APIs. The failure mode that actually threatened me was subtler: &lt;strong&gt;prose that is factually correct at the code level and fictional at the story level.&lt;/strong&gt; A test suite can't catch that. A linter can't catch that. Only checking the &lt;em&gt;narrative&lt;/em&gt; against the record does — the same move the guard already required for facts, applied one level up.&lt;/p&gt;

&lt;p&gt;So the honesty guard grew a second clause, for anything written in the first person: &lt;strong&gt;the story has to be verifiable too, not just the code.&lt;/strong&gt; "This broke in production" is a factual claim. It needs a log, the same way a benchmark needs a number.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I can and can't claim
&lt;/h2&gt;

&lt;p&gt;I'm not going to tell you fabrications went to zero forever — I have no counter for that, and the honest evidence cuts against the clean version anyway. What I can say is narrower and, I think, more useful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In the runs where the guard was in the prompt, the agents stopped filling gaps with plausible-but-unsourced claims, and started reporting the gaps instead.&lt;/li&gt;
&lt;li&gt;When a fabrication &lt;em&gt;did&lt;/em&gt; reach a draft, a guarded review — personas plus a record cross-check — caught it before publish.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two different mechanisms, same principle: &lt;strong&gt;make the receipt mandatory, at the fact level and the story level.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;None of this is a framework I'm selling. It's four sentences in a prompt and a habit of checking the narrative, not just the numbers.&lt;/p&gt;

&lt;p&gt;What's in your AI agents' instructions to stop them from making things up — and does it cover the &lt;em&gt;story&lt;/em&gt;, or only the facts?&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>opensource</category>
    </item>
    <item>
      <title>CI Paid for Itself on the First Run: 4 Repos, Real Bugs</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Mon, 27 Jul 2026 12:13:44 +0000</pubDate>
      <link>https://dev.to/hideyukimori/ci-paid-for-itself-on-the-first-run-4-repos-real-bugs-2m61</link>
      <guid>https://dev.to/hideyukimori/ci-paid-for-itself-on-the-first-run-4-repos-real-bugs-2m61</guid>
      <description>&lt;p&gt;I added CI to four small apps that had been living without it. The first run went red before it executed a single test — and not because my code was broken. Because a file I had never actually tested was.&lt;/p&gt;

&lt;p&gt;Here's what a first CI run catches that your machine has been quietly hiding.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline was nothing special
&lt;/h2&gt;

&lt;p&gt;Same shape for all four: fresh checkout, &lt;code&gt;cp .env.example .env&lt;/code&gt;, install dependencies, run the checks, run a dependency audit. The kind of thing you'd copy off a template in ten minutes.&lt;/p&gt;

&lt;p&gt;The value wasn't the sophistication. It was the word &lt;strong&gt;fresh&lt;/strong&gt;. My laptop has a real &lt;code&gt;.env&lt;/code&gt;, an installed &lt;code&gt;node_modules&lt;/code&gt;, a warm cache, and years of accumulated state. CI has none of that. It starts from what's actually committed — which turns out to be a very different thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug type 1: &lt;code&gt;.env.example&lt;/code&gt; broke a clean boot
&lt;/h2&gt;

&lt;p&gt;Two of the four repos couldn't boot from their own example environment file.&lt;/p&gt;

&lt;p&gt;The values had unquoted spaces — think &lt;code&gt;APP_NAME=My App&lt;/code&gt; — and the dotenv parser rejects that outright. On my machine it never mattered, because I have a real &lt;code&gt;.env&lt;/code&gt; from months ago and never re-copy the example. CI copies the example on every run, so the first thing it did was fail to parse it, which failed to boot the app, which took a chunk of the test suite down with it before any real test ran.&lt;/p&gt;

&lt;p&gt;That's the whole "works on my machine" trap in one line: the example env is a file you ship to every new contributor and every deploy, and it's the one file your own environment guarantees you'll never exercise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug type 2: a required variable was in the example, but blank
&lt;/h2&gt;

&lt;p&gt;Related, in one of those same repos: a required encryption key was listed in &lt;code&gt;.env.example&lt;/code&gt; but deliberately left blank — it's a secret you don't commit — so a fresh checkout had no usable value for it. The app booted fine for me — my environment had a real key — and fell over in the clean room, where there was nothing to fill it in with.&lt;/p&gt;

&lt;p&gt;CI made the missing value explicit the only way that ever really happens: by being a machine that starts from nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug type 3: the audit gate caught real, known vulnerabilities
&lt;/h2&gt;

&lt;p&gt;The pipelines ran a dependency audit and failed on high-severity findings. This wasn't uniform, and I want to be precise — including about the limits of what I can still reproduce:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One repo's audit flagged a &lt;strong&gt;high&lt;/strong&gt; in its build-tool chain — a &lt;code&gt;vite&lt;/code&gt; advisory of the kind &lt;code&gt;npm audit fix&lt;/code&gt; clears. This is the one I can point you straight at: it's documented in &lt;a href="https://github.com/hideyukiMORI/nene-deal/pull/60" rel="noopener noreferrer"&gt;that repo's CI-setup PR&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;My notes from the same week flagged advisories in a second repo's test-runner / &lt;code&gt;vite&lt;/code&gt; chain, but the pinned lockfile I can re-audit today comes back clean. So I'll only claim what the PR trail still shows, and leave that one as "flagged then, not reproducible now."&lt;/li&gt;
&lt;li&gt;The other repos turned up &lt;strong&gt;moderate/low&lt;/strong&gt; at most.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the honest version is smaller than "every repo was hiding a critical": at least one repo had a real high-severity advisory that the first gated &lt;code&gt;npm audit&lt;/code&gt; surfaced — sitting there silently because nobody had ever run &lt;code&gt;npm audit&lt;/code&gt; in a gate that could fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern, not the scoreboard
&lt;/h2&gt;

&lt;p&gt;None of these were logic bugs. Every one lived in the gap between "runs on my machine" and "runs from a clean checkout": a broken example env, an undocumented required variable, a stale dependency nobody re-audited.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My machine had exactly the state needed to hide all three.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'll be honest about the counting, too. I'm not going to hand you a tidy "13 products now have CI" number — the exact figure depends on what you call a product, and it was a self-tally at the end of that week, not a measurement. What I can stand behind is smaller and more useful: &lt;strong&gt;four repos that had no CI, one afternoon of adding the most boring possible pipeline, and real defects on the first run of each.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's the argument for CI I actually believe. Not "it catches regressions someday" — that's the slow, abstract payoff. The immediate one is that CI is the only environment that starts from nothing, and starting from nothing is where your &lt;code&gt;.env.example&lt;/code&gt;, your undocumented variables, and your un-audited dependencies all come due at once.&lt;/p&gt;

&lt;p&gt;The four CI-setup PRs, if you want the workflow files and the audit output: &lt;a href="https://github.com/hideyukiMORI/nene-field/pull/86" rel="noopener noreferrer"&gt;nene-field&lt;/a&gt;, &lt;a href="https://github.com/hideyukiMORI/nene-contact/pull/357" rel="noopener noreferrer"&gt;nene-contact&lt;/a&gt;, &lt;a href="https://github.com/hideyukiMORI/nene-deal/pull/60" rel="noopener noreferrer"&gt;nene-deal&lt;/a&gt;, &lt;a href="https://github.com/hideyukiMORI/nene-vault/pull/115" rel="noopener noreferrer"&gt;nene-vault&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It's the same bet I made on my documentation, for the same reason: &lt;a href="https://dev.to/hideyukimori/261-docs-6-languages-one-maintainer-frontmatter-is-the-source-of-truth-2c8f"&gt;261 docs in 6 languages with one maintainer&lt;/a&gt;, where the index is generated and CI goes red when it drifts. Different problem, identical logic — a machine that checks every single time doesn't have a bad week, and I do.&lt;/p&gt;

&lt;p&gt;If you've got a repo running without CI right now, the cheapest pipeline you can write will probably pay for itself on run #1.&lt;/p&gt;

&lt;p&gt;What did your first CI run catch that your machine had been hiding?&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>cicd</category>
      <category>testing</category>
      <category>ai</category>
      <category>discuss</category>
    </item>
    <item>
      <title>261 docs, 6 languages, one maintainer: frontmatter is the source of truth</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Fri, 24 Jul 2026 16:17:54 +0000</pubDate>
      <link>https://dev.to/hideyukimori/261-docs-6-languages-one-maintainer-frontmatter-is-the-source-of-truth-2c8f</link>
      <guid>https://dev.to/hideyukimori/261-docs-6-languages-one-maintainer-frontmatter-is-the-source-of-truth-2c8f</guid>
      <description>&lt;p&gt;If you maintain a docs folder that keeps drifting — missing index entries, stale translations, categories that no longer match — this is for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/hideyukiMORI/NENE2" rel="noopener noreferrer"&gt;NENE2&lt;/a&gt;&lt;/strong&gt; is my small PHP framework for building AI-readable business APIs.&lt;/p&gt;

&lt;p&gt;Repository:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/hideyukiMORI/NENE2" rel="noopener noreferrer"&gt;https://github.com/hideyukiMORI/NENE2&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Over time it grew a large set of how-to guides. Today the English directory holds &lt;strong&gt;261 guides&lt;/strong&gt;, and every one of them is mirrored into &lt;strong&gt;five more languages&lt;/strong&gt;: Japanese, French, Chinese, German, and Brazilian Portuguese.&lt;/p&gt;

&lt;p&gt;That is 261 guides across 6 locales — 1,566 files in all.&lt;/p&gt;

&lt;p&gt;Maintained by one person.&lt;/p&gt;

&lt;p&gt;The only way this stays sane is to stop treating docs as prose and start treating them as data.&lt;/p&gt;

&lt;p&gt;This article is about the machinery that makes that possible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you'll learn:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How to make YAML frontmatter the single source of truth and regenerate every index from disk&lt;/li&gt;
&lt;li&gt;How to keep six-locale translations in sync without duplicating the taxonomy&lt;/li&gt;
&lt;li&gt;How to turn index drift into a red CI check instead of a manual chore&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The problem with hand-maintained indexes
&lt;/h2&gt;

&lt;p&gt;If you have ever kept a docs folder, you know how index pages rot.&lt;/p&gt;

&lt;p&gt;You add a guide. You forget to add it to the index. Someone else renames a file. The category list drifts. A translation goes missing and nobody notices for months.&lt;/p&gt;

&lt;p&gt;None of this is dramatic. It is just slow decay.&lt;/p&gt;

&lt;p&gt;At six guides it does not matter. At 261 × 6 it is fatal.&lt;/p&gt;

&lt;p&gt;So the rule I settled on is simple:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No index is ever edited by hand. Every index is regenerated from the files on disk.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Frontmatter is the source of truth
&lt;/h2&gt;

&lt;p&gt;Each English guide opens with a YAML frontmatter block:&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="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;How-to:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;A/B&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Testing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Framework"&lt;/span&gt;
&lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;product&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;ab-testing&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;experimentation&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;state-machine&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;analytics&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;difficulty&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;advanced&lt;/span&gt;
&lt;span class="na"&gt;related&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;feature-flags&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;feature-flag-api&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That block is the source of truth for everything the index knows about the guide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;title&lt;/code&gt; — display name&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;category&lt;/code&gt; — one of seven fixed buckets&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tags&lt;/code&gt; — lowercase kebab-case labels&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;difficulty&lt;/code&gt; — beginner, intermediate, or advanced&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;related&lt;/code&gt; — slugs of sibling guides&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ft&lt;/code&gt; — an optional field-trial reference&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The seven categories are fixed in code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;getting-started
auth
security
database
api-design
infrastructure
product
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The guide body follows the frontmatter. The frontmatter is not decoration — it is structured data that a generator reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  The generator
&lt;/h2&gt;

&lt;p&gt;A single script, &lt;code&gt;tools/build-howto-index.php&lt;/code&gt;, rebuilds every index. It is wired to &lt;code&gt;composer howto:index&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For English, it walks every guide, parses the frontmatter, and groups guides into the seven categories. It writes two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;"Browse by category"&lt;/strong&gt; table injected into &lt;code&gt;docs/howto/README.md&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a separate &lt;strong&gt;&lt;code&gt;by-tag.md&lt;/code&gt;&lt;/strong&gt; page grouping every guide under each tag&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The category loop is the whole idea in a few 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="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;guideFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'.md'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$fm&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFrontmatter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$file&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;$fm&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="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$fm&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'category'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$missing&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$byCategory&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$fm&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'category'&lt;/span&gt;&lt;span class="p"&gt;]][]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'slug'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'title'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$fm&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'difficulty'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$fm&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'difficulty'&lt;/span&gt;&lt;span class="p"&gt;]&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="s1"&gt;'tags'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$fm&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'tags'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[],&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;Guides that have no frontmatter, or no &lt;code&gt;category&lt;/code&gt;, are not silently dropped. They are collected into &lt;code&gt;$missing&lt;/code&gt; and printed as a warning. Skipping something is a visible event, not a quiet gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Translations carry no frontmatter
&lt;/h2&gt;

&lt;p&gt;Here is a decision that surprises people: the translated guides have &lt;strong&gt;no frontmatter at all&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A Japanese guide starts straight with its H1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# ハウツー: A/B テストフレームワーク&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no &lt;code&gt;category:&lt;/code&gt; line in the translation. Duplicating the taxonomy into six languages would mean six chances to drift out of sync.&lt;/p&gt;

&lt;p&gt;So the generator treats locales differently. For every non-English locale it builds a &lt;strong&gt;flat, alphabetical index&lt;/strong&gt; using the first H1 it finds in each file:&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;function&lt;/span&gt; &lt;span class="n"&gt;howtoTitle&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;$path&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="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;preg_split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/\r?\n/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$line&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="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/^#\s+(.+?)\s*$/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&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="nv"&gt;$m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&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="nb"&gt;basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'.md'&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;The taxonomy lives in exactly one place — the English frontmatter. Translations only need to exist and have a heading. That keeps the source of truth singular.&lt;/p&gt;

&lt;h2&gt;
  
  
  Injecting without clobbering
&lt;/h2&gt;

&lt;p&gt;Each README has a hand-written part I want to keep: a curated "I want to…" finder table.&lt;/p&gt;

&lt;p&gt;So the generator does not overwrite the whole file. It only replaces the content between two markers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- AUTO-INDEX:START (generated by `composer howto:index` — do not edit by hand) --&amp;gt;&lt;/span&gt;
...regenerated content...
&lt;span class="c"&gt;&amp;lt;!-- AUTO-INDEX:END --&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything before the start marker and after the end marker is preserved verbatim. The injection is idempotent: run it twice and you get byte-identical output. That property is the reason the whole thing can be enforced in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drift is a CI failure
&lt;/h2&gt;

&lt;p&gt;The generator is only useful if a stale index cannot be merged. That guarantee lives in the CI pipeline:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Verify howto index is up to date&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;composer howto:index&lt;/span&gt;
    &lt;span class="s"&gt;git diff --exit-code docs/howto/README.md 'docs/*/howto/README.md' docs/howto/by-tag.md&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;Validate howto frontmatter&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;composer howto:frontmatter -- --require-all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first step regenerates every index and then runs &lt;code&gt;git diff --exit-code&lt;/code&gt;. If regenerating produced any change, the working tree is dirty, the diff is non-empty, and the build fails.&lt;/p&gt;

&lt;p&gt;In other words: if you added a guide but did not regenerate the index, CI notices — because it regenerates the index for you and sees that your committed version does not match.&lt;/p&gt;

&lt;p&gt;The second step is a separate validator, &lt;code&gt;tools/validate-howto-frontmatter.php&lt;/code&gt;. It checks every English guide against the schema: required fields present, &lt;code&gt;category&lt;/code&gt; and &lt;code&gt;difficulty&lt;/code&gt; from the allowed sets, tags lowercase kebab-case, &lt;code&gt;related&lt;/code&gt; pointing at guides that actually exist. The &lt;code&gt;--require-all&lt;/code&gt; flag means an English guide with &lt;strong&gt;no&lt;/strong&gt; frontmatter also fails the build.&lt;/p&gt;

&lt;p&gt;Between the two steps, drift cannot accumulate silently. A missing index entry, a typo in a category, a broken &lt;code&gt;related&lt;/code&gt; link, or a guide with no metadata all turn into a red check.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it got here, in phases
&lt;/h2&gt;

&lt;p&gt;This was not built in one shot. The git history shows a deliberate rollout:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Phase A&lt;/strong&gt; — add auto-generated indexes at all, from the H1 heading.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase B1&lt;/strong&gt; — settle the frontmatter schema and prove it on five guides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase B2&lt;/strong&gt; — annotate every English guide with frontmatter (256 at the time).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase B3&lt;/strong&gt; — regenerate the indexes &lt;em&gt;from&lt;/em&gt; the frontmatter and make it permanently required in CI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last step is the one that flipped frontmatter from "nice to have" to "the build fails without it." The guide count has grown from 256 to 261 since then, and the machinery absorbed the growth without any manual index edits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The translation workload
&lt;/h2&gt;

&lt;p&gt;Adding a guide is not one file. It is one English guide plus five translations, and the index must stay complete in all six locales.&lt;/p&gt;

&lt;p&gt;In practice the translations land in batches — the commit history has entries like "translate N untranslated guides into all five locales." This is an AI-assisted solo project, so drafting six language variants of a guide is exactly the kind of work that gets delegated to a model and then reviewed.&lt;/p&gt;

&lt;p&gt;But the honest part is this: the &lt;em&gt;quality&lt;/em&gt; gate is not "an AI wrote it." The gate is the same mechanical check for everyone. A translation either exists and produces an index row, or the locale index differs from what CI regenerates and the build goes red. The generator does not care who or what produced the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell my past self
&lt;/h2&gt;

&lt;p&gt;Three things held up at scale:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One source of truth.&lt;/strong&gt; The taxonomy lives in English frontmatter and nowhere else. Translations only mirror content, never metadata.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate, never hand-edit.&lt;/strong&gt; Every index is a build artifact. The only hand-written parts sit outside the auto-index markers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make drift a failure, not a chore.&lt;/strong&gt; Regenerate in CI and diff against the commit. A stale index is a red build, not a thing you hope someone remembers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is clever. It is boring on purpose.&lt;/p&gt;

&lt;p&gt;Boring is what lets one person keep 261 guides alive in six languages without the docs quietly rotting underneath them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;NENE2: &lt;a href="https://github.com/hideyukiMORI/NENE2" rel="noopener noreferrer"&gt;https://github.com/hideyukiMORI/NENE2&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;── By Hideyuki Mori (Ayane International) — I build back-office systems for small businesses at published, fixed prices.&lt;br&gt;
🔗 &lt;a href="https://hideyuki-mori.com/en/?ref=devto" rel="noopener noreferrer"&gt;hideyuki-mori.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>documentation</category>
      <category>php</category>
    </item>
    <item>
      <title>A production fix trapped in a month-old feature PR: extract it and ship it alone</title>
      <dc:creator>HideyukiMORI</dc:creator>
      <pubDate>Mon, 20 Jul 2026 15:14:37 +0000</pubDate>
      <link>https://dev.to/hideyukimori/a-production-fix-trapped-in-a-month-old-feature-pr-extract-it-and-ship-it-alone-2kco</link>
      <guid>https://dev.to/hideyukimori/a-production-fix-trapped-in-a-month-old-feature-pr-extract-it-and-ship-it-alone-2kco</guid>
      <description>&lt;p&gt;If you've ever fixed a real bug inside a feature branch and then watched the fix wait weeks for the feature to merge, this is for you.&lt;/p&gt;

&lt;p&gt;There is a failure mode that looks responsible but isn't: you find a real bug while building a feature, you fix it &lt;em&gt;inside&lt;/em&gt; the feature branch, and then the whole thing waits for the feature to be reviewed and merged.&lt;/p&gt;

&lt;p&gt;The bug is done. The fix works. But it can't ship, because it's now a passenger on a much bigger PR.&lt;/p&gt;

&lt;p&gt;This happened in one of my open source projects, &lt;a href="https://github.com/hideyukiMORI/nene-vault" rel="noopener noreferrer"&gt;NeNe Vault&lt;/a&gt;, a self-hosted archive for received business documents. Here is what happened, the call I made, and the small mechanics of undoing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you'll learn:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How to spot when a bug fix has become a hostage to a feature's review timeline&lt;/li&gt;
&lt;li&gt;How to extract just the bug-fix subset and land it on &lt;code&gt;main&lt;/code&gt; independently&lt;/li&gt;
&lt;li&gt;How to keep the git history honest about where an extracted fix came from&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;A feature PR was open to add an &lt;strong&gt;integrity-verified inline preview&lt;/strong&gt; to the document detail page: render stored images and PDFs in the browser, re-compute their SHA-256, and show a "verified" badge only when the hash matches.&lt;/p&gt;

&lt;p&gt;It was a substantial change: a new preview component with tests, a data-fetching hook, object-URL lifecycle handling, new locale keys for two languages, and a scope-contract entry. Exactly the kind of surface that takes real review time.&lt;/p&gt;

&lt;p&gt;While building it, the author noticed the page's &lt;strong&gt;Download&lt;/strong&gt; button was already broken — unrelated to the preview, a pre-existing defect. So they fixed it in the same branch and noted it in the PR description under "also fixes a pre-existing download bug."&lt;/p&gt;

&lt;p&gt;That is a reasonable instinct. You're already in the file, you see the bug, you fix it. The problem is what happens next.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that was trapped
&lt;/h2&gt;

&lt;p&gt;The download bug was not cosmetic. The &lt;strong&gt;Download&lt;/strong&gt; button failed for &lt;strong&gt;every&lt;/strong&gt; user role. Confirmed in a real-browser walkthrough of the deployed demo, admin and viewer alike.&lt;/p&gt;

&lt;p&gt;Two defects lived in the same handler:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No credentials.&lt;/strong&gt; It built a plain &lt;code&gt;&amp;lt;a href&amp;gt;&lt;/code&gt; and clicked it. A plain link carries no &lt;code&gt;Authorization&lt;/code&gt; header, and this backend is JWT-only with no session cookie — so the request could never authenticate, on any hosting environment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong key.&lt;/strong&gt; The URL used the ordinal &lt;code&gt;version_number&lt;/code&gt; (&lt;code&gt;1&lt;/code&gt;, &lt;code&gt;2&lt;/code&gt;, &lt;code&gt;3&lt;/code&gt;…), but the download route is keyed by the version's &lt;strong&gt;ULID&lt;/strong&gt;. So even an authenticated request would have hit a 404.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The demo walkthrough had a highlighted step: "download the document and verify its SHA-256." With this bug, that step could not be performed at all. This was a production-facing defect on a page that's supposed to demonstrate trustworthiness.&lt;/p&gt;

&lt;p&gt;And the fix for it was sitting, done, inside a feature PR that had been open for close to a month.&lt;/p&gt;

&lt;h2&gt;
  
  
  The call
&lt;/h2&gt;

&lt;p&gt;The rule I try to hold to is simple:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A bug fix and a feature have different urgencies. Do not couple their release.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The feature can take its time in review — that's healthy. But the production bug should not wait on the feature's review just because they happen to share a branch. The moment they're coupled, the fix inherits the feature's schedule, its review scope, and its risk of stalling.&lt;/p&gt;

&lt;p&gt;So the call was: &lt;strong&gt;extract the bug-fix subset, land it on &lt;code&gt;main&lt;/code&gt; on its own, and let the feature keep cooking.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Concretely, that meant three artifacts instead of one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;strong&gt;bug issue&lt;/strong&gt; describing only the download defect — root cause, reproduction, impact — and noting explicitly that the fix already exists inside the feature PR and is being extracted so it can land independently.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;small, focused PR&lt;/strong&gt; containing only the download fix.&lt;/li&gt;
&lt;li&gt;The original &lt;strong&gt;feature PR left open&lt;/strong&gt;, to be rebased down to just the preview surface once the fix is on &lt;code&gt;main&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The mechanics
&lt;/h2&gt;

&lt;p&gt;The important part of splitting a fix out is honesty about provenance. You are not pretending the fix was written fresh. You're relocating a known-good change to a smaller, faster vehicle and saying so.&lt;/p&gt;

&lt;p&gt;The extracted PR carried exactly the download plumbing and nothing else:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the authenticated blob-fetch method on the shared API client&lt;/li&gt;
&lt;li&gt;the entity-layer helper that targets the ULID-keyed download path&lt;/li&gt;
&lt;li&gt;the page change that resolves the version ULID from the history response and downloads through the authenticated client&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything that belonged to the &lt;em&gt;feature&lt;/em&gt; stayed in the feature PR: the preview component and its tests, the SHA-256 re-verification hook, the object-URL lifecycle for previews, the locale keys, and the scope-contract entry.&lt;/p&gt;

&lt;p&gt;The core of the page-level fix was small. Before, condensed (the DOM-append boilerplate around the anchor click is elided in both snippets):&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleDownload&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="nx"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apiBaseUrl&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="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ordinal version_number + plain &amp;lt;a href&amp;gt; =&amp;gt; 404 and no auth header&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/admin/vault/documents/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/versions/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version_number&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;/download`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;download&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;original_filename&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s2"&gt;`document-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&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;After — resolve the real version ULID, fetch through the authenticated client, save from an object URL:&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="c1"&gt;// The download endpoint is keyed by the version's ULID, which only the&lt;/span&gt;
&lt;span class="c1"&gt;// history response carries — the detail payload exposes just the ordinal.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentVersion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;versions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version_number&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version_number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleDownload&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="nx"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;currentVersion&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchDocumentBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currentVersion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;objectUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;objectUrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;download&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;original_filename&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s2"&gt;`document-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;revokeObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;objectUrl&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;Plus regression tests: at the entity level, that the request targets the ULID-keyed path and sends the auth headers; at the page level, that clicking Download issues a request keyed by the version ULID from the history response, not the ordinal &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The commit message recorded where the change came from, in words to this effect:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Extracted from the pending inline-preview feature PR, which first implemented this fix bundled with the preview feature; this lands the bug-fix subset on &lt;code&gt;main&lt;/code&gt; independently.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That one sentence keeps the history honest. Anyone reading &lt;code&gt;git log&lt;/code&gt; later can see that the fix and the feature are two halves of the same original idea, deliberately separated.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it actually went
&lt;/h2&gt;

&lt;p&gt;The whole extraction, once decided, was fast. The bug issue was filed, the focused PR opened a few minutes later, and it merged a couple of minutes after that — closing the issue. The production download failure was fixed on &lt;code&gt;main&lt;/code&gt; the same afternoon, while the feature PR stayed open.&lt;/p&gt;

&lt;p&gt;The feature PR didn't get thrown away. A comment on it recorded exactly what had been lifted out and what remained, so the suggested next step was a straightforward rebase: most of the download plumbing was already on &lt;code&gt;main&lt;/code&gt;, so slimming the feature down to the preview-only surface would be mostly deletions. The rebase-or-close decision was left to the PR's owner. The feature was never the thing in a hurry.&lt;/p&gt;

&lt;h2&gt;
  
  
  When coupling is actually fine
&lt;/h2&gt;

&lt;p&gt;To be fair to the original instinct: bundling a fix into a feature branch is not always wrong.&lt;/p&gt;

&lt;p&gt;If the bug and the feature touch the same code, the feature is small and about to merge, and nothing production-facing is broken in the meantime — then splitting them is just ceremony. Ship the branch.&lt;/p&gt;

&lt;p&gt;The signal that you have a problem is specific:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;something &lt;strong&gt;production-facing is broken now&lt;/strong&gt;, and&lt;/li&gt;
&lt;li&gt;the branch holding the fix is &lt;strong&gt;not close to merging&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When both are true, the fix has become a hostage. The longer the feature takes, the longer a known, solved defect stays live in production for no reason other than branch geography. That's the case worth acting on. The rest of the time, use judgment and don't over-process it.&lt;/p&gt;

&lt;p&gt;There's also a cheap diagnostic. When you write the PR description and find yourself adding a line like "this also fixes an unrelated bug," treat that sentence as a small alarm. It's telling you the branch is carrying two changes with two different reasons to exist. Sometimes that's fine. Sometimes it's the thing you'll wish you'd split a month from now.&lt;/p&gt;

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

&lt;p&gt;Finding a bug while you build a feature is normal and good. Fixing it in the same branch is the trap — not because the fix is wrong, but because you've quietly bound a production concern to a feature's timeline.&lt;/p&gt;

&lt;p&gt;When it happens, don't argue about whether the feature should merge faster. Just split the fix out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;File the bug on its own terms.&lt;/strong&gt; Root cause, repro, impact — as if the feature didn't exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extract only the fix.&lt;/strong&gt; Leave every line that belongs to the feature behind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Say where it came from.&lt;/strong&gt; Provenance in the issue, the PR, and the commit message keeps the history readable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let the feature keep its own pace.&lt;/strong&gt; It was never the urgent part.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two changes with different urgencies should have two release paths. If a production fix is stuck behind a feature review, the feature isn't the problem. The coupling is.&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;

</description>
      <category>git</category>
      <category>process</category>
      <category>engineering</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
