<?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: Hammad Shams Uddin</title>
    <description>The latest articles on DEV Community by Hammad Shams Uddin (@hammad4june1999).</description>
    <link>https://dev.to/hammad4june1999</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%2F4066514%2F2ba9ed94-45a3-4e62-910f-8c42551b2b91.jpg</url>
      <title>DEV Community: Hammad Shams Uddin</title>
      <link>https://dev.to/hammad4june1999</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hammad4june1999"/>
    <language>en</language>
    <item>
      <title>Three of twenty pages are indexed. Here is what ten days of my own crawler log says about why.</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Thu, 08 Oct 2026 17:45:05 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/three-of-twenty-pages-are-indexed-here-is-what-ten-days-of-my-own-crawler-log-says-about-why-33f</link>
      <guid>https://dev.to/hammad4june1999/three-of-twenty-pages-are-indexed-here-is-what-ten-days-of-my-own-crawler-log-says-about-why-33f</guid>
      <description>&lt;p&gt;I run a site with 6,100 URLs in its sitemap. Google downloaded that sitemap three days ago, no warnings, no errors. Today I inspected twenty randomly chosen pages from it, one API call each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  3  Submitted and indexed
 11  Crawled - currently not indexed
  6  URL is unknown to Google
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fifteen per cent. And the six "unknown" ones are the interesting part: they are listed in a sitemap Google has, and Google has never fetched them at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Search Console shows, and what it hides
&lt;/h2&gt;

&lt;p&gt;The performance numbers for the last 28 days: &lt;strong&gt;85 impressions, 5 clicks.&lt;/strong&gt; Sixty of those 85 impressions are the homepage. The top queries are not tool queries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;software &amp;amp; it services                  3 impressions   position 5.7
software services                       3               position 2.0
software house near me for freshers     2               position 1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a calculator site ranking for "software house near me". The pages that would answer a real query — the converters, the calculators — are not in the index to be shown.&lt;/p&gt;

&lt;p&gt;Ninety days, by week, tells the rest:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;week&lt;/th&gt;
&lt;th&gt;impressions&lt;/th&gt;
&lt;th&gt;clicks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;W31&lt;/td&gt;
&lt;td&gt;13,683&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;W32&lt;/td&gt;
&lt;td&gt;24,082&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;W33&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;68,834&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;W34&lt;/td&gt;
&lt;td&gt;27,317&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;W35&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;191&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;W36–W40&lt;/td&gt;
&lt;td&gt;31, 32, 18, 21, 12&lt;/td&gt;
&lt;td&gt;~1 each&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cliff between W34 and W35 is where a compromise was cleaned up. Note the clicks column during the peak: 68,834 impressions produced 35 clicks, a CTR of 0.05%. Those impressions were never real demand — they were the attacker's invented URLs appearing in results. Losing them cost almost nothing. What has not come back is everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The log the host does not give you
&lt;/h2&gt;

&lt;p&gt;Search Console is two days behind and rounds everything into weekly shapes, so last week I gave the site its own crawler log: one line per crawler request — time, crawler, &lt;strong&gt;the status it was served&lt;/strong&gt;, path, IP — and nothing at all for human visitors. Ten days in:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;day&lt;/th&gt;
&lt;th&gt;Googlebot&lt;/th&gt;
&lt;th&gt;bingbot&lt;/th&gt;
&lt;th&gt;of which 410&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;29 Sep&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;td&gt;1,077&lt;/td&gt;
&lt;td&gt;982&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30 Sep&lt;/td&gt;
&lt;td&gt;66&lt;/td&gt;
&lt;td&gt;4,114&lt;/td&gt;
&lt;td&gt;3,914&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 Oct&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;td&gt;2,660&lt;/td&gt;
&lt;td&gt;2,487&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 Oct&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;2,903&lt;/td&gt;
&lt;td&gt;2,708&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 Oct&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;3,832&lt;/td&gt;
&lt;td&gt;3,616&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 Oct&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;3,967&lt;/td&gt;
&lt;td&gt;3,776&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 Oct&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;4,255&lt;/td&gt;
&lt;td&gt;4,056&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6 Oct&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;4,976&lt;/td&gt;
&lt;td&gt;4,795&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7 Oct&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;4,527&lt;/td&gt;
&lt;td&gt;4,389&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two numbers to take from that table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Googlebot fetches about 37 pages a day.&lt;/strong&gt; In ten days it fetched 221 distinct real URLs, all 200s. Against a 6,100-URL sitemap, a single complete pass at that rate takes about five months. That alone explains the six "unknown to Google" pages in my sample of twenty: nothing is wrong with them, Google simply has not got there, and at this rate it will not get there soon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;bingbot fetches about 4,000 dead URLs a day&lt;/strong&gt;, and that number is going &lt;em&gt;up&lt;/em&gt;. Those are the URLs a cloaker invented during the compromise — a word, a slash, a word with digits between its letters — about 291 of them handed to every crawler that asked for a page, for six days.&lt;/p&gt;

&lt;h2&gt;
  
  
  I was wrong about the fix
&lt;/h2&gt;

&lt;p&gt;Four days into this log I switched those dead URLs from 404 to &lt;strong&gt;410 Gone&lt;/strong&gt;, which tells a crawler to drop the URL rather than check again later. I then wrote that the daily count was "pointing the right way": 3,914 → 2,487 → 1,653.&lt;/p&gt;

&lt;p&gt;It was not. Those were two partial days at either end of a four-day sample. Ten days later the sequence reads 3,914, 2,487, 2,708, 3,616, 3,776, 4,056, 4,795, 4,389 — flat to rising. A 410 does tell Bing to drop &lt;em&gt;that&lt;/em&gt; URL, but it does nothing to the rate at which Bing works through a queue of nine thousand others, and I had read a trend into a sample too short to hold one. I have left the original numbers in that post with a correction under them, because deleting them would make my reasoning look better than it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the two instruments say together
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Google &lt;strong&gt;is&lt;/strong&gt; crawling — steadily, slowly, only real pages, every one a 200. There is no technical blockage: no 403s, no 404 storm, no cloaking, a clean sitemap fetch three days ago.&lt;/li&gt;
&lt;li&gt;Google &lt;strong&gt;is not&lt;/strong&gt; indexing what it crawls: 11 of 20 sampled pages are "crawled, currently not indexed", most last crawled in August.&lt;/li&gt;
&lt;li&gt;Bing is crawling thirty times more than Google and spending 94% of it on URLs that have been gone for a month.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Crawled — currently not indexed" is a judgement, not a fault. There is no header to add and no file to fix; it means Google fetched the page and decided it was not worth a slot. For a page that converts hectares to acres, competing with a hundred thousand identical pages, that judgement is not unreasonable, which is the uncomfortable part.&lt;/p&gt;

&lt;p&gt;So the work is not technical any more. It is the slower kind: fewer, better pages, and reasons for anyone to link to them.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want the same two numbers for your own site
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Measure indexation, do not estimate it.&lt;/strong&gt; &lt;code&gt;site:&lt;/code&gt; queries in a browser are a lossy approximation. The URL Inspection API gives you a verdict per URL, and twenty of them is a sample that will tell you something real in ten minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know how big your sitemap actually is before you argue about crawl budget.&lt;/strong&gt; Mine says 6,100 across 54 child sitemaps, which is not a number I would have guessed; a &lt;a href="https://utilorax.com/tools/sitemap-generator" rel="noopener noreferrer"&gt;sitemap generator&lt;/a&gt; that walks your own site will tell you what you are really asking Google to read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at the result you are competing for.&lt;/strong&gt; Before writing another near-duplicate page, &lt;a href="https://utilorax.com/tools/serp-snippet-preview" rel="noopener noreferrer"&gt;put the title and description into a SERP preview&lt;/a&gt; and ask whether that line would get clicked next to nine others exactly like it. If the honest answer is no, indexing is not your bottleneck.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>monitoring</category>
      <category>googlesearchconsole</category>
    </item>
    <item>
      <title>Bing spent 94% of four days crawling a hacker''s URLs. Google spent none.</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Fri, 02 Oct 2026 16:09:00 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/bing-spent-94-of-four-days-crawling-a-hackers-urls-google-spent-none-404o</link>
      <guid>https://dev.to/hammad4june1999/bing-spent-94-of-four-days-crawling-a-hackers-urls-google-spent-none-404o</guid>
      <description>&lt;p&gt;Four days ago I gave my site its own crawler log, because the host keeps access logs somewhere a script cannot read them and Search Console answers two days late. Here is the first full read of it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bingbot            9,629 hits     bytespider   357
googlebot            183 hits     yandexbot     94
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the status codes those two were served:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;day&lt;/th&gt;
&lt;th&gt;bingbot 200&lt;/th&gt;
&lt;th&gt;bingbot 410&lt;/th&gt;
&lt;th&gt;googlebot 200&lt;/th&gt;
&lt;th&gt;googlebot other&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;29 Sep (from 15:51)&lt;/td&gt;
&lt;td&gt;70&lt;/td&gt;
&lt;td&gt;982&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30 Sep&lt;/td&gt;
&lt;td&gt;198&lt;/td&gt;
&lt;td&gt;3,914&lt;/td&gt;
&lt;td&gt;66&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 Oct&lt;/td&gt;
&lt;td&gt;172&lt;/td&gt;
&lt;td&gt;2,487&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;td&gt;1× 404&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 Oct (to 16:03)&lt;/td&gt;
&lt;td&gt;123&lt;/td&gt;
&lt;td&gt;1,653&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;9,036 of bingbot's 9,629 requests — 94% — were to URLs that do not exist.&lt;/strong&gt; 9,015 of them were &lt;em&gt;distinct&lt;/em&gt;. Every one has the same shape: a dictionary word, a slash, a word with digits pushed between its letters.&lt;/p&gt;

&lt;p&gt;They are not Bing's fault. In September this site was compromised, and the malware handed every crawler that asked for a page roughly 291 invented URLs. It did that for six days. The malware has been gone for weeks; the queue it created is still being worked through, at about two thousand requests a day.&lt;/p&gt;

&lt;p&gt;Googlebot, over the same four days: 183 requests, 135 distinct real pages, and &lt;strong&gt;not one spam URL&lt;/strong&gt;. Two crawlers, the same site, the same week, completely different pictures. If I had only Search Console I would have had neither.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in those four days
&lt;/h2&gt;

&lt;p&gt;On the 29th those dead URLs were answering 404. A 404 means &lt;em&gt;not here right now&lt;/em&gt;, which is an invitation to come back. I switched the cloaker's URL shape to &lt;strong&gt;410 Gone&lt;/strong&gt; — &lt;em&gt;stop asking&lt;/em&gt; — after checking the pattern against every URL in my sitemap (9 of 9 known spam paths matched; 0 of 6,240 real ones did).&lt;/p&gt;

&lt;p&gt;The daily 410 count since: &lt;strong&gt;3,914 → 2,487 → 1,653&lt;/strong&gt;. The last day is two-thirds of a day, so it is not yet a trend I would bet on, but it is the first number I have had at all.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Correction, 8 October.&lt;/strong&gt; It was not a trend, and I should not have printed those three numbers in that order. With ten days instead of four, the daily 410 count reads 982 (part day), 3,914, 2,487, 2,708, 3,616, 3,776, 4,056, 4,795, 4,389, 3,274 (part day) — flat to rising, not falling. The "decline" was two partial days at either end of a short sample, which is exactly the shape I warn about elsewhere in this post. A 410 tells a crawler to drop a URL; it does not slow down the rate at which it works through a queue of nine thousand others, and ten days was not long enough to see the first effect either. I have left the original numbers above rather than quietly editing them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The rule itself lives inside the 404 handler, not at the top of the router. That ordering is the whole safety argument: a request only reaches &lt;code&gt;notFound()&lt;/code&gt; once it has failed to match anything real, so a live route cannot be caught by the pattern — and one of mine, &lt;code&gt;/s/{code}&lt;/code&gt;, has exactly the same shape as the spam.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Unverified" was hiding two different facts
&lt;/h2&gt;

&lt;p&gt;I had the log check each crawler's IP against the published address ranges — Google's &lt;code&gt;googlebot.json&lt;/code&gt;, Bing's &lt;code&gt;bingbot.json&lt;/code&gt; — and report &lt;code&gt;verified&lt;/code&gt; against &lt;code&gt;unverified&lt;/code&gt;. A reader of the first write-up, who runs the same check over nginx logs, pointed out that my split quietly merged two things that are not alike:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a request that &lt;strong&gt;failed&lt;/strong&gt; the list: claims to be Googlebot, is not in Google's ranges — suspicious;&lt;/li&gt;
&lt;li&gt;a request with &lt;strong&gt;no list to check&lt;/strong&gt;: Anthropic publishes no ranges at all, so a ClaudeBot hit can never be more than unchecked — not suspicious, just unknowable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Collapsing those is the same error as calling an unreachable sitemap "empty", which this project has made more than once. So there are three counters now, and the digest prints what it actually knows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;googlebot          183 hits (178 verified, 5 FAILED the list)
yandexbot           94 hits (94 no published list)
claudebot           39 hits (39 no published list)
google-inspection    2 hits (2 FAILED the list)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same reader made a second point I have taken: these lists &lt;strong&gt;move&lt;/strong&gt;, so a verification you made today will not reproduce against next month's list. The cache now keeps a dated snapshot of every list it fetches, sixty days of them, which costs a few hundred kilobytes and makes an old answer checkable.&lt;/p&gt;

&lt;p&gt;Those 5 Googlebot hits that failed the list are all mine, and I had to look at the addresses to know it: two came from my laptop testing with a spoofed agent, and three from the server itself — my own daily cloaking check, which asks for the homepage as Googlebot and compares it against the homepage as a browser. My monitoring impersonates Googlebot for a living, so of course it fails a Googlebot address check. The log recorded all five exactly as it was told, which is the point: &lt;strong&gt;a user agent is a claim, and a claim is not an identity.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want the same visibility
&lt;/h2&gt;

&lt;p&gt;You do not need a log pipeline. Roughly 120 lines of PHP: match the user agent against a list of crawler tokens, append one line at shutdown — time, crawler, &lt;strong&gt;status served&lt;/strong&gt;, path, IP — and never write a line for a human. Then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Count distinct paths, not requests.&lt;/strong&gt; 9,629 hits sounded like a crawl-rate problem; 9,015 &lt;em&gt;distinct&lt;/em&gt; dead URLs is a completely different diagnosis. Any &lt;a href="https://utilorax.com/tools/remove-duplicate-lines" rel="noopener noreferrer"&gt;list of lines will collapse to its unique entries&lt;/a&gt; in a second, and the gap between the two numbers is usually where the story is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check what you are telling crawlers to skip.&lt;/strong&gt; Mine already &lt;a href="https://utilorax.com/tools/robots-txt-generator" rel="noopener noreferrer"&gt;blocks the training crawlers I want blocked&lt;/a&gt; and nothing else — worth confirming rather than assuming, because a &lt;code&gt;Disallow&lt;/code&gt; in the wrong user-agent group reads as harmless and is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log the status you served, not the path requested.&lt;/strong&gt; The interesting line is never "Googlebot asked for X". It is "Googlebot was given a 404 for X".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three weeks after the site was clean, most of one search engine's attention was still going to the attacker's imagination. Nothing in Search Console would have told me that, and nothing in my code was telling me either — until the site started writing it down.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>security</category>
      <category>webdev</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>I gave my site an access log. Within a minute it showed Bing crawling a hacker''s URLs.</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Tue, 29 Sep 2026 16:00:54 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/i-gave-my-site-an-access-log-within-a-minute-it-showed-bing-crawling-a-hackers-urls-30c5</link>
      <guid>https://dev.to/hammad4june1999/i-gave-my-site-an-access-log-within-a-minute-it-showed-bing-crawling-a-hackers-urls-30c5</guid>
      <description>&lt;p&gt;My host keeps access logs in a control panel I have to click through, and nowhere a script can read them. So after September's compromise I could not answer the one question that mattered — &lt;em&gt;are the crawlers coming back, and what are they being served?&lt;/em&gt; — except by waiting two days for Search Console to round it into a weekly shape.&lt;/p&gt;

&lt;p&gt;So I gave the site its own log. About 120 lines of PHP: if the user agent names a known crawler, append one line at shutdown with the time, the crawler, &lt;strong&gt;the status code it was actually served&lt;/strong&gt;, the path and the IP. Humans are never written to it.&lt;/p&gt;

&lt;p&gt;The first day's output, in full, from the first minute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;15:51:01  bingbot  200  /calculators/pace-calculator          40.77.167.123
15:51:34  bingbot  404  /stimulated/a0d3o2b2o25378994         40.77.167.123
15:51:36  bingbot  404  /harshly/p0o9s1i9t9i3o7n38906         40.77.167.123
15:51:38  bingbot  404  /disservice/u1n6g2u8e5s4670872        40.77.167.123
15:51:40  bingbot  404  /dictation/g0o6g3g2l3e3e1y3e0264      40.77.167.123
15:51:41  bingbot  404  /coadjust/v1i4m1i5n4a2r1i8a6529       40.77.167.123
15:51:48  bingbot  404  /selfapproving/p1r3e2l1e9c2t1e3d9648  40.77.167.123
…
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By the end of the day: &lt;strong&gt;24 of 26 crawler requests were to URLs a cloaker invented three weeks earlier.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Those paths are not random junk. They are the exact shape the malware generated — a dictionary word, a slash, a second word with digits pushed between its letters — and it handed about 291 fresh ones to every crawler that asked for a page, for six days, before the site was cleaned. The malware has been gone for weeks. The queue it created has not.&lt;/p&gt;

&lt;h2&gt;
  
  
  404 was the wrong answer
&lt;/h2&gt;

&lt;p&gt;Every one of those URLs was returning 404, which is correct in the sense that they are not there. But 404 means &lt;em&gt;not here right now&lt;/em&gt;, and crawlers treat it as a reason to come back and check again later. That is why they are still working through a list from three weeks ago.&lt;/p&gt;

&lt;p&gt;410 means &lt;em&gt;gone; stop asking&lt;/em&gt;. Both Google and Bing drop a 410 faster than a 404, and the difference is exactly the behaviour I want.&lt;/p&gt;

&lt;p&gt;The risk in a rule like this is obvious: match too broadly and you 410 something real. So I wrote the shape, then measured it before it went anywhere near the site:&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;$rule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;function&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;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$seg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;explode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;trim&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;'/'&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;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$seg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;2&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;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$seg&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;strpos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt;&lt;span class="p"&gt;)&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="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// our slugs are hyphenated&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;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10&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;false&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;preg_match_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'~[0-9]~'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;preg_match_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'~[a-z]~i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&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;Result: &lt;strong&gt;9 of 9 known spam paths matched, 0 of 6,240 real URLs did.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One more precaution, and it is the part I would get wrong if I were rushing. The rule does not run at the start of routing. It runs at the &lt;em&gt;end&lt;/em&gt;, inside the 404 handler — the place a request only reaches once it has failed to match anything real:&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;notFound&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// …if the path has the cloaker's shape, 410 instead of 404…&lt;/span&gt;
    &lt;span class="nb"&gt;http_response_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;410&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X-Robots-Tag: noindex'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;exit&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;That ordering is not decoration. My site has a &lt;code&gt;/s/{code}&lt;/code&gt; short-link route: two segments, no hyphen, letters and digits — it matches the shape. Put the rule first and every short link a user shares becomes 410 Gone. Put it last, and a live route can never be caught by it, because a live route never reaches the 404 handler at all.&lt;/p&gt;

&lt;p&gt;Verified on the live site afterwards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/accidence/d0e3p2o6s2i8t6a6r8y548   410      (cloaker shape)
/no-such-page                        404      (ordinary typo)
/s/a1b2c3d4e5                        302      (short link, still works)
/tools/json-formatter                200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The log lied to me once on day one
&lt;/h2&gt;

&lt;p&gt;One line said &lt;code&gt;googlebot 200 /&lt;/code&gt;. I was pleased for about ten seconds, until I looked at the IP: &lt;code&gt;39.34.131.166&lt;/code&gt; — my own network. It was me, testing with a spoofed user agent earlier that afternoon. The log had recorded exactly what it was told, which is all a user agent ever is: &lt;strong&gt;a claim anybody can type.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So the log now checks the claim against the crawlers' own published address lists — Google's &lt;code&gt;googlebot.json&lt;/code&gt; and &lt;code&gt;special-crawlers.json&lt;/code&gt;, Bing's &lt;code&gt;bingbot.json&lt;/code&gt;, cached for a day — and reports both numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bingbot     25 hits (25 verified, 0 unverified)
googlebot    1 hit  (0 verified, 1 unverified)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the alarm that says "Googlebot has not come" now counts verified visits only, because an alarm that can be silenced by anyone typing &lt;code&gt;Googlebot&lt;/code&gt; into a user agent string is not an alarm.&lt;/p&gt;

&lt;p&gt;If you are running this kind of check yourself: your crawler's &lt;a href="https://utilorax.com/tools/http-status-checker" rel="noopener noreferrer"&gt;status codes are what it believes about your site&lt;/a&gt;, so log what was &lt;em&gt;served&lt;/em&gt;, not what was requested. And if you end up needing to retire a batch of URLs permanently, an &lt;a href="https://utilorax.com/tools/htaccess-redirect-generator" rel="noopener noreferrer"&gt;.htaccess rule&lt;/a&gt; is the cheaper place to do it than in application code — as long as you have measured the pattern against your real URLs first.&lt;/p&gt;

&lt;p&gt;Three weeks after the cleanup, most of my crawl budget was still being spent on the attacker's imagination. I only know that because the site finally started writing things down.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>security</category>
      <category>php</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My monitoring said 26 pages were broken. Every one of them was fine.</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Fri, 25 Sep 2026 19:02:18 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/my-monitoring-said-26-pages-were-broken-every-one-of-them-was-fine-i5j</link>
      <guid>https://dev.to/hammad4june1999/my-monitoring-said-26-pages-were-broken-every-one-of-them-was-fine-i5j</guid>
      <description>&lt;p&gt;This morning's report from my own daily checker opened like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NEEDS YOU — 27 thing(s)
  * / returns HTTP 403 but had 33 impressions
  * /es/tools/text-summarizer returns HTTP 403 but had 11 impressions
  * /tools/text-to-slug returns HTTP 520 but had 11 impressions
  … 23 more …
  * sitemap.xml returned nothing usable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty-six pages and the sitemap. The homepage among them. My first thought was that the host had suspended the account — SSH to the box was timing out at the same moment, which fitted.&lt;/p&gt;

&lt;p&gt;Every one of those pages was serving normally.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the site was actually doing
&lt;/h2&gt;

&lt;p&gt;Before touching anything I measured the same URL through different clients. Forty requests, alternating, same minute:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;client&lt;/th&gt;
&lt;th&gt;result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;curl sending a Chrome user agent&lt;/td&gt;
&lt;td&gt;20 of 20 → &lt;strong&gt;403&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;curl sending Googlebot's user agent&lt;/td&gt;
&lt;td&gt;20 of 20 → &lt;strong&gt;200&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;real headless Chrome&lt;/td&gt;
&lt;td&gt;5 of 5 → &lt;strong&gt;200&lt;/strong&gt;, full content&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;curl sending &lt;code&gt;curl/8.0&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;mixed, mostly 200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read that table twice. The requests that claimed to be a browser were refused. The request that claimed to be Googlebot was served. An actual browser was served. So the site was fine for humans, fine for Google — and hostile to exactly one population: &lt;strong&gt;things that say "Chrome" without being Chrome.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is not a fault. That is bot protection at the edge doing its job. Cloudflare-style protection compares what a client &lt;em&gt;claims&lt;/em&gt; against how it behaves, and a curl request wearing a Chrome user agent is the oldest impersonation in the book. My checker had been making that exact request every morning for months, and the day the protection tightened, my checker became a bot to be blocked.&lt;/p&gt;

&lt;p&gt;The 520s were the same story from a different angle: a proxy failing to get a clean answer from the origin, on requests that were being refused anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug is not the block. It is the report.
&lt;/h2&gt;

&lt;p&gt;The block is somebody else's defence working. The failure that cost me an hour is that my own script turned &lt;em&gt;I was refused&lt;/em&gt; into &lt;em&gt;the page is broken&lt;/em&gt; — twenty-six times, in a list headed NEEDS YOU.&lt;/p&gt;

&lt;p&gt;Four changes, and they are all the same change:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Ask whether you can see, before saying what you see.&lt;/strong&gt;&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;seoCanSee&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// fetch the homepage, then:&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;$code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;stripos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;SEO_MARKER&lt;/span&gt;&lt;span class="p"&gt;)&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="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ok'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'code'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'why'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ok'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'code'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'why'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'the homepage answered HTTP '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;' to this script'&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;If that fails, nothing downstream is allowed to report on individual pages at all. One line goes out — &lt;em&gt;this script cannot reach the site; page, sitemap and robots checks did NOT run; a browser and Googlebot may well be served normally&lt;/em&gt; — and the per-page loop never runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Look at the body, not only at the status.&lt;/strong&gt; A 403 from my application would carry my page. A 403 from the edge carries the edge's page. Same integer, different fact:&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;if&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nv"&gt;$st&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'code'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$st&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'code'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;520&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$st&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ours'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$edgeBlocked&lt;/span&gt;&lt;span class="o"&gt;++&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Report one fact once.&lt;/strong&gt; The blockage is upstream of every page, so it is one line — &lt;em&gt;3 pages could not be checked: our own edge answered 403 to this script&lt;/em&gt; — not one alarm per page. Twenty-six alarms for one cause is how a NEEDS YOU list becomes wallpaper.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Check what your loudest alarm does when the probe is blocked.&lt;/strong&gt; Mine compares the homepage as Googlebot sees it against the homepage as a browser sees it, and shouts CLOAKING if they differ — the alarm that means &lt;em&gt;your site is hacked&lt;/em&gt;. With browser-UA requests being refused, the "browser" side was a 403 page, whose &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; is &lt;code&gt;403 Forbidden&lt;/code&gt;. Two different titles. That alarm was one edge-rule away from telling me I had been hacked again, three weeks after I actually had been. Now a response that is not my page is not a comparison — it is "did not see it".&lt;/p&gt;

&lt;p&gt;After all four: 27 items became 4, and the four are true.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you are chasing the same ghost
&lt;/h2&gt;

&lt;p&gt;Three things worth doing before you believe your own monitoring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Load the page in a real browser.&lt;/strong&gt; Not curl with a browser string — an actual browser. If it renders, your users are fine and your problem is your client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at what your client is really sending.&lt;/strong&gt; Your checker, your CI job, your uptime probe: each one has a user agent, and it may not be what you think. &lt;a href="https://utilorax.com/tools/what-is-my-user-agent" rel="noopener noreferrer"&gt;See exactly what a client sends&lt;/a&gt;, then make your script say something honest rather than dressing up as Chrome.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the whole response, not the code.&lt;/strong&gt; &lt;a href="https://utilorax.com/tools/http-header-checker" rel="noopener noreferrer"&gt;The headers a URL actually returns&lt;/a&gt; will usually name the thing that answered — &lt;code&gt;server: cloudflare&lt;/code&gt;, &lt;code&gt;x-turbo-charged-by: LiteSpeed&lt;/code&gt;, a &lt;code&gt;cf-ray&lt;/code&gt; id — and that tells you whether you reached your application or something standing in front of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The uncomfortable part is that none of this was a site problem for a single minute. Every visitor and every crawler was served the whole time. The only thing that was down was my ability to tell.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>monitoring</category>
      <category>webdev</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>My check said "all 17 articles". There were 18.</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Tue, 22 Sep 2026 17:52:44 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/my-check-said-all-17-articles-there-were-18-jn3</link>
      <guid>https://dev.to/hammad4june1999/my-check-said-all-17-articles-there-were-18-jn3</guid>
      <description>&lt;p&gt;Every morning a script of mine fetches each of my published articles and checks that the outbound links are still followed. Yesterday's run ended with this line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dev.to: links on all 18 published articles are still followed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Today's run, same script, nothing removed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dev.to: links on all 17 published articles are still followed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had published one article since. The honest number was 18 and rising. The word doing the damage is &lt;strong&gt;all&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop
&lt;/h2&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="nv"&gt;$articles&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$a&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="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'published'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&lt;/span&gt;&lt;span class="p"&gt;]))&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;$html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DevTo&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fetch&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;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&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;$html&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;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;        &lt;span class="c1"&gt;// &amp;lt;-- here&lt;/span&gt;
    &lt;span class="nv"&gt;$checked&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// …find every link to my site, look at its rel attribute…&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// later&lt;/span&gt;
&lt;span class="nv"&gt;$did&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'links on all '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$checked&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;' published articles are still followed.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One article did not come back — a throttle, a hiccup, it does not matter. The loop skipped it, &lt;code&gt;$checked&lt;/code&gt; stayed where it was, and the summary printed &lt;code&gt;$checked&lt;/code&gt; &lt;strong&gt;as if it were the total&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So the sentence was not "17 of 18 are fine, 1 is unknown". It was "all 17", which asserts something about a set I never measured. The one article that could have gone bad was precisely the one that was not looked at, and the report was worded to sound most confident about exactly that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the denominator is the whole thing
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;$checked&lt;/code&gt; answers "how many replied". The summary claims to answer "how many exist and are fine". Those are the same integer only when nothing fails, which is the one case where you do not need the check.&lt;/p&gt;

&lt;p&gt;A count of successes cannot describe a set unless you also carry the size of the set:&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;$checked&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$unchecked&lt;/span&gt; &lt;span class="o"&gt;=&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="nv"&gt;$articles&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$a&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="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'published'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&lt;/span&gt;&lt;span class="p"&gt;]))&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;$html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DevTo&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fetch&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;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&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;$html&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="nb"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nv"&gt;$html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DevTo&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fetch&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;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&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="nv"&gt;$html&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="nv"&gt;$unchecked&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="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&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;$checked&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$checked&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$unchecked&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the report can say what actually happened: &lt;em&gt;links still followed on 17 of 18; 1 could not be fetched twice and was NOT checked&lt;/em&gt;. And when most of them fail, that is no longer a quiet line at the bottom of an email — it is escalated, because a check that could not run is not a check that passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is the third time
&lt;/h2&gt;

&lt;p&gt;The same shape has bitten this project three times in a month, in three different files:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;where&lt;/th&gt;
&lt;th&gt;what it printed&lt;/th&gt;
&lt;th&gt;what was true&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;sitemap check&lt;/td&gt;
&lt;td&gt;"1 of 54 child sitemaps is empty"&lt;/td&gt;
&lt;td&gt;the child was throttled, not empty&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;robots.txt check&lt;/td&gt;
&lt;td&gt;passed, six days running&lt;/td&gt;
&lt;td&gt;it was reading an attacker's planted file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;link check&lt;/td&gt;
&lt;td&gt;"all 17 articles"&lt;/td&gt;
&lt;td&gt;there were 18; one was never fetched&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of them turned &lt;em&gt;I could not measure this&lt;/em&gt; into &lt;em&gt;I measured this and it is fine&lt;/em&gt;. That direction of error is the dangerous one: a false alarm gets investigated, a false all-clear gets filed.&lt;/p&gt;

&lt;p&gt;So I wrote the test as a source check rather than a behaviour test, because the bug lives in a shape, not in a value:&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="nv"&gt;$lines&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$l&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="o"&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*if \(\$html === .{2}\) \{ *continue; *\}~'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$l&lt;/span&gt;&lt;span class="p"&gt;))&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;$silentSkips&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'line %d drops a page without recording it: %s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$n&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="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$l&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;$check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'no fetch failure is skipped without being counted'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$silentSkips&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;I put the old line back to make sure the test fails on it, then took it out again. It fails on the bug and passes on the fix — which is the only way to know a regression test is a test and not decoration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three questions worth asking your own reports
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What is the denominator?&lt;/strong&gt; If a line says "all N", find where N comes from. If N is a counter that increments on success, the word "all" is a lie waiting for a bad day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can it tell "nothing failed" from "nothing was checked"?&lt;/strong&gt; Zero failures out of zero attempts prints the same as a clean run. Report failures &lt;em&gt;against attempts&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the count come from the source of truth or from the transport?&lt;/strong&gt; The number of URLs you have comes from your list — for a site, the sitemap itself, which you can &lt;a href="https://utilorax.com/tools/sitemap-downloader" rel="noopener noreferrer"&gt;pull into a plain list of every URL&lt;/a&gt; and count — not from how many of them your script happened to load. Then check each one's &lt;a href="https://utilorax.com/tools/http-status-checker" rel="noopener noreferrer"&gt;status code&lt;/a&gt; against that list, so the two numbers stay separate and the gap between them stays visible.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix is four lines. The habit is harder: when a script tells you everything is fine, ask it how many things it actually looked at.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>devops</category>
      <category>php</category>
      <category>webdev</category>
    </item>
    <item>
      <title>invalid_grant: my service account was fine. My laptop's clock was three days slow.</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Sun, 20 Sep 2026 21:50:41 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/invalidgrant-my-service-account-was-fine-my-laptops-clock-was-three-days-slow-11hm</link>
      <guid>https://dev.to/hammad4june1999/invalidgrant-my-service-account-was-fine-my-laptops-clock-was-three-days-slow-11hm</guid>
      <description>&lt;p&gt;My daily SEO round emails me one digest. This morning it led with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NEEDS YOU — 5 thing(s)
  * Search Console could not be read today.
  ...

SEARCH CONSOLE — COULD NOT READ
  Token request failed (HTTP 400): {"error":"invalid_grant",
  "error_description":"Invalid JWT: Token must be a short-lived token
  (60 minutes) and in a reasonable timeframe. Check your iat and exp
  values in the JWT claim."}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;invalid_grant&lt;/code&gt; is the error you get when a credential is wrong, so that is where I went first: had the service account been disabled, had someone rotated the key, had I lost access to the property? I had spent six days cleaning a compromised hosting account the week before, so "the key is gone" was not a paranoid thought.&lt;/p&gt;

&lt;p&gt;All of that was the wrong question, and the report had already told me so. I just had not read the line above the error.&lt;/p&gt;

&lt;h2&gt;
  
  
  The header was the evidence
&lt;/h2&gt;

&lt;p&gt;The digest's own title line said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Utilorax SEO — Thu 17 Sep 2026
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That date is generated by the script at run time, from the machine it runs on. I asked something with an authoritative clock what day it was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://www.google.com | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'^date:'&lt;/span&gt;
Date: Sun, 20 Sep 2026 21:03:40 GMT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three days apart. The machine that signed the token thought it was the 17th.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why that is fatal to a signed token, and to nothing else
&lt;/h2&gt;

&lt;p&gt;Here is the part of my client that builds the assertion:&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;$now&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$claim&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;b64url&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="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'iss'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$sa&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'client_email'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'scope'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;SCOPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'aud'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$sa&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'token_uri'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'exp'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$now&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'iat'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$now&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;There is no bug in it. &lt;code&gt;iat&lt;/code&gt; is now and &lt;code&gt;exp&lt;/code&gt; is an hour from now — exactly what Google asks for. But &lt;em&gt;now&lt;/em&gt; is whatever the machine says it is, and the machine was saying Thursday. So Google received a token that claimed to have been issued three days ago and to have expired two days and twenty-three hours ago. It refused, and its refusal named the two fields it was unhappy with: &lt;code&gt;iat&lt;/code&gt; and &lt;code&gt;exp&lt;/code&gt;. The error text was a precise description of the fault. I read it as a category — "auth error" — instead of as a sentence.&lt;/p&gt;

&lt;p&gt;The giveaway I walked past: &lt;strong&gt;every other check in the same run was fine.&lt;/strong&gt; IndexNow checked what had changed and correctly sent nothing, dev.to answered, the sitemap's 54 children were counted, the AI model probe came back. Not one of them signs anything, so not one of them cares what time my laptop thinks it is. The only check that broke was the only one holding a stopwatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two minutes that would have saved an hour
&lt;/h2&gt;

&lt;p&gt;If you get &lt;code&gt;invalid_grant&lt;/code&gt; from a Google, Apple or AWS token endpoint, before you touch the key:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Compare your clock to theirs.&lt;/strong&gt; &lt;code&gt;curl -sI https://www.google.com | grep -i ^date&lt;/code&gt; costs nothing and answers the question outright.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the token you actually sent.&lt;/strong&gt; The assertion is a JWT, so paste it into a &lt;a href="https://utilorax.com/tools/jwt-decoder" rel="noopener noreferrer"&gt;JWT decoder&lt;/a&gt; and look at the payload rather than at your code. Your code says what you meant; the token says what you sent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn &lt;code&gt;iat&lt;/code&gt; and &lt;code&gt;exp&lt;/code&gt; into dates.&lt;/strong&gt; They are Unix seconds, which are unreadable on sight — &lt;code&gt;1789938294&lt;/code&gt; looks like every other ten-digit number. A &lt;a href="https://utilorax.com/tools/unix-timestamp-converter" rel="noopener noreferrer"&gt;Unix timestamp converter&lt;/a&gt; turns them into a date you can compare to today's, and the gap is either zero or your whole answer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rule of thumb worth internalising: &lt;strong&gt;a signed short-lived token is a claim about time, so a rejected one is as likely to be a clock complaint as a credential complaint.&lt;/strong&gt; The word &lt;code&gt;invalid_grant&lt;/code&gt; is doing double duty for both, which is why the &lt;code&gt;error_description&lt;/code&gt; — not the &lt;code&gt;error&lt;/code&gt; — is the field to read.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one thing that went right
&lt;/h2&gt;

&lt;p&gt;The round did not print zero impressions. It said &lt;em&gt;Search Console could not be read today&lt;/em&gt;, kept the section, and put the raw HTTP body in it.&lt;/p&gt;

&lt;p&gt;That distinction has bitten this project repeatedly in the other direction: a sitemap check that reported unreachable children as empty, a robots.txt check that passed for six days on an attacker's file, a fetch failure that arrived as "no data". Every one of them turned a failure to measure into a measurement. Here the instrument said it was blind, said why, and handed me the sentence that contained the answer.&lt;/p&gt;

&lt;p&gt;I still needed three days of wrong clock before I read that sentence properly. But I got to read it, which is the whole difference.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(The clock has since resynced on its own. The same round, re-run: Search Console read, 46 pages snapshotted, nothing owed.)&lt;/em&gt;&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>devops</category>
      <category>php</category>
      <category>api</category>
    </item>
    <item>
      <title>The backdoor was a database row</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Mon, 14 Sep 2026 16:30:53 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/the-backdoor-was-a-database-row-8dk</link>
      <guid>https://dev.to/hammad4june1999/the-backdoor-was-a-database-row-8dk</guid>
      <description>&lt;p&gt;I cleaned a compromised hosting account on 5 September and wrote down, with some satisfaction, that it was done. On 11 September I found the rest of it.&lt;/p&gt;

&lt;p&gt;The part that bothers me is not that I missed something. It is &lt;em&gt;why&lt;/em&gt; I missed it: I had built three scanners, and the thing they were looking for was not in any file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a file scanner was asked to find
&lt;/h2&gt;

&lt;p&gt;This is the file that mattered, in full. 420 bytes, sitting where a WordPress core file should be:&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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="k"&gt;require_once&lt;/span&gt; &lt;span class="nv"&gt;$_SERVER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'DOCUMENT_ROOT'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/wp-load.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$encoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'_codehidden'&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="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$encoded&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;base64_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$encoded&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$code&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="nv"&gt;$tmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tmpfile&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;$tmp&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="nb"&gt;fwrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;stream_get_meta_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;include&lt;/span&gt; &lt;span class="nv"&gt;$meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'uri'&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;$tmp&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No obfuscation. No &lt;code&gt;eval&lt;/code&gt;. No hex-escaped strings, no &lt;code&gt;goto&lt;/code&gt; spaghetti, no variable function names. It reads well. Every one of my signature checks looked straight at it and had nothing to say, because there is nothing in it to flag — the malicious part is a &lt;strong&gt;row in &lt;code&gt;wp_options&lt;/code&gt;&lt;/strong&gt;, and the file is just the thing that fetches it.&lt;/p&gt;

&lt;p&gt;The row held 9,784 bytes of base64. Decoded: 7,338 bytes, whose first line is &lt;code&gt;eval(base64_decode('...'))&lt;/code&gt; and whose inner layer reads &lt;code&gt;HTTP_HOST&lt;/code&gt;, &lt;code&gt;HTTP_ACCEPT_LANGUAGE&lt;/code&gt; and &lt;code&gt;HTTP_REFERER&lt;/code&gt; and picks a remote host from a list of ROT13'd domains. A cloaker — the same kind that had been serving a Japanese shopping page to Googlebot from the main site. Stored in the database, so &lt;code&gt;grep -r&lt;/code&gt; across 161,762 files finds exactly nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The only authority that worked
&lt;/h2&gt;

&lt;p&gt;The file was one of ~2,000 in a WordPress install, and it looked plausible. What caught it is that WordPress publishes the md5 of every file it ships:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;https://api.wordpress.org/core/checksums/1.0/?version=7.1&amp;amp;locale=en_US
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives a map of &lt;code&gt;path =&amp;gt; md5&lt;/code&gt; for the whole core. Three questions fall out of it, and the third is the one that mattered:&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="nv"&gt;$sums&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$md5&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="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;is_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$root&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$path&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="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="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// deleted&lt;/span&gt;
    &lt;span class="k"&gt;elseif&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;md5_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$root&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$md5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$modified&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// changed&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// and the other direction: anything under wp-admin/ or wp-includes/&lt;/span&gt;
&lt;span class="c1"&gt;// that the checksum list has never heard of&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$onDisk&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$path&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="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;$sums&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="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$foreign&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$path&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;Across 18 installs on the account: 15 came back clean, and the three that did not told me exactly where to look. The loader showed up as &lt;strong&gt;MODIFIED&lt;/strong&gt;. Three empty &lt;code&gt;.php&lt;/code&gt; files in invented directories — &lt;code&gt;wp-includes/blocks/5HJlg/block/wp-utility.php&lt;/code&gt; and friends, all &lt;code&gt;chmod 777&lt;/code&gt; — showed up as &lt;strong&gt;FOREIGN&lt;/strong&gt;. Another site was missing 74 core files, which is why it had been answering 500 to every visitor.&lt;/p&gt;

&lt;p&gt;This is the difference between a signature and an authority. My signatures encode what I imagine malware looks like. The checksum list encodes what the software &lt;em&gt;is&lt;/em&gt;. Only one of those keeps working when the attacker writes something I did not imagine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things I got wrong, out loud
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;I cleared a file because it was not obfuscated.&lt;/strong&gt; On 5 September I read the site's &lt;code&gt;wp-login.php&lt;/code&gt;, saw no &lt;code&gt;eval&lt;/code&gt;, no base64, no hex, and moved on. The checksums flagged it. The diff turned out to be two blank lines and a missing newline — so my &lt;em&gt;conclusion&lt;/em&gt; was right, no credential stealer — but my &lt;em&gt;reason&lt;/em&gt; was worthless. "It does not look like malware" is not evidence. I only knew the file was clean once something authoritative told me what it should be.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I wrote a scanner rule that was certain and wrong.&lt;/strong&gt; It flagged images containing PHP, which is a real technique:&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$isImg&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;'~&amp;lt;\?php|&amp;lt;\?=|eval\s*\(~i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* CERTAIN */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That reported &lt;strong&gt;277 files&lt;/strong&gt;, labelled CERTAIN, across ordinary stock photos and one of Advanced Custom Fields' own PNGs. &lt;code&gt;&amp;lt;?=&lt;/code&gt; is three bytes. In a 700 KB binary, three particular bytes turn up by chance constantly. Retested with the five-byte &lt;code&gt;&amp;lt;?php&lt;/code&gt; alone: &lt;strong&gt;zero&lt;/strong&gt;. Nothing was deleted on that basis, but only because I checked before acting — and I had labelled the rule CERTAIN, which is precisely the label that invites not checking.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the database knew that the filesystem did not
&lt;/h2&gt;

&lt;p&gt;Once I was reading the database, the rest of the story was there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lms   patilari       31 Aug 05:53
lms   suxexnob        4 Sep 05:45     1 live session
lms   root_1n84gk9    4 Sep 07:13:21  2 live sessions
lms   jalejsul        8 Sep 09:09
site2 patilari       31 Aug 07:05:04
site2 root            2 Sep 01:25:30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Administrator accounts. &lt;code&gt;root_1n84gk9&lt;/code&gt; was created in the same minute as those 777 files. &lt;code&gt;patilari&lt;/code&gt; appears on two unrelated sites, and on the second one a file-manager plugin was installed &lt;strong&gt;nine seconds&lt;/strong&gt; after the account existed. And &lt;code&gt;jalejsul&lt;/code&gt; is dated 8 September — three days after I declared the account clean.&lt;/p&gt;

&lt;p&gt;That last row is the whole lesson. I had removed their files. They still had a way in, and it was never a file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take from it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prefer an authority to a signature.&lt;/strong&gt; For WordPress that is the checksums API; for your own code it is a manifest you generated somewhere the attacker cannot reach. A signature list only finds malware that resembles malware you have already seen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grep the datastore too.&lt;/strong&gt; Anything that can be &lt;code&gt;include&lt;/code&gt;d can be stored: a row, an object-cache entry, a serialised option, an S3 key. A scanner that only walks a filesystem is auditing half the machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When you label a check CERTAIN, go and verify it once on real data&lt;/strong&gt; — before you build anything that acts on it automatically. Mine would have deleted 277 innocent photos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean does not mean cleaned.&lt;/strong&gt; After an incident, re-verify against something the attacker could not have touched. A baseline taken on a compromised system certifies the compromise.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://utilorax.com" rel="noopener noreferrer"&gt;Utilorax&lt;/a&gt;, a set of free browser-based tools. If you want to check a file against a published hash without installing anything, the &lt;a href="https://utilorax.com/tools/md5-hash-generator" rel="noopener noreferrer"&gt;MD5 hash generator&lt;/a&gt; runs entirely in your browser — the file never leaves your machine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>php</category>
      <category>wordpress</category>
      <category>devops</category>
    </item>
    <item>
      <title>My robots.txt check passed for six days on an attacker's robots.txt</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Fri, 11 Sep 2026 05:15:36 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/my-robotstxt-check-passed-for-six-days-on-an-attackers-robotstxt-51jc</link>
      <guid>https://dev.to/hammad4june1999/my-robotstxt-check-passed-for-six-days-on-an-attackers-robotstxt-51jc</guid>
      <description>&lt;p&gt;Every morning a script checks my site's health and emails me. One of its lines, for six days running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fine. Sitemap index lists 54 child sitemaps holding 6,240 URLs in total,
robots.txt points at it, and every page earning impressions answers 200.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For those six days, this was my robots.txt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;User&lt;/span&gt;-&lt;span class="n"&gt;agent&lt;/span&gt;: *
&lt;span class="n"&gt;Allow&lt;/span&gt;: /

&lt;span class="n"&gt;Sitemap&lt;/span&gt;: &lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;utilorax&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;/?&lt;span class="n"&gt;sitemapindex&lt;/span&gt;.&lt;span class="n"&gt;xml&lt;/span&gt;
&lt;span class="n"&gt;Sitemap&lt;/span&gt;: &lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;utilorax&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;/?&lt;span class="n"&gt;sitemap249&lt;/span&gt;.&lt;span class="n"&gt;xml&lt;/span&gt;
&lt;span class="n"&gt;Sitemap&lt;/span&gt;: &lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;utilorax&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;/?&lt;span class="n"&gt;sitemap476&lt;/span&gt;.&lt;span class="n"&gt;xml&lt;/span&gt;
&lt;span class="n"&gt;Sitemap&lt;/span&gt;: &lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;utilorax&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;/?&lt;span class="n"&gt;sitemap531&lt;/span&gt;.&lt;span class="n"&gt;xml&lt;/span&gt;
&lt;span class="n"&gt;Sitemap&lt;/span&gt;: &lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;utilorax&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;/?&lt;span class="n"&gt;sitemap458&lt;/span&gt;.&lt;span class="n"&gt;xml&lt;/span&gt;
&lt;span class="n"&gt;Sitemap&lt;/span&gt;: &lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;utilorax&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;/?&lt;span class="n"&gt;sitemap522&lt;/span&gt;.&lt;span class="n"&gt;xml&lt;/span&gt;
&lt;span class="n"&gt;Sitemap&lt;/span&gt;: &lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;utilorax&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;/&lt;span class="n"&gt;goods&lt;/span&gt;.&lt;span class="n"&gt;php&lt;/span&gt;?&lt;span class="n"&gt;sitemap806&lt;/span&gt;.&lt;span class="n"&gt;xml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of those are mine. &lt;code&gt;goods.php&lt;/code&gt; is the giveaway: it is the shape of a Japanese shop-spam campaign, and the site had been hit by exactly that — a cloaker serving Googlebot a clothing store while people saw the real site. I cleaned that up on 5 September and wrote down, with some satisfaction, that the site was clean.&lt;/p&gt;

&lt;p&gt;It was not. The cloaker had left a second thing behind, and every instrument I had built to catch the first one looked straight at it and said &lt;em&gt;fine&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$robots&lt;/span&gt; &lt;span class="o"&gt;=&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="o"&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="s1"&gt;'https://utilorax.com/robots.txt'&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="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;str_contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$robots&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Sitemap:'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$problems&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'robots.txt does not point at a sitemap'&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;It asks whether robots.txt &lt;em&gt;mentions a sitemap&lt;/em&gt;. The attacker's file mentions seven. It passed with a margin.&lt;/p&gt;

&lt;p&gt;This is the same bug as a login check that verifies a password field is non-empty. The test was written against the failure I imagined — someone deleting the Sitemap line by accident — and not against the failure that happened, which is someone replacing it on purpose. &lt;strong&gt;A check for the presence of a field passes on anyone's field.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How a static file beat a dynamic one
&lt;/h2&gt;

&lt;p&gt;My real robots.txt is not a file. &lt;code&gt;index.php&lt;/code&gt; generates it, so the sitemap URL always matches the host:&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'robots.txt'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="cm"&gt;/* sitemap*.xml */&lt;/span&gt; &lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Sitemap&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;handle&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;exit&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;and &lt;code&gt;.htaccess&lt;/code&gt; sends a request there only when no real file exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{REQUEST_FILENAME} !-f
&lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{REQUEST_FILENAME} !-d
&lt;span class="nc"&gt;RewriteRule&lt;/span&gt; ^ index.php [L]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So dropping a plain &lt;code&gt;robots.txt&lt;/code&gt; into the web root does not fight the generator. It simply wins, silently, because that is what &lt;code&gt;!-f&lt;/code&gt; means. There is no conflict to notice.&lt;/p&gt;

&lt;p&gt;The file itself had been dressed for the job:&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="go"&gt;-r--r--r--  356 bytes   Jul 7 2024   robots.txt
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read-only, so a careless cleanup would fail. And dated &lt;strong&gt;July 2024&lt;/strong&gt; — on a domain registered in July 2026. The only file on the server older than the project, and the only file outside &lt;code&gt;vendor/&lt;/code&gt; set to 444.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three instruments, one blind spot each
&lt;/h2&gt;

&lt;p&gt;What bothers me is not that one check was weak. It is that I had built three, after the hack, specifically so this could not happen again — and each one had a different reason to wave it through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The health check&lt;/strong&gt; tested for a string the attacker supplied.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The integrity monitor&lt;/strong&gt; hashes every static file and reports anything that changes. I took its baseline &lt;em&gt;after&lt;/em&gt; the cleanup — so the planted robots.txt went into the baseline as a known-good file. From then on, the monitor's job was to guarantee the attacker's file stayed exactly as they left it. It did that perfectly.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A baseline taken on a compromised system does not detect the compromise. It certifies it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;The off-server verifier&lt;/strong&gt; compares live files against git and lists anything on the server that git does not know about. On 5 September it listed &lt;code&gt;robots.txt&lt;/code&gt;. I read the list, saw a handful of expected runtime files — caches, a ratings database, a speed-test payload — and filed &lt;code&gt;robots.txt&lt;/code&gt; with them, because a site having a robots.txt looked normal.&lt;/p&gt;

&lt;p&gt;It was the only item on that list that could not legitimately exist. The real one is generated; a file by that name should never be on disk at all. The verifier was right and I explained it away.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;The check now asks &lt;em&gt;whose&lt;/em&gt; sitemap, and treats a file existing as the finding:&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="nb"&gt;preg_match_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'~^\s*Sitemap:\s*(\S+)~mi'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$robots&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$sm&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$ours&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'https://utilorax.com/sitemap.xml'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$foreign&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sm&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="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;rtrim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$ours&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="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;$ours&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$sm&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="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="nv"&gt;$problems&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'robots.txt does not list our sitemap'&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="nv"&gt;$foreign&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                       &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$problems&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'robots.txt lists sitemaps that are not ours'&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;is_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$webRoot&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/robots.txt'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$problems&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'a static robots.txt exists — the real one is dynamic, so this is shadowing it'&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 verifier has a separate section for static files that shadow a generated route, so it cannot be mistaken for runtime clutter again. And a failed fetch is reported as &lt;em&gt;not evaluated today&lt;/em&gt;, rather than as &lt;em&gt;no sitemap&lt;/em&gt; — the old code turned an empty response into the same message as a real problem, which is part of how I found this: a false alarm made me open the actual file.&lt;/p&gt;

&lt;h2&gt;
  
  
  And then the fix was invisible
&lt;/h2&gt;

&lt;p&gt;I deleted the file, fetched &lt;code&gt;/robots.txt&lt;/code&gt;, and got my real one back. Then the new check ran and fired again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*** robots.txt lists 7 sitemap(s) that are not ours ***
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had tested with a cache-busting query string. The plain URL — the one crawlers request — was coming from the CDN:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;cf-cache-status: HIT
Cache-Control: max-age=14400
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The origin was fixed. The edge was still serving the attacker's file, for up to four more hours, to every crawler that asked. If I had kept the old string check, it would have passed that too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take from it
&lt;/h2&gt;

&lt;p&gt;Three things, and none of them are about robots.txt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Test for the right value, not for a value.&lt;/strong&gt; "Is there a sitemap line" and "is it our sitemap line" differ by exactly one attacker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never baseline a system you have just cleaned&lt;/strong&gt; without first proving it against something the attacker could not touch. My proof was git, and I read its answer too quickly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An "expected" list is where things hide.&lt;/strong&gt; Every exception you wave through once becomes invisible forever. If a file cannot legitimately exist, it does not belong in a list of files that usually do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And test your fix the way the crawler will see it, not the way you will.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://utilorax.com" rel="noopener noreferrer"&gt;Utilorax&lt;/a&gt;, a set of free browser-based tools. If you want to see what your own robots.txt actually tells a crawler — including which sitemaps it advertises — the &lt;a href="https://utilorax.com/tools/robots-txt-tester" rel="noopener noreferrer"&gt;robots.txt tester&lt;/a&gt; will show you.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>php</category>
      <category>devops</category>
    </item>
    <item>
      <title>Page 999 of my 5-page blog returned 200 and said it was canonical</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Tue, 08 Sep 2026 08:29:14 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/page-999-of-my-5-page-blog-returned-200-and-said-it-was-canonical-1d9i</link>
      <guid>https://dev.to/hammad4june1999/page-999-of-my-5-page-blog-returned-200-and-said-it-was-canonical-1d9i</guid>
      <description>&lt;p&gt;Search Console, this morning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Discovered - currently not indexed   20,852
Crawled - currently not indexed       1,582
Excluded by 'noindex' tag                63
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My sitemap has 6,240 URLs.&lt;/p&gt;

&lt;p&gt;Google knows about roughly 22,500 addresses on a site that publishes 6,240. Nobody typed the other sixteen thousand. Something on the site is generating them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pager
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;paged&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$page&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="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$perPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$all&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$all&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&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="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$total&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nv"&gt;$perPage&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nv"&gt;$page&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&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="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$pages&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;      &lt;span class="c1"&gt;// &amp;lt;- here&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'items'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;array_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$all&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$page&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="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$perPage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$perPage&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'total'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'pages'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$pages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'page'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$page&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;That clamp is the sensible thing to write. Somebody asks for page 40 of five, you show them page 5 rather than an empty screen or a stack trace. I would write it again.&lt;/p&gt;

&lt;p&gt;The blog has 5 pages. Here is what the server does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;page=1      HTTP 200   9 posts   canonical = /blog
page=5      HTTP 200   3 posts   canonical = /blog?page=5
page=6      HTTP 200   3 posts   canonical = /blog?page=6
page=999    HTTP 200   3 posts   canonical = /blog?page=999
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Page 6 does not exist. It answers 200, serves page 5's three posts, and tells Google &lt;strong&gt;this URL is the canonical version of this content&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So does page 7. And page 8. There is no largest one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The clamp was never the bug
&lt;/h2&gt;

&lt;p&gt;The clamp is right, and the caller is wrong, and it took me a while to see which was which.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;paged()&lt;/code&gt; returns &lt;code&gt;'page' =&amp;gt; 5&lt;/code&gt; when you ask for 999, because 5 is the page it is rendering. That is honest. But the template built its canonical from the &lt;strong&gt;request&lt;/strong&gt;, not the response:&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="s1"&gt;'canonical'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;abs_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/blog'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$page&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'?page='&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$page&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$page&lt;/code&gt; there is 999. It was never reconciled with what the pager decided.&lt;/p&gt;

&lt;p&gt;So the function quietly answered two different questions with one number, and the caller picked the wrong one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;which page am I showing?&lt;/em&gt; → 5&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;does the page you asked for exist?&lt;/em&gt; → &lt;strong&gt;never asked, never answered&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second question is the one a router needs, and there was no way to ask it. That is the actual defect: not a wrong value, a missing one.&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;$asked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&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="nv"&gt;$page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$page&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$asked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$pages&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="s1"&gt;'items'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;array_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$all&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$page&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="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$perPage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$perPage&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'pages'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$pages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'page'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// what is being rendered&lt;/span&gt;
    &lt;span class="s1"&gt;'requested'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$asked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// what was asked for&lt;/span&gt;
    &lt;span class="s1"&gt;'exists'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$asked&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nv"&gt;$pages&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$paged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BlogRepo&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;paged&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&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="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$paged&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'exists'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;notFound&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;and the canonical now uses &lt;code&gt;$paged['page']&lt;/code&gt; — the page that was served — rather than the number in the query string.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;page=5      HTTP 200   3 posts   canonical = /blog?page=5
page=6      HTTP 404
page=999    HTTP 404
page=abc    HTTP 200   9 posts   canonical = /blog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;?page=abc&lt;/code&gt; still resolves to page 1, which is right: it is not a request for a page that does not exist, it is a request that does not name a page at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this shape is worth recognising
&lt;/h2&gt;

&lt;p&gt;A clamp is a &lt;strong&gt;rendering&lt;/strong&gt; decision. It answers "what do I draw". The moment its output is used to decide "what do I say this URL is", it has silently become a &lt;strong&gt;routing&lt;/strong&gt; decision, and it is the wrong tool for that, because clamping's entire job is to erase the difference between valid and out-of-range input.&lt;/p&gt;

&lt;p&gt;That is fine inside a view. It is not fine anywhere near an HTTP status or a canonical tag, and those are usually written a long way from the function doing the clamping.&lt;/p&gt;

&lt;p&gt;The generalisation I would carry:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A function that normalises its input owes the caller the un-normalised value too.&lt;/strong&gt; Otherwise every caller that needed to know "was this valid?" has to re-derive it, and the ones that forget do not fail — they succeed at something slightly wrong.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;max()&lt;/code&gt; and &lt;code&gt;min()&lt;/code&gt; are where this hides. So is &lt;code&gt;?? $default&lt;/code&gt;, &lt;code&gt;intval()&lt;/code&gt;, and any &lt;code&gt;array_slice&lt;/code&gt; that silently returns fewer rows than you asked for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I cannot claim
&lt;/h2&gt;

&lt;p&gt;I found one unbounded URL source and I fixed it. I cannot tell you it accounts for 20,852, because Search Console's Page Indexing report does not expose the URLs through the API, and I am not going to work backwards from a number I want to explain.&lt;/p&gt;

&lt;p&gt;What I can say is that this one is unbounded, it was reachable, it answered 200, and it invited indexing — and that until this morning I would have told you the site had 6,240 URLs.&lt;/p&gt;

&lt;p&gt;If your Discovered count is wildly larger than your sitemap, the thing to look for is not a leak of &lt;em&gt;pages&lt;/em&gt;. It is a parameter with no upper bound that still answers 200.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'page 5 exists'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/blog?page=5'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'page 6 does not'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/blog?page=6'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'nor does page 999'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/blog?page=999'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'a non-numeric page is page 1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/blog?page=abc'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'/blog'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'page 5 is its own canonical'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="nf"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/blog?page=5'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'/blog?page=5'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fourth and fifth assertions matter more than they look. The obvious fix — 404 anything that is not a valid page number — breaks &lt;code&gt;?page=abc&lt;/code&gt;, which should be page 1. The two cases sit either side of the same &lt;code&gt;if&lt;/code&gt; and the temptation is to write one test.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://utilorax.com" rel="noopener noreferrer"&gt;Utilorax&lt;/a&gt;, a set of free browser-based tools. This one started with the &lt;a href="https://utilorax.com/tools/sitemap-url-counter" rel="noopener noreferrer"&gt;sitemap URL counter&lt;/a&gt;, which is how I knew the sitemap said 6,240 while Search Console said 22,500.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>php</category>
      <category>testing</category>
    </item>
    <item>
      <title>96% of my labels were translated. The other 4% could never be.</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Sat, 05 Sep 2026 11:45:58 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/96-of-my-labels-were-translated-the-other-4-could-never-be-5ffl</link>
      <guid>https://dev.to/hammad4june1999/96-of-my-labels-were-translated-the-other-4-could-never-be-5ffl</guid>
      <description>&lt;p&gt;My site ships in five languages. The calculator results go through a dictionary:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;T&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CH_CALC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;               &lt;span class="c1"&gt;// { "English label": "translation" }&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasOwnProperty&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&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;Look up the English, fall back to the English. It is about as simple as translation gets, and for most of the site it works.&lt;/p&gt;

&lt;p&gt;Then I read a CPM calculator row on the Spanish page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Impresiones            20,000
Coste por impresión    0.0025
Impressions per 50.00  20,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One row in English. Not a missing translation — I went and checked, and there was no key to add. There is no key that could be added.&lt;/p&gt;

&lt;h2&gt;
  
  
  The string does not exist until the reader types
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Impressions per &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imp&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;&lt;code&gt;Impressions per 50.00&lt;/code&gt;. Change the campaign cost to 75 and the label is &lt;code&gt;Impressions per 75.00&lt;/code&gt;. The dictionary would need an entry for every number anyone ever enters.&lt;/p&gt;

&lt;p&gt;This is a different category from "not translated yet", and I had been filing it under the same heading. Untranslated is a backlog. This is a &lt;strong&gt;defect&lt;/strong&gt;, and no amount of work on the dictionaries would ever have closed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How many
&lt;/h2&gt;

&lt;p&gt;I did not want to guess, and I could not eyeball it — 112 calculators. But the engines are pure functions of an input object, so I could run all of them and read what came out:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;strings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;primary&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;note&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trick is telling the two categories apart. Run each calculator &lt;strong&gt;twice with different numbers&lt;/strong&gt;: a label that changes between the runs is being assembled; a label that stays put is a fixed string that is simply missing.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nudge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&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;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&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;n&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reader-facing strings   fixed: 397   built from input: 29
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The first count was wrong
&lt;/h2&gt;

&lt;p&gt;29 was too many, and the reason is worth the detour.&lt;/p&gt;

&lt;p&gt;BMI at one weight says &lt;code&gt;Healthy weight&lt;/code&gt;. At double that weight it says &lt;code&gt;Overweight&lt;/code&gt;. The string changed between runs, so my sweep called it assembled — but it is not assembled at all. It is a categorical label from a fixed set of four, and every one of them is sitting in the dictionary already, translated.&lt;/p&gt;

&lt;p&gt;The nudge did not reveal interpolation. It revealed a &lt;strong&gt;threshold&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So the discriminator needs a second condition: the label must also contain a digit.&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="k"&gt;for &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;s&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;varying&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="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;varying&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reader-facing strings   fixed: 405   built from input: 21
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;21, across 18 calculators. And now the numbers say something:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;es:  missing but fixable: 15    unreachable by lookup: 21   (all of them)
fr:  missing but fixable: 16    unreachable by lookup: 21   (all of them)
pt:  missing but fixable: 14    unreachable by lookup: 21   (all of them)
zh:  missing but fixable: 10    unreachable by lookup: 21   (all of them)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fixed strings are about 96% translated — an ordinary, healthy backlog of a dozen or so. The assembled ones are 0% translated in all four languages, without exception.&lt;/p&gt;

&lt;p&gt;That is not a coincidence you have to interpret. &lt;strong&gt;A 96% column next to a 0% column is a mechanism, not a backlog.&lt;/strong&gt; If it were an effort problem the two would look alike. When one category is perfect and the neighbouring one is empty, something is preventing the work rather than nobody doing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is to translate the shape
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;TF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tpl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arguments&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="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;T&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tpl&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;\{(\d)\}&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;a&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;m&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;a&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;TF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Impressions per {0}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imp&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 key is now &lt;code&gt;Impressions per {0}&lt;/code&gt;, which is a constant. Twenty-three of them across the site.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;a === undefined ? m : ...&lt;/code&gt; matters more than it looks. Templates get edited in dictionaries, by people who are not looking at the call site, months later. If a translator adds a &lt;code&gt;{1}&lt;/code&gt; that no argument feeds, the honest outcome is a visible &lt;code&gt;{1}&lt;/code&gt; — not the word &lt;code&gt;undefined&lt;/code&gt; sitting in a financial result where a number belongs.&lt;/p&gt;

&lt;p&gt;And because an untranslated template falls back to English exactly like an untranslated string, this converts one label at a time with nothing to coordinate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying it end to end
&lt;/h2&gt;

&lt;p&gt;Not "does TF work" — of course TF works. The question is whether a Spanish reader now sees Spanish, which means running the real calculators against the real dictionary:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="nx"&gt;require&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;require&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CALC&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
    &lt;span class="nb"&gt;global&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dict&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;CH_CALC&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dict&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;return&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CALC&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;ENGINES&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;percentage-calculator     "200.00 plus 15%"       -&amp;gt; "200.00 más 15%"
discount-calculator       "You save 30.00"        -&amp;gt; "Ahorras 30.00"
roi-calculator            "Profit 3,000.00"       -&amp;gt; "Beneficio 3,000.00"
zakat-calculator          "Zakat due (2.5%)"      -&amp;gt; "Zakat a pagar (2.5%)"
paypal-fee-calculator     "Fee (2.9% + 0.30)"     -&amp;gt; "Comisión (2.9% + 0.30)"
cpm-calculator            "Impressions per 50.00" -&amp;gt; "Impresiones por 50.00"
emergency-fund-calculator "13,000.00 to go"       -&amp;gt; "Faltan 13,000.00"
...
20 labels now reach a Spanish reader in Spanish that could not before.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Doing this also flushed out that &lt;code&gt;T()&lt;/code&gt; reached straight for &lt;code&gt;window&lt;/code&gt;. The engines had never called it — only the renderer did — so no Node test had ever touched that line. Making labels translatable inside the engines was what dragged the browser into a test suite that had happily run without one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two assertions I would keep
&lt;/h2&gt;

&lt;p&gt;The mechanical ones are the ones that survive:&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="c1"&gt;// Every TF template must exist in all four dictionaries. A template nobody&lt;/span&gt;
&lt;span class="c1"&gt;// translated is the same English string with extra steps.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;templates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/TF&lt;/span&gt;&lt;span class="se"&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="se"&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;'/g&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&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="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;: all templates translated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

&lt;span class="c1"&gt;// And no translation may drop a {0}. That does not look wrong in review -&lt;/span&gt;
&lt;span class="c1"&gt;// it looks like a slightly shorter sentence - and it deletes the number&lt;/span&gt;
&lt;span class="c1"&gt;// from a financial result.&lt;/span&gt;
&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;: every placeholder survives&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dropped&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 template list is read out of the source rather than written down again in the test. A hand-maintained copy would drift, and it would drift &lt;em&gt;silently&lt;/em&gt;, in the direction of passing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take from it
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;+&lt;/code&gt; in a label is the whole bug. It is invisible in review because it is the most ordinary line of code in the world, and it does not fail — it produces exactly the right English string.&lt;/p&gt;

&lt;p&gt;If your product speaks more than one language, the pattern to grep for is a string literal next to a &lt;code&gt;+&lt;/code&gt; inside anything a reader will see. And if you already have translation coverage numbers, look at whether they cluster: a column at 96% and a column at 0% are not the same project running at different speeds. One of them is a wall.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://utilorax.com" rel="noopener noreferrer"&gt;Utilorax&lt;/a&gt;, a set of free browser-based tools. This came out of the &lt;a href="https://utilorax.com/calculators/cpm-calculator" rel="noopener noreferrer"&gt;CPM calculator&lt;/a&gt;, which now says "Impresiones por 50.00" to people reading it in Spanish.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>i18n</category>
      <category>webdev</category>
      <category>testing</category>
    </item>
    <item>
      <title>Break-even is not a profit, and my calculator said it was</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Fri, 04 Sep 2026 20:35:24 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/break-even-is-not-a-profit-and-my-calculator-said-it-was-pha</link>
      <guid>https://dev.to/hammad4june1999/break-even-is-not-a-profit-and-my-calculator-said-it-was-pha</guid>
      <description>&lt;p&gt;Refinance a 200,000 mortgage. Current rate 7%, 300 months left. New rate: 7%. New term: 25 years. Closing costs: 4,000.&lt;/p&gt;

&lt;p&gt;That is not a refinance. It is the loan you already have, plus a bill for four thousand.&lt;/p&gt;

&lt;p&gt;My calculator, in green:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Save $0.00/mo&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The number is right. The word is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  One ternary, three answers
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;monthlySave&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Save &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;monthlySave&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/mo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;good&lt;/span&gt;&lt;span class="dl"&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;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;monthlySave&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/mo more&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bad&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Did it go up or down" has three answers. A ternary has two branches. &lt;code&gt;&amp;gt;=&lt;/code&gt; is where the third one goes to die — it does not disappear, it gets quietly filed under the first.&lt;/p&gt;

&lt;p&gt;I went looking for the pattern and it was not one calculator, it was the house style. Every comparison tag on the site was written the same way, and every one of them was wrong at exactly one point:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;calculator&lt;/th&gt;
&lt;th&gt;at break-even, it printed&lt;/th&gt;
&lt;th&gt;and coloured it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ROI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Profit 0.00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;green&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;percentage change&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;+0.00%&lt;/code&gt; · Increase&lt;/td&gt;
&lt;td&gt;green&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CAGR&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Growth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;green&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;net worth&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Positive&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;green&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;capital gains tax&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Gain 0.00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;green&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;real rate of return&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Beats inflation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;green&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;refinance&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Save 0.00/mo&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;green&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;debt consolidation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.00/mo more&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;amber&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Seven of the eight called nothing a win. The eighth is more interesting, and I will come back to it.&lt;/p&gt;

&lt;p&gt;None of these are floating-point curiosities. Every input in that table is something a person types on purpose: buy at 10,000 and sell at 10,000. A salary that did not move. A 3% return in a 3% inflation year. Assets that exactly cover the debts — a number people work &lt;em&gt;towards&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Beats inflation" is the one that bothers me
&lt;/h2&gt;

&lt;p&gt;Most of these are a wrong word next to a right number, and the reader can see the 0.00 and correct for it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Beats inflation&lt;/code&gt; has no number next to it. There is a percentage in the headline, but the tag is the part that renders as a green pill and the part a reader takes away. If your return exactly matches inflation you have made precisely nothing in real terms, and the tool congratulated you.&lt;/p&gt;

&lt;p&gt;That is the difference between a cosmetic bug and a wrong answer: whether the reader has enough on screen to notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The eighth one, which was worse than wrong
&lt;/h2&gt;

&lt;p&gt;Debt consolidation compares a payment the user types against one it computes. Feed it a debt of 30,000 at 8% over 5 years, and it works out 608.29/mo. Type 608.29 back in — the number it just showed you — and:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;0.00/mo more&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The other branch. Not because the maths differs, but because the computed value is 608.2857..., and 608.29 minus that is a hair below zero.&lt;/p&gt;

&lt;p&gt;So at break-even this calculator did not have a wrong answer, it had &lt;em&gt;no stable answer&lt;/em&gt;. The same two numbers on screen could be labelled a saving or a cost depending on where the float landed. And nothing about the display would tell you which side you got, because both branches print &lt;code&gt;0.00&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rounding before comparing is the actual fix
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;up&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;flat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;down&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dp&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;r&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="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="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;up&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;good&lt;/span&gt;&lt;span class="dl"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;down&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bad&lt;/span&gt;&lt;span class="dl"&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;flat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;neutral&lt;/span&gt;&lt;span class="dl"&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;Three branches is the obvious half. The &lt;code&gt;toFixed&lt;/code&gt; is the half I nearly left out, and it is the one that actually fixes debt consolidation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The label has to be computed from the number the reader can see, not from the number you have.&lt;/strong&gt; If you print to the cent, compare to the cent. Otherwise you are asserting a difference that exists only in a register, in a pill sitting directly beside the digits that deny it.&lt;/p&gt;

&lt;p&gt;It also makes the boundary honest in the other direction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;newRate 6.999961   -&amp;gt;  Same payment — the costs buy nothing   [neutral]
newRate 6.9999     -&amp;gt;  Save 0.01/mo                           [good]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A saving of four-tenths of a cent a month is not a saving. It prints as &lt;code&gt;0.00&lt;/code&gt;. Now it is labelled as what it prints as.&lt;/p&gt;

&lt;h2&gt;
  
  
  The third colour
&lt;/h2&gt;

&lt;p&gt;There were three pill styles: green, amber, red. Nothing for "neither", so &lt;code&gt;&amp;gt;=&lt;/code&gt; had somewhere convenient to hide.&lt;/p&gt;

&lt;p&gt;Amber is not the answer. Amber means &lt;em&gt;pay attention, this might be a problem&lt;/em&gt;, and break-even is not a problem. It is a null result, and null results deserve to look like null results:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.result-tag.neutral&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--ink-soft&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;#fff&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;Grey says "neither" without saying "warning". The missing branch and the missing colour were the same omission — I had built a vocabulary with only two words in it and then had to say something with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the boundary and both sides of it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FLAT&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;roi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;              &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;5000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;final&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;5000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;               &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Broke even&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;percentageChange&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;50&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;50&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;                         &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No change&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;realReturn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;nominal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;inflation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;                 &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Exactly matches inflation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;refinance&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;200000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currentRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;7&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currentMonths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;300&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                           &lt;span class="na"&gt;newRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;7&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;newYears&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;25&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;costs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;4000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Same payment — the costs buy nothing&lt;/span&gt;&lt;span class="dl"&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;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;engine&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="nx"&gt;want&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;FLAT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;: says "&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;want&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="nf"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;E&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;engine&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="nx"&gt;want&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;: is not coloured as good news&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="nf"&gt;level&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;E&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;engine&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;neutral&lt;/span&gt;&lt;span class="dl"&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;Two assertions per case, because they fail for different reasons: someone can fix the word and leave the pill green, and the pill is the part people see first.&lt;/p&gt;

&lt;p&gt;Then the same number of assertions on either side of the boundary. A three-way fix is one &lt;code&gt;&amp;gt;&lt;/code&gt; away from breaking the direction it used to get right, and a test suite that only checks the newly-fixed case will not notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take from it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;&amp;gt;=&lt;/code&gt; in a place where equality means something different from "greater" is a bug you write with your fingers, not your head. It reads as a rounding decision — &lt;em&gt;do I include the endpoint&lt;/em&gt; — and it is not. It is a decision about whether a third outcome exists.&lt;/p&gt;

&lt;p&gt;The tell is the naming. When the two branches are &lt;code&gt;good&lt;/code&gt; and &lt;code&gt;bad&lt;/code&gt;, ask what the boundary between them is called. If it has a name — break-even, no change, flat, matches, unchanged, exactly covers — then it is a case, and you have not written it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://utilorax.com" rel="noopener noreferrer"&gt;Utilorax&lt;/a&gt;, a set of free browser-based tools. This came out of the &lt;a href="https://utilorax.com/calculators/percentage-change-calculator" rel="noopener noreferrer"&gt;percentage change calculator&lt;/a&gt;, which no longer tells you that nothing is an increase.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>testing</category>
      <category>ux</category>
    </item>
    <item>
      <title>Every field was filled in. The error message said otherwise.</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Fri, 04 Sep 2026 05:08:44 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/every-field-was-filled-in-the-error-message-said-otherwise-488o</link>
      <guid>https://dev.to/hammad4june1999/every-field-was-filled-in-the-error-message-said-otherwise-488o</guid>
      <description>&lt;p&gt;My car loan calculator would not answer the most ordinary question anyone asks about a car loan.&lt;/p&gt;

&lt;p&gt;Vehicle price 25,000. Interest 8%. Term 5 years. Down payment: nothing, because I am putting nothing down.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Please fill in all fields with valid numbers.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every field is filled in. Three of them hold numbers and the fourth holds the truth. The tool has everything it needs, and it has told the user that they are the problem.&lt;/p&gt;

&lt;p&gt;Here is the whole of it:&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="nx"&gt;mortgage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;function &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="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&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="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&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="nx"&gt;down&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&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="nx"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&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="nx"&gt;years&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;12&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="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&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="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;err&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;&lt;code&gt;parseFloat('')&lt;/code&gt; is &lt;code&gt;NaN&lt;/code&gt;. &lt;code&gt;NaN - 25000&lt;/code&gt; is &lt;code&gt;NaN&lt;/code&gt;. And &lt;code&gt;NaN &amp;gt; 0&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;, quietly, with no warning and no exception — so the empty field walks straight into the same branch as genuinely broken input and comes out wearing its message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three different situations, one guard
&lt;/h2&gt;

&lt;p&gt;That one &lt;code&gt;if&lt;/code&gt; is doing three unrelated jobs, and I only noticed when I started listing what it rejects:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;what the user did&lt;/th&gt;
&lt;th&gt;what they meant&lt;/th&gt;
&lt;th&gt;what they got&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;left down payment blank&lt;/td&gt;
&lt;td&gt;"nothing down"&lt;/td&gt;
&lt;td&gt;fill in all fields&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;down payment = price&lt;/td&gt;
&lt;td&gt;"I'm paying cash"&lt;/td&gt;
&lt;td&gt;fill in all fields&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;down payment &amp;gt; price&lt;/td&gt;
&lt;td&gt;"my trade-in is worth more"&lt;/td&gt;
&lt;td&gt;fill in all fields&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;price blank&lt;/td&gt;
&lt;td&gt;actually missing&lt;/td&gt;
&lt;td&gt;fill in all fields&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the only one where the message is even close to true. The first three are not errors at all. They are &lt;em&gt;answers&lt;/em&gt;: there is no loan, there is no interest, the monthly payment is zero, and in the third case the dealer owes you money.&lt;/p&gt;

&lt;p&gt;The calculator knew all of that. It threw it away because &lt;code&gt;p &amp;lt;= 0&lt;/code&gt;, and &lt;code&gt;p &amp;lt;= 0&lt;/code&gt; had been quietly redefined as "the input is bad" when what it actually means is "there is nothing to finance".&lt;/p&gt;

&lt;h2&gt;
  
  
  I checked whether it was just this one
&lt;/h2&gt;

&lt;p&gt;It is one line in one engine, so before writing anything I wanted to know how much of the site had the same shape. There are 112 calculators sharing 111 engines, all pure functions of an input object, so sweeping them is cheap:&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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;defs&lt;/span&gt;&lt;span class="p"&gt;))&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="p"&gt;{};&lt;/span&gt;                      &lt;span class="c1"&gt;// each calculator's own defaults&lt;/span&gt;
  &lt;span class="k"&gt;for &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;i&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inputs&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="nx"&gt;i&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="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;for &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;i&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;)&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;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;engine&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="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&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;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;refused&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&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;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&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;Zero, not blank. Zero is unambiguously a number, and the field is unambiguously filled in, so any complaint about empty fields is unambiguously wrong.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;calculators swept        : 112
field = 0 refused        : 123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and of those 123 refusals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 114  Please fill in all fields with valid numbers.
   2  Enter two non-zero whole numbers.
   2  Enter whole numbers; denominators cannot be zero.
   1  a cannot be 0 — that is a linear equation, not quadratic.
   1  Original value cannot be zero.
   ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nine at the bottom are fine. They refuse, they say what they refused, and one of them teaches you something. The 114 above them are a sentence that is false on its face, shown to someone who has already done the thing it is asking for — which sends them off to check the three fields that are correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  But be honest about the scale
&lt;/h2&gt;

&lt;p&gt;It is tempting to stop there and call it 114 bugs. It is not 114 bugs, and the difference matters.&lt;/p&gt;

&lt;p&gt;Most of those 114 are refusals that &lt;em&gt;should&lt;/em&gt; happen. A BMI with height 0, a bill split between 0 people, a loan of 0 — those have no answer, and refusing is right. Only the message is wrong.&lt;/p&gt;

&lt;p&gt;The smaller and worse category is the one where the tool could have answered and did not. I could not detect that mechanically, so I picked it out by hand: fields that are an &lt;em&gt;adjustment&lt;/em&gt; to a main quantity rather than the quantity itself — a down payment, a fee, an employer match, a trade-in. Leaving one of those out is a statement, not an omission.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;optional-adjustment fields that refuse a blank or a 0:   4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four. Out of 112 calculators. That is a small number and I am not going to dress it up as a big one.&lt;/p&gt;

&lt;p&gt;Two of the four were the mortgage calculator and the car loan calculator — the two most-visited calculators on the site. Rarity and importance are different axes, and a bug census that only counts is measuring the wrong one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, which is mostly just splitting the line up
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&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="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;down&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;optional&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="nx"&gt;down&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;r&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&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="nx"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;n&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&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="nx"&gt;years&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;12&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="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;price&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Enter a price greater than zero.&lt;/span&gt;&lt;span class="dl"&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;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;     &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Enter an interest rate.&lt;/span&gt;&lt;span class="dl"&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="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Enter a loan term of at least one year.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;down&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;p&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                       &lt;span class="c1"&gt;// paying cash, or a trade-in worth more&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Monthly payment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&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="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;with&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&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;Splitting the guard is not tidiness. A single message &lt;em&gt;could not&lt;/em&gt; name the field at fault, because the code had never worked out which field it was. The specific messages are not a wording improvement layered on top of the old check — they only become possible once the check is separated.&lt;/p&gt;

&lt;p&gt;The part I find hardest to defend is that &lt;code&gt;optional()&lt;/code&gt; was already in the file. Here is &lt;code&gt;retirement401k&lt;/code&gt;, unchanged, before I touched anything:&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="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;bal&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&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="nx"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// optional&lt;/span&gt;
    &lt;span class="nx"&gt;salary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&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="nx"&gt;salary&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;cpct&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&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="nx"&gt;contribution&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;     &lt;span class="c1"&gt;// not optional. same line.&lt;/span&gt;
    &lt;span class="nx"&gt;mpct&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&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="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// optional&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Opening balance: optional. Employer match: optional. Contribution, sitting between them in the same &lt;code&gt;var&lt;/code&gt; statement: not. Contributing nothing this year is a number, and the projection still exists. The right idiom was six characters away and had been for months — because it was an idiom and not a name. Naming it is most of what stops it drifting again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the two halves separately
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nothing down is answered, not refused&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;car&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;down&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;506.91&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nothing down equals an explicit zero&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;car&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;down&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="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;car&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;down&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})));&lt;/span&gt;
&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paying cash costs nothing a month&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;car&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;down&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;25000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0.00&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;trade-in over the price shows change&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="nf"&gt;row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;over&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Left over&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;5,000.00&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;missing price names the price&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;car&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;price&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="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Enter a price greater than zero.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;missing rate names the rate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="nf"&gt;car&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;rate&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="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Enter an interest rate.&lt;/span&gt;&lt;span class="dl"&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 groups, because they fail for different reasons. The first breaks if someone reintroduces a truthiness check on an optional field. The second breaks if someone collapses the guards back into one line to save space. A single "does it error" assertion would pass while either happened.&lt;/p&gt;

&lt;p&gt;And one assertion that is about the message rather than the maths:&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;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;the fallback no longer claims fields are empty&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sr"&gt;/fill in all fields/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;generic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I would take from it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;An error message is an assertion about the world, and it can be false.&lt;/strong&gt; &lt;code&gt;NaN &amp;gt; 0 === false&lt;/code&gt; is not a bug in JavaScript; treating that &lt;code&gt;false&lt;/code&gt; as "the user made a mistake" is a bug in me. The code never established that a field was empty. It inferred it from a comparison that fails for four different reasons, and reported the inference as a fact, in the second person.&lt;/p&gt;

&lt;p&gt;The one to watch for is a guard whose condition is about the &lt;em&gt;result&lt;/em&gt; — &lt;code&gt;p &amp;lt;= 0&lt;/code&gt;, &lt;code&gt;!isFinite(x)&lt;/code&gt;, &lt;code&gt;total === 0&lt;/code&gt; — but whose message is about the &lt;em&gt;input&lt;/em&gt;. Those two are not connected by anything except habit, and every so often the result is zero because zero is the answer.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://utilorax.com" rel="noopener noreferrer"&gt;Utilorax&lt;/a&gt;, a set of free browser-based tools. This came out of the &lt;a href="https://utilorax.com/calculators/car-loan-calculator" rel="noopener noreferrer"&gt;car loan calculator&lt;/a&gt;, which will now tell you what a car costs when you put nothing down — and what it costs when you pay cash, which is nothing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>ux</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
