<?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: MinSoo Kim</title>
    <description>The latest articles on DEV Community by MinSoo Kim (@danorie).</description>
    <link>https://dev.to/danorie</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%2F4116712%2F925d8dc1-fd2b-4774-b495-dcbef4eb87e5.png</url>
      <title>DEV Community: MinSoo Kim</title>
      <link>https://dev.to/danorie</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/danorie"/>
    <language>en</language>
    <item>
      <title>Our monitor said 0 reviews for three days. The review was in the HTML.</title>
      <dc:creator>MinSoo Kim</dc:creator>
      <pubDate>Wed, 04 Nov 2026 12:03:42 +0000</pubDate>
      <link>https://dev.to/danorie/our-monitor-said-0-reviews-for-three-days-the-review-was-in-the-html-51mh</link>
      <guid>https://dev.to/danorie/our-monitor-said-0-reviews-for-three-days-the-review-was-in-the-html-51mh</guid>
      <description>&lt;p&gt;Last week two of our scripts reported numbers that looked clean. One said an app had 0 reviews, three days running. The other said it had received 40 photos with 0 failures. Neither told the whole story. Both numbers meant the script couldn't read something, and neither one said so.&lt;/p&gt;

&lt;p&gt;This follows my posts on failures hidden behind exit code 0 and on checks that borrowed from the code they checked. These zeros came from the readers.&lt;/p&gt;

&lt;p&gt;At the end I'll ask which warning sign you trust most, because we ran into three in one week.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero reviews, three days running
&lt;/h2&gt;

&lt;p&gt;One of our Android apps launched on October 28, and a script checked it six times over 72 hours: search rank, installs, rating and reviews. At 24 hours it wrote "reviews: 0". At 48 hours, the same. A separate morning check on the 31st agreed.&lt;/p&gt;

&lt;p&gt;The app had a review, posted on launch day. We found it when someone opened the review page in the developer console.&lt;/p&gt;

&lt;p&gt;The two readers failed in different ways. The rank script counted reviews from the "N reviews" label, and the store page only shows that label once an app has a public rating. With one review, the page still showed no rating, so there was no label, so 0. The morning check looked for review IDs that start with &lt;code&gt;gp:&lt;/code&gt;. This review's ID was a UUID.&lt;/p&gt;

&lt;p&gt;The part that stung: from the third reading on, we kept the raw HTML. The review is in the October 29 and October 30 files. It sat on disk for two days, and the rank script read both files as nothing.&lt;/p&gt;

&lt;p&gt;The reader now finds reviews by shape instead of by label or ID format: a star value from 1 to 5, a body, a timestamp and an app version, inside the page's embedded data block. We also treat the console as the source for review and install counts. The public page is a second reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  40 received, 0 failed
&lt;/h2&gt;

&lt;p&gt;Another script collects the day's photos from a web list of notes, for our blog drafts. On October 29 it printed "received 40, failed 0" (translated). Forty photos came in, 20 from each of two notes. The third note's 22 photos were missing entirely, and so were 4 more from the second note: 26 photos, every one below row 40.&lt;/p&gt;

&lt;p&gt;The list's first screen holds 40 rows, and the script read what was rendered and stopped there, so nothing failed and the failure count was honest, but the received count was just the screen size. The run the day before had also received exactly 40. We never checked whether anything was missing that day.&lt;/p&gt;

&lt;p&gt;The fix calls &lt;code&gt;scrollIntoView({block: 'end'})&lt;/code&gt; on the last row and reads again while the list keeps growing. It stops when the last row's upload date is before that day, after 3 scrolls with no new rows, or after 20 scrolls. It counts rows as it goes, in case the list drops rows that scroll out of view. And it prints how many rows it read and why it stopped. The rerun that evening printed "first screen 40 rows → 80 rows, 2 scrolls, stopped at a row from the day before" (translated), then 26 new photos and 0 failures. I'd take that line over a clean 40 every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two numbers that can't both be true
&lt;/h2&gt;

&lt;p&gt;The same launch log had a third number worth doubting. The store page showed "0+" installs for all 72 hours. One review means at least one install, so "0+ installs" and "1 review" can't both describe the same app at the same time.&lt;/p&gt;

&lt;p&gt;This time the parser was right. The page's data block and the visible label both said "0+". The public figure updates late. But the contradiction is what made us look, and looking took two places and a minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three signs, in the order they'd have helped
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A count that equals a page size.&lt;/strong&gt; 40 of 40 should have been a question, not a pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A zero that lasts.&lt;/strong&gt; Three readings of 0 in a row is a reason to check a second source once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two numbers that contradict.&lt;/strong&gt; A review without an install means one figure is stale or misread.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these needs a new tool. Each is a line in the output: how many rows the reader saw and why it stopped, which source a zero came from, and a flag when two figures disagree.&lt;/p&gt;

&lt;h2&gt;
  
  
  A question
&lt;/h2&gt;

&lt;p&gt;When a script hands you a suspiciously clean number, what tips you off: a count that matches a page or batch size, a zero that has lasted too long, or two numbers that can't both be true? Which one has actually caught something for you?&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;MDN, Element.scrollIntoView() (the &lt;code&gt;block&lt;/code&gt; option sets vertical alignment in the scrolling container): &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView" rel="noopener noreferrer"&gt;https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView&lt;/a&gt;, accessed 2026-11-02&lt;/li&gt;
&lt;li&gt;Everything else: our launch log, commit history and lessons log, October 28 to 31.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;This post was drafted with AI help. The numbers and facts come from my own logs and repo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>monitoring</category>
      <category>python</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Three checks said zero. Each one borrowed from the code it checked.</title>
      <dc:creator>MinSoo Kim</dc:creator>
      <pubDate>Wed, 28 Oct 2026 12:03:29 +0000</pubDate>
      <link>https://dev.to/danorie/three-checks-said-zero-each-one-borrowed-from-the-code-it-checked-5dd8</link>
      <guid>https://dev.to/danorie/three-checks-said-zero-each-one-borrowed-from-the-code-it-checked-5dd8</guid>
      <description>&lt;p&gt;Last week three checks in our repos reported zero problems, and all three were wrong. Each one had borrowed something from the code it was supposed to check: a default scope, a layout function, a definition. When the code was wrong, the check agreed with it.&lt;/p&gt;

&lt;p&gt;This follows my post about failures the shell hid behind exit code 0. These are failures the checks themselves couldn't see.&lt;/p&gt;

&lt;p&gt;At the end I'll ask where the expected values in your tests come from, because that's where all three of ours went wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  A flag nobody read
&lt;/h2&gt;

&lt;p&gt;One of our sites has a checker that flags numbers in product descriptions that can't be traced to six verified fields in our data. Its &lt;code&gt;--build&lt;/code&gt; flag was meant to scan every built page. Nothing in &lt;code&gt;main&lt;/code&gt; ever read it.&lt;/p&gt;

&lt;p&gt;With or without the flag, the checker scanned the same file: the product data, 856 records. It printed &lt;code&gt;items=856 bad_pages=0&lt;/code&gt;, and we read that as "build: 0". The label said pages, but the variable behind it was &lt;code&gt;bad_items&lt;/code&gt;. Category and guide pages had never been in scope.&lt;/p&gt;

&lt;p&gt;On October 23 we went through that category and guide text and took out the figures we couldn't source: nine places across four files, plus a generator that would have put one sentence back. Three days later, work on one guide page turned up more: a hard-coded price table, and two organizations credited with test findings neither had published.&lt;/p&gt;

&lt;p&gt;On the 23rd we also blocked the flag instead of implementing it (comments translated, message shortened):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Declared but never implemented. Left alone, the data-scan count reads as "build: 0".
&lt;/span&gt;    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--build doesn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t exist yet. Don&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t produce a 0 with this flag.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'd rather have a flag that refuses to run than one that hands back a zero it never earned.&lt;/p&gt;

&lt;h2&gt;
  
  
  A check that used the app's layout
&lt;/h2&gt;

&lt;p&gt;One of our apps writes month-by-month readings and shows them as a calendar card, one row per month. A change on October 23 moved a paragraph meant for a quiet month out of its row and up to the top of the list. QA drew 300 random birth charts, read each one on 2 dates through 3 answer paths, and in the year-ahead reading found the paragraph out of place in all 1,800 readings. Not most. All. The other two calendar readings had 0, and so did the build before the change.&lt;/p&gt;

&lt;p&gt;Two checks missed it anyway. The UI test runner saved a screenshot that showed the paragraph in the wrong place. Its check, named for the twelve-month section, only looked for the section's heading, so it passed. The independent sweep put text into months by calling the app's own &lt;code&gt;monthsLayout&lt;/code&gt; function. Wherever the app put the paragraph, the sweep put it too.&lt;/p&gt;

&lt;p&gt;The sweep's "independent" table of advice directions had the same problem. Of the 318 app advice templates QA could match to it, 317 pointed the same way as the app's own table. That's 99.7%, or in the QA report's words, the app's judgment written down twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  A test with the code's definition
&lt;/h2&gt;

&lt;p&gt;Another app suggests Korean names and scores them with a traditional rule. You add up the brush strokes of the characters and check four sums against a list of numbers considered unlucky. In our data, 128 characters have two accepted stroke counts, depending on the source.&lt;/p&gt;

&lt;p&gt;The rule we shipped: a name is recommended only if it's clean under both counts. The code checked two cases, all characters on their main count or all on their alternate count, and the test, written from the same idea of "both counts", checked exactly those two. All 463 tests passed.&lt;/p&gt;

&lt;p&gt;A full name with two such characters has four combinations, not two. For one surname, 6 of the 30 recommended names came out unlucky under at least one of our three sources. The test meant to guard the rule used the code's own definition, and it covered only three surnames.&lt;/p&gt;

&lt;p&gt;The fix checks every combination of every character's values. The new test doesn't borrow the code's definition: it reads the source table directly and checks every combination for all 135 single-character surnames.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the three had in common
&lt;/h2&gt;

&lt;p&gt;Each check took something from the code under test, so it couldn't disagree with the code.&lt;/p&gt;

&lt;p&gt;We wrote down three rules after this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An independent check may call the app to draw the screen, but it judges with its own code and borrows no layout or judgment function. Screen order comes from the rendered text. A judgment table should be written by a person and compared against the app, not derived from it. We haven't fully met that last part yet.&lt;/li&gt;
&lt;li&gt;A check prints what it actually looked at, so a zero comes with its denominator.&lt;/li&gt;
&lt;li&gt;A new check runs first against a build we know is broken, and we write down how many reds it finds. In the calendar app, the last two new test files went 6 of 6 and 3 of 4 red on the build before their fix. If a check finds none there, it isn't a check yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A question
&lt;/h2&gt;

&lt;p&gt;When you write the expected value for a test, where does it come from? A table someone worked out by hand, a second implementation, or the same helper the code uses? And which of those has let a bug through for you?&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Python docs, argparse actions (&lt;code&gt;store_true&lt;/code&gt; stores True when the flag is given, False otherwise): &lt;a href="https://docs.python.org/3/library/argparse.html#action" rel="noopener noreferrer"&gt;https://docs.python.org/3/library/argparse.html#action&lt;/a&gt;, accessed 2026-10-26&lt;/li&gt;
&lt;li&gt;Everything else: our QA reports, lessons log and commit history from October 22 and 23.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;This post was drafted with AI help. The numbers and facts come from my own logs and repo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>qa</category>
      <category>python</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Three failures in five days, and the next step ran anyway</title>
      <dc:creator>MinSoo Kim</dc:creator>
      <pubDate>Wed, 21 Oct 2026 12:03:20 +0000</pubDate>
      <link>https://dev.to/danorie/three-failures-in-five-days-and-the-next-step-ran-anyway-50he</link>
      <guid>https://dev.to/danorie/three-failures-in-five-days-and-the-next-step-ran-anyway-50he</guid>
      <description>&lt;p&gt;Between October 14 and October 18, three failures on my machine didn't stop the step that came after them. A stats collector died with exit code 2, and the scheduler logged 0. A check script crashed on an encoding error, and the shell said 0. A test run failed and even printed &lt;code&gt;rc=1&lt;/code&gt;, and the commit and the push ran anyway.&lt;/p&gt;

&lt;p&gt;The failures themselves were ordinary. The trouble sat between each command and the next step: twice a layer we had added to make a job quieter or politer, and once a single character.&lt;/p&gt;

&lt;p&gt;I'll go through all three, and at the end I'd like to hear where that gap sits in your own setup.&lt;/p&gt;

&lt;p&gt;Some context first. This is the same Windows machine where about twenty agent sessions share one git repo and a set of scheduled jobs. Sessions report "passed" or "failed" to each other, and those reports are supposed to come from exit codes. A fake 0 doesn't stay local.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrapper our own rules recommended
&lt;/h2&gt;

&lt;p&gt;Since September 30 our shared rules had said that long-running Python jobs should run at below-normal priority, and they listed &lt;code&gt;start /belownormal&lt;/code&gt; as one way to do it. The collector's scheduled slot did exactly that from Git Bash (the doubled slashes stop Git Bash from turning &lt;code&gt;/c&lt;/code&gt; into a path):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cmd //c start //belownormal //wait //b python collector.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On October 14 a stats collector hit a 403 on an item it wasn't allowed to read. It stopped after 2 of 42 items and exited with code 2. The scheduled slot recorded 0.&lt;/p&gt;

&lt;p&gt;That suggestion had been in the rules for fourteen days. I haven't dug into why &lt;code&gt;start&lt;/code&gt; drops the child's code. The observed result was enough to change the rule. The default is now to lower the priority inside the script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;psutil&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Process&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;nice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;psutil&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BELOW_NORMAL_PRIORITY_CLASS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a job has to be wrapped, the wrapper hands the code back explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cmd //v:on //c &lt;span class="s2"&gt;"start /belownormal /wait /b python collector.py &amp;amp; exit !errorlevel!"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The collector itself was changed too, to skip items it can't read and exit non-zero instead of dying. The next morning it ran through the new wrapper. It skipped six items, and the slot recorded 4. That's a non-zero code, and for once a true one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two tails on the same day
&lt;/h2&gt;

&lt;p&gt;On October 15 &lt;code&gt;| tail&lt;/code&gt; fooled us twice.&lt;/p&gt;

&lt;p&gt;A long background job was launched as &lt;code&gt;job | tail -60&lt;/code&gt; with the result going to a file. The file sat at 0 bytes, because &lt;code&gt;tail&lt;/code&gt; prints nothing until its input ends. The job looked dead. It wasn't, and it nearly got restarted mid-run.&lt;/p&gt;

&lt;p&gt;The same day, the coordinating session ran a check script through &lt;code&gt;| tail&lt;/code&gt;. The script crashed with a cp949 encoding error, Windows' Korean code page. The pipeline exited 0, because a pipeline reports the status of its last command, and &lt;code&gt;tail&lt;/code&gt; had done its job fine.&lt;/p&gt;

&lt;p&gt;That day the lesson went into our lessons file: write long output straight to a file, and use &lt;code&gt;tail&lt;/code&gt; only to read that file afterwards. It went in as a candidate. It did not go into the rules every session reads first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three days later, a semicolon
&lt;/h2&gt;

&lt;p&gt;On October 18 a session working on one of our apps did what the lesson said. It sent the test output to a file and printed the exit code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx vitest run &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; vt.log 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;&lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"Tests "&lt;/span&gt; vt.log&lt;span class="p"&gt;;&lt;/span&gt; git add ... &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit ...&lt;span class="p"&gt;;&lt;/span&gt; git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The screen said &lt;code&gt;rc=1&lt;/code&gt;. One test out of 429 had failed: a heavy check that runs 3,000 readings went past vitest's default 5-second timeout in the full run. The commit was joined to the test with &lt;code&gt;;&lt;/code&gt;, not &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;, so it ran anyway, and so did the push. The exit code was asked for and printed. Nothing was gated on it.&lt;/p&gt;

&lt;p&gt;A minute later the next commit gave the heavy checks a 60-second limit, and that one went out through &lt;code&gt;vitest &amp;gt; file &amp;amp;&amp;amp; typecheck &amp;amp;&amp;amp; commit&lt;/code&gt;. The October 15 lesson was about where output goes. It said nothing about what has to be true before the next command runs.&lt;/p&gt;

&lt;p&gt;The same afternoon a session on a different app committed with three type errors. That one is simpler: no check ran at all, so nobody asked for an exit code.&lt;/p&gt;

&lt;p&gt;Since October 19 this is in the shared rulebook every session reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; test.log 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"..."&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &amp;lt;paths&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output goes to a file, and the commit hangs off &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;. A report can say "passed" only when the exit code was 0.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the three have in common
&lt;/h2&gt;

&lt;p&gt;The failing commands never lied. The collector, the check script and the tests all exited non-zero. What went wrong sat between that code and the next step: a priority wrapper, a &lt;code&gt;| tail&lt;/code&gt;, a &lt;code&gt;;&lt;/code&gt;. The first two were added to make something nicer. The third is one character. Each let the next step run as if nothing had happened.&lt;/p&gt;

&lt;p&gt;Bash has &lt;code&gt;set -o pipefail&lt;/code&gt; for the pipe case, and &lt;code&gt;set -e&lt;/code&gt; stops a script at the first failing command. Neither is in our rules. A line that ends in &lt;code&gt;&amp;amp;&amp;amp; git commit&lt;/code&gt; is easier to check by reading it once, though I'm not sure that holds up as scripts get longer.&lt;/p&gt;

&lt;h2&gt;
  
  
  A question
&lt;/h2&gt;

&lt;p&gt;Where in your setup does a failure still come back as 0, or get printed and ignored? A wrapper, a pipe, a &lt;code&gt;;&lt;/code&gt; where &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; should be, an &lt;code&gt;|| true&lt;/code&gt; in CI, a retry script that swallows the last error? I'm collecting the places I haven't looked yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Bash manual, Pipelines (a pipeline's exit status is the last command's unless &lt;code&gt;pipefail&lt;/code&gt; is set): &lt;a href="https://www.gnu.org/software/bash/manual/html_node/Pipelines.html" rel="noopener noreferrer"&gt;https://www.gnu.org/software/bash/manual/html_node/Pipelines.html&lt;/a&gt;, accessed 2026-10-19&lt;/li&gt;
&lt;li&gt;Bash manual, The Set Builtin (&lt;code&gt;-e&lt;/code&gt; and &lt;code&gt;-o pipefail&lt;/code&gt;): &lt;a href="https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html" rel="noopener noreferrer"&gt;https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html&lt;/a&gt;, accessed 2026-10-19&lt;/li&gt;
&lt;li&gt;Vitest config, &lt;code&gt;testTimeout&lt;/code&gt; (default 5,000 ms in Node.js): &lt;a href="https://vitest.dev/config/testtimeout" rel="noopener noreferrer"&gt;https://vitest.dev/config/testtimeout&lt;/a&gt;, accessed 2026-10-19&lt;/li&gt;
&lt;li&gt;Everything else: my own lessons log, job logs and commit history from October 14 to 18.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;This post was drafted with AI help. The numbers and facts come from my own logs and repo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>bash</category>
      <category>testing</category>
      <category>discuss</category>
    </item>
    <item>
      <title>My extension sends nothing, and the Web Store FAQ says I still have to tick two data boxes</title>
      <dc:creator>MinSoo Kim</dc:creator>
      <pubDate>Sat, 17 Oct 2026 12:03:27 +0000</pubDate>
      <link>https://dev.to/danorie/my-extension-sends-nothing-and-the-web-store-faq-says-i-still-have-to-tick-two-data-boxes-34ef</link>
      <guid>https://dev.to/danorie/my-extension-sends-nothing-and-the-web-store-faq-says-i-still-have-to-tick-two-data-boxes-34ef</guid>
      <description>&lt;p&gt;Over two weeks I passed on five Upwork jobs that looked fine in the search results. Decent client spend, plenty of reviews, not many proposals, posted recently. Every reason to skip them was on the job page itself. One client had spent $27K and had over 300 reviews, and their average hourly rate paid was $15.34. Their recent history showed $5 and $10 fixed-price contracts, again and again.&lt;/p&gt;

&lt;p&gt;So I built jobsignal, a small Chrome extension that reads the Upwork job page you open and labels those things. It doesn't give the job a score. It quotes the sentence from the page, so the call stays yours.&lt;/p&gt;

&lt;p&gt;The code turned out to be the easy part. The Web Store forms were not. More on that below, and I'd like to hear how you handled them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it flags
&lt;/h2&gt;

&lt;p&gt;Thirteen signals. Five come from the job cards in search: payment not verified, number of proposals, when it was posted, the budget, and 30+ hrs/week. Eight only show up on the job page: a low average hourly rate paid, repeated tiny fixed-price contracts, a request for proof of past work, a required video call or time zone overlap, how many screening questions there are, a high Connects cost, a suspicious scope, and a low hire rate.&lt;/p&gt;

&lt;p&gt;Four of the cutoffs can be changed in settings, like the average-rate one ($20/hr by default). The hire-rate (30%) and Connects (20) cutoffs are fixed in code for now.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkso5csm4e468148bz6b4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkso5csm4e468148bz6b4.png" alt="The jobsignal panel on a job page. The job text is blurred; the panel quotes the lines it flagged." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  One permission
&lt;/h2&gt;

&lt;p&gt;The manifest asks for &lt;code&gt;storage&lt;/code&gt; and nothing else. No host permissions, no background service worker, no tabs, no scripting. Content scripts run on three Upwork paths and nowhere else.&lt;/p&gt;

&lt;p&gt;I didn't want "sends nothing" to be a line in a README, so the build script checks it. If a rule breaks, there's no zip:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&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;permissions&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;["storage"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;permissions must be exactly ["storage"]&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;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;host_permissions&lt;/span&gt; &lt;span class="o"&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;background&lt;/span&gt; &lt;span class="o"&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;optional_permissions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;fail&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 host_permissions/background/optional_permissions&lt;/span&gt;&lt;span class="dl"&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;bad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;fetch|XMLHttpRequest|sendBeacon|WebSocket|importScripts|eval&lt;/span&gt;&lt;span class="se"&gt;)\s&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;\b&lt;/span&gt;&lt;span class="sr"&gt;new Function&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;|https&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;www&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;upwork&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;com|danorie&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;com&lt;/span&gt;&lt;span class="se"&gt;)[^&lt;/span&gt;&lt;span class="sr"&gt;'"&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;)&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every file in &lt;code&gt;src/&lt;/code&gt; is run against that pattern, so a URL outside the allow list fails the build as well.&lt;/p&gt;

&lt;p&gt;What it keeps lives in &lt;code&gt;chrome.storage.local&lt;/code&gt;. That's your settings and a daily counter, plus the jobs you've opened (with a note if you pressed Skip on one). Those are removed 30 days after you last opened them. Opening the same job again doesn't count twice. There's no service worker, so the 30-day cleanup only happens the next time the extension runs. If you never open Upwork again, that list stays put until you do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parsing a page I don't control
&lt;/h2&gt;

&lt;p&gt;The tests run against eight pages I opened and saved myself, most of them job pages. The rule I kept relearning: don't widen a regex because a phrasing might exist somewhere. Add it when a saved page actually shows it.&lt;/p&gt;

&lt;p&gt;The bug I didn't see coming was sentence cutting. A quote like "overlap with U.S. Central Time" kept getting chopped at "U.S." Now there's a short list of abbreviations (e.g., i.e., approx.) plus a rule for dotted initials like U.S., so none of them end a sentence.&lt;/p&gt;

&lt;p&gt;There are 55 tests. Besides the saved pages, they cover card parsing and edge values, plus small DOM changes and broken storage entries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard part wasn't the code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The privacy form.&lt;/strong&gt; My first instinct was "collects no data." Then I read the Chrome Web Store FAQ: data you only handle on the user's device still has to be disclosed. So the honest answer is two ticks: web history (the jobs you opened, kept locally for 30 days) and website content (the page text it reads). The first line of the privacy policy went from "does not collect" to "does not transmit, sell, or share any data; everything it handles stays on your device." The public listing doesn't say that yet. As I write this, its privacy section still says the extension won't collect or use your data. Fixing that is a dashboard change, and it isn't live yet. &lt;em&gt;Update, October 28: that change is live now. The listing's privacy section shows web history and website content, and the line saying the extension won't collect or use your data is gone.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The publish limit.&lt;/strong&gt; When I hit submit, the dashboard said I already had the maximum of four published extensions. I asked for a higher limit and got turned down right away: my published extensions needed more engagement first. I unpublished one of my older ones to make room.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review.&lt;/strong&gt; I submitted in the morning and it was live that evening. One line in the reviewer notes mattered: if the reviewer isn't logged in to Upwork, client details are hidden and no labels show up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After launch.&lt;/strong&gt; Once it was live, I compared the public listing with what I'd submitted, field by field. The summary at the top isn't the short description field from the dashboard. It comes from &lt;code&gt;description&lt;/code&gt; in the manifest, and mine still had older wording that didn't match the privacy policy. The fix is a new version, which is now live.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I can't promise
&lt;/h2&gt;

&lt;p&gt;It's a set of regexes looking for known phrasings. If Upwork rewords a field, it will miss it until I update it.&lt;/p&gt;

&lt;p&gt;It isn't affiliated with Upwork, and you should know what Upwork's help center says: "If an extension reads or changes Upwork pages or automates actions, it could trigger a warning, restriction, or block." jobsignal sends no requests and takes no actions, but it does read the page and add labels to it. Decide with that in mind.&lt;/p&gt;

&lt;p&gt;You also need to be logged in. Logged out, Upwork hides the client details, so there's nothing to flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  A question
&lt;/h2&gt;

&lt;p&gt;If you've shipped an extension that never touches the network, what did you tick on the privacy form? I went back and forth on it, and I'm curious whether you drew the line in the same place.&lt;/p&gt;




&lt;p&gt;jobsignal on the Chrome Web Store: &lt;a href="https://chromewebstore.google.com/detail/cnbafbiajaacmpkedboekbfkkoiicmjg" rel="noopener noreferrer"&gt;https://chromewebstore.google.com/detail/cnbafbiajaacmpkedboekbfkkoiicmjg&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Upwork Help, "Use bots and other automation properly": &lt;a href="https://support.upwork.com/hc/en-us/articles/43342677368467-Use-bots-and-other-automation-properly" rel="noopener noreferrer"&gt;https://support.upwork.com/hc/en-us/articles/43342677368467-Use-bots-and-other-automation-properly&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;This post was drafted with AI help. The numbers and facts come from my own logs and repo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>privacy</category>
      <category>discuss</category>
    </item>
    <item>
      <title>4 of 26 'Active' rules weren't doing what their titles said</title>
      <dc:creator>MinSoo Kim</dc:creator>
      <pubDate>Wed, 14 Oct 2026 12:33:24 +0000</pubDate>
      <link>https://dev.to/danorie/4-of-26-active-rules-werent-doing-what-their-titles-said-23og</link>
      <guid>https://dev.to/danorie/4-of-26-active-rules-werent-doing-what-their-titles-said-23og</guid>
      <description>&lt;p&gt;Our checkout app showed 26 rules as "Active" across the stores that use it. On October 2 I read every one of them: four were not doing what their titles said. About 15 percent. Two were dead because of bugs in our code. Two had titles that disagreed with their own settings.&lt;/p&gt;

&lt;p&gt;None of the four had produced an error. Every one of them saved cleanly and showed the Active badge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comma that the field next door accepted
&lt;/h2&gt;

&lt;p&gt;It started the night before, while I was reading saved rule settings for an unrelated idea. One rule targeted a payment method, and its target was two method names joined by a comma. The merchant wanted one rule to hide two methods.&lt;/p&gt;

&lt;p&gt;Our matcher didn't know about comma lists. It compared the whole string, comma included, against each method name, found nothing, and hid nothing. The rule saved. The badge said Active.&lt;/p&gt;

&lt;p&gt;Why would anyone type a comma there? Because the field right next to it, the condition value, does accept comma lists, and its hint text says so. The merchant read the screen exactly the way it was written.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that wasn't
&lt;/h2&gt;

&lt;p&gt;The first fix went in early on October 2: split on commas and compare each name. Then I replayed that store's saved settings through the new engine before telling anyone it was fixed.&lt;/p&gt;

&lt;p&gt;It still didn't match.&lt;/p&gt;

&lt;p&gt;The rule had three problems stacked on each other:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the comma, now handled;&lt;/li&gt;
&lt;li&gt;a product condition using "equals", an operator our product conditions didn't evaluate at all, so it was always false;&lt;/li&gt;
&lt;li&gt;a product value that was the product's title, not its ID.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A second fix five minutes later made product conditions match on the full title (case-insensitive) and read "equals" as "is one of". The checkout function was redeployed. Another store had a live rule that was dead for reason 3. The same release fixed it in our engine. Less than an hour later I found out that store's checkout wasn't running our rules at all.&lt;/p&gt;

&lt;p&gt;If I had stopped after the first fix, any "we fixed it" message would have been false.&lt;/p&gt;

&lt;h2&gt;
  
  
  The email I almost sent
&lt;/h2&gt;

&lt;p&gt;I wrote to three stores: the other one hit by our bug, and the two whose rule titles disagreed with their settings. The brief for those emails was simple: open with what a shopper sees at checkout, say in the first paragraph when the bug was ours, give one line on how to undo the change, and invite a reply with the behaviour they actually want. No prices, no upgrade pitch. The first went out 43 minutes after the first fix commit, and all three within 3 minutes of each other.&lt;/p&gt;

&lt;p&gt;Right before sending, I re-read the settings of the store hit by our bug and found that our payment customization was switched off in Shopify's own settings. Fixing our bug would change nothing for their shoppers, so the approved email ("from today, this method will be hidden") had become untrue. I stopped, rewrote it to say what shoppers actually see now and where the switch lives, and sent that version instead. Our app had been showing that rule as Active the whole time. It got an "off" banner the same day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Warnings at save time
&lt;/h2&gt;

&lt;p&gt;Later that morning a fourth email went to a store installed the night before. In 23 minutes it had saved the same rule 9 times and never opened our Rule Tester. The likely cause was an exact-match method name that differed from what checkout displays, so the email's one instruction was to copy the name from checkout and paste it. Writing it, I realised the Rule Tester couldn't have caught this anyway: the merchant types the method names into the tester too, so a wrong name passes both places.&lt;/p&gt;

&lt;p&gt;The real fix is upstream of all of this. That afternoon I shipped a warning that appears when a rule probably can't match anything: names split with &lt;code&gt;;&lt;/code&gt; or &lt;code&gt;|&lt;/code&gt; instead of commas, an empty entry, or a shipping option's name inside a payment rule. It can't tell you that a rule named "hide PayPal" is pointed at credit cards. That one still needs a person reading the title and the settings side by side.&lt;/p&gt;

&lt;h2&gt;
  
  
  And then the upgrade button broke
&lt;/h2&gt;

&lt;p&gt;Same day, right after that deploy, I clicked our upgrade button to check it. It stopped at "401 Unauthorized". A route I'd changed to count clicks couldn't follow a server redirect inside the embedded app. It was live for about an hour, nobody else clicked it in that window, and I fixed it by sending the browser to the outside page directly. "Click every button that involves money after a deploy" is now on the release checklist.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd ask you
&lt;/h2&gt;

&lt;p&gt;If I keep one habit from that day, it's this: replay the stored settings through the new engine before saying "fixed". That replay found the two problems hiding under the comma.&lt;/p&gt;

&lt;p&gt;Every app has a field like this: it saves, it shows an active state, and it quietly does nothing. Which one is it in yours, and how did you find out?&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;HideKit, the app in this post: &lt;a href="https://apps.shopify.com/hidekit" rel="noopener noreferrer"&gt;https://apps.shopify.com/hidekit&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Our own audit of 26 active rules and the two fix commits, October 2, 2026 (private repo)&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This post was drafted with AI help. The numbers and facts come from my own logs and repo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>webdev</category>
      <category>debugging</category>
      <category>discuss</category>
    </item>
    <item>
      <title>969 commits in 2.4 days, one git repo, no lock server</title>
      <dc:creator>MinSoo Kim</dc:creator>
      <pubDate>Wed, 07 Oct 2026 12:33:17 +0000</pubDate>
      <link>https://dev.to/danorie/969-commits-in-24-days-one-git-repo-no-lock-server-2fj2</link>
      <guid>https://dev.to/danorie/969-commits-in-24-days-one-git-repo-no-lock-server-2fj2</guid>
      <description>&lt;p&gt;Between September 30 and the morning of October 2, one git repository took 969 commits. The busiest hour had 107. Every one of them landed in the same working tree on the same Windows machine, written by about twenty separate automated agent sessions that each own a slice of the work: one writes these posts, others handle a Shopify app, social channels, a freelance pipeline.&lt;/p&gt;

&lt;p&gt;There is no lock server. There is no queue. There are three rules in a markdown file, and so far they have held.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three rules
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Commit by path.&lt;/strong&gt; Every session commits with &lt;code&gt;git commit -m "..." -- &amp;lt;paths&amp;gt;&lt;/code&gt;, naming only its own files. Nobody runs &lt;code&gt;git add .&lt;/code&gt;, because the index is shared and &lt;code&gt;add .&lt;/code&gt; would sweep up a neighbour's half-finished work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If &lt;code&gt;.git/index.lock&lt;/code&gt; exists, wait and retry. Never delete it.&lt;/strong&gt; Another session is in the middle of a commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared ledgers have one writer.&lt;/strong&gt; A handful of files (the decision log, the task queue, the lessons ledger) are written only by a coordinating session. Everyone else writes to their own log and sends a message.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. The repo is 7,565 tracked files and a 6.05 GiB pack, and the rules don't care about either number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The error message that tells you to do the wrong thing
&lt;/h2&gt;

&lt;p&gt;On September 30, around 10 p.m., one session's commit failed 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;fatal: Unable to create '&amp;lt;repo&amp;gt;/.git/index.lock': File exists.

Another git process seems to be running in this repository, e.g.
an editor opened by 'git commit'. Please make sure all processes
are terminated then try again. If it still fails, a git process
may have crashed in this repository earlier:
remove the file manually to continue.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the last line. Git's advice, aimed at one person at one keyboard, is to delete the lock. With twenty writers, the lock almost always belongs to someone who is alive and committing right now. Delete it and their commit corrupts or vanishes.&lt;/p&gt;

&lt;p&gt;So rule 2 exists because of that sentence. The session waited five seconds, retried, and went through on the next attempt. Boring. That's the goal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that was written down twice
&lt;/h2&gt;

&lt;p&gt;Here is the embarrassing part. When I checked on October 2, the protocol file described the retry rule in two places with two different numbers: "wait 5 seconds, 3 tries" in one section and "2-second gap, 3 tries" in another, added later. Both work in practice, because contention is short. But the whole system rests on everyone reading the same rule, and the document disagreed with itself for two days before anyone noticed. It was fixed the same day: 5 seconds, 3 tries, in both places.&lt;/p&gt;

&lt;p&gt;One number, one place. I'd rather have the wrong number written once than the right one written twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The near miss
&lt;/h2&gt;

&lt;p&gt;Back on September 27, an agent that had been restarted went to a release clone and ran &lt;code&gt;git reset --hard&lt;/code&gt;. It did no damage, because nobody had uncommitted work there at that moment. It could have wiped someone's afternoon.&lt;/p&gt;

&lt;p&gt;The fix is a habit, not a tool: before touching any clone, check &lt;code&gt;git log -1&lt;/code&gt;, the file modification times, and whether an &lt;code&gt;index.lock&lt;/code&gt; is sitting there. If something looks recent and isn't yours, stop.&lt;/p&gt;

&lt;p&gt;A quieter one came on October 1. A session pulled out of habit with &lt;code&gt;git pull --rebase --autostash&lt;/code&gt;. Autostash tucks away &lt;em&gt;every&lt;/em&gt; session's uncommitted changes, not just yours, and two files were being rewritten live by background runners. The restore failed and 2 autostash entries were left behind. The content was still in the working tree, so nothing was lost, but the rule changed. First it became "pull only when a push is rejected". A day later autostash was banned outright: commit your own paths first, then &lt;code&gt;git pull --rebase&lt;/code&gt; with nothing stashed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I haven't measured
&lt;/h2&gt;

&lt;p&gt;I want to be straight about the gaps, because they're real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I don't know how many times sessions actually collided on the lock. Nothing logs it. The one failure above is the one I saw.&lt;/li&gt;
&lt;li&gt;"No corrupted commits" means nobody reported one. It is not an audit.&lt;/li&gt;
&lt;li&gt;Even the headline number moved. When I first counted this window on October 2 it was 941. Counted again on October 5 it was 969, with zero merge commits, because commits from the one session in its own worktree landed on main later with their original dates. Commit date is not arrival date.&lt;/li&gt;
&lt;li&gt;I never tried a lock server or one worktree per session. One executor session does run in its own worktree; the rest share.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why not just give everyone a worktree?
&lt;/h2&gt;

&lt;p&gt;Probably the next step. The shared tree survived because the sessions mostly write to different files, and path-scoped commits turn "shared tree" into "shared index, separate files". The moment two sessions need the same file, rule 3 has to carry the load, and that only works because one session is the single writer.&lt;/p&gt;

&lt;p&gt;If you've run many writers against one repo, what broke first for you? The index, the files, or the people reading the log?&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;git commit&lt;/code&gt; pathspec (committing only the named paths): &lt;a href="https://git-scm.com/docs/git-commit" rel="noopener noreferrer"&gt;https://git-scm.com/docs/git-commit&lt;/a&gt;, accessed 2026-10-07&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;git pull --autostash&lt;/code&gt;: &lt;a href="https://git-scm.com/docs/git-pull" rel="noopener noreferrer"&gt;https://git-scm.com/docs/git-pull&lt;/a&gt;, accessed 2026-10-07&lt;/li&gt;
&lt;li&gt;Commit counts: &lt;code&gt;git log --since=2026-09-30T00:00+09:00 --until=2026-10-02T09:45+09:00&lt;/code&gt; on the repo, re-run 2026-10-05 (969 commits, 0 merges, peak 107 in the 14:00 hour on Sep 30)&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This post was drafted with AI help. The numbers and facts come from my own logs and repo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>git</category>
      <category>automation</category>
      <category>devops</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Seven posts on DEV, 74 views, and the one comment I never answered</title>
      <dc:creator>MinSoo Kim</dc:creator>
      <pubDate>Fri, 02 Oct 2026 12:03:41 +0000</pubDate>
      <link>https://dev.to/danorie/seven-posts-on-dev-74-views-and-the-one-comment-i-never-answered-2hg4</link>
      <guid>https://dev.to/danorie/seven-posts-on-dev-74-views-and-the-one-comment-i-never-answered-2hg4</guid>
      <description>&lt;p&gt;I joined DEV on September 9 and posted seven articles over the next nineteen days. Together they have 74 views and 3 reactions. The last two have zero views each.&lt;/p&gt;

&lt;p&gt;Zero is a strange number. Not "low". Zero.&lt;/p&gt;

&lt;p&gt;So before writing an eighth, I pulled my own numbers through the API and read them next to what everyone else posted the same week. This is what I found, and there's a part I still can't explain, which is where I'd like your help.&lt;/p&gt;

&lt;h2&gt;
  
  
  The seven posts, and what they got
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;th&gt;Views&lt;/th&gt;
&lt;th&gt;Reactions&lt;/th&gt;
&lt;th&gt;Comments&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sep 10&lt;/td&gt;
&lt;td&gt;What a static calculator site loads (16 requests)&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 12&lt;/td&gt;
&lt;td&gt;A product data pipeline that refuses Amazon as a source&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 14&lt;/td&gt;
&lt;td&gt;How a Shopify app's rules run inside checkout&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 16&lt;/td&gt;
&lt;td&gt;Rounding in a flooring calculator&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 19&lt;/td&gt;
&lt;td&gt;A browser extension's 15-second timer in Manifest V3&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 25&lt;/td&gt;
&lt;td&gt;A comparison operator that returns true on empty input&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 28&lt;/td&gt;
&lt;td&gt;Five upgrade banners nobody will ever see&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of them is about something I built. Every title is about the thing, not about a person. All seven carry #showdev. None has a cover image.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three comments, and two were spam
&lt;/h2&gt;

&lt;p&gt;Two of the three comments are link spam. One sells a Google Drive cleaner, the other an auto-apply job bot.&lt;/p&gt;

&lt;p&gt;The third was real. On September 12 someone read the Amazon post properly and wrote that splitting "who confirms the model match" from "who supplies the number" was a cleaner separation than most pipelines get. That's a good comment, the kind you hope for. It sat there unanswered for eighteen days while I kept publishing.&lt;/p&gt;

&lt;p&gt;That one is on me. I answered it this week. If you comment here, I'll reply within a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things I ruled out
&lt;/h2&gt;

&lt;p&gt;Posting time was my first guess. Six of the seven went out at 8 a.m. US Eastern, on purpose. The best performer, the very first one, went out around 2 a.m. Eastern because I hadn't set up the schedule yet. So timing isn't the main thing.&lt;/p&gt;

&lt;p&gt;Hidden posts were my second guess. I checked the tag feeds through the public API, and both zero-view posts are listed under #shopify, #showdev and their other tags. They're visible. Nobody clicked.&lt;/p&gt;

&lt;p&gt;That leaves the posts themselves, and the honest reading of the table is that people saw seven cards in a busy feed, each with a long title naming a tool they had never heard of and no picture, and moved on to the next card.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the same week looked like for everyone else
&lt;/h2&gt;

&lt;p&gt;I pulled every #showdev post from September 23 to 28. There were more than 200. The median reaction count was 0, and only 3 percent reached five reactions. #beginners and #javascript looked the same: roughly 200 and 170 posts, median 0.&lt;/p&gt;

&lt;p&gt;Then I read the week's top 30 across the whole site. Thirteen of the 30 titles are about a person, mostly in the first person: "I burned out." "23 rejections, multiple offers." "An introverted dev in an extroverted world." Thirteen carry #discuss, only four carry #showdev, and 25 have a cover image. The median comment count was 26.&lt;/p&gt;

&lt;p&gt;My titles read like changelog entries. "The checkout never calls our server: how HideKit's rules run inside Shopify" is accurate. It's also a sentence nobody needs to click.&lt;/p&gt;

&lt;h2&gt;
  
  
  So this post is the experiment
&lt;/h2&gt;

&lt;p&gt;This one breaks my pattern on purpose: first person, #discuss instead of #showdev, a shorter title, and no product in it. That's three changes at once, which is bad science, I know. I kept the posting time and the missing cover the same, so at least those two are out of the picture. If this one also lands at zero, the cover goes on next.&lt;/p&gt;

&lt;p&gt;I'll post the numbers for this one in two weeks, whatever they are.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd like to know
&lt;/h2&gt;

&lt;p&gt;If you've been here longer than three weeks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Did your first month look like this, and what actually moved it?&lt;/li&gt;
&lt;li&gt;Does #showdev ever work for someone without an audience yet, or is it a tag you grow into?&lt;/li&gt;
&lt;li&gt;Cover images: a real difference in the feed, or folklore?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I'll answer every comment. That part I can fix today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the numbers came from
&lt;/h2&gt;

&lt;p&gt;All pulled on September 30 from the public DEV API. My own post counts come from the same API with my key.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tag feeds: &lt;a href="https://dev.to/api/articles"&gt;https://dev.to/api/articles&lt;/a&gt; with the tag parameter set to showdev, beginners, javascript and shopify, 100 per page, accessed 2026-09-30&lt;/li&gt;
&lt;li&gt;Top of the week: &lt;a href="https://dev.to/api/articles?top=7&amp;amp;per_page=30"&gt;https://dev.to/api/articles?top=7&amp;amp;per_page=30&lt;/a&gt;, accessed 2026-09-30&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This post was drafted with AI help. The numbers and facts come from my own logs and repo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>writing</category>
      <category>devjournal</category>
      <category>community</category>
    </item>
    <item>
      <title>How to build five upgrade banners that nobody will ever see</title>
      <dc:creator>MinSoo Kim</dc:creator>
      <pubDate>Mon, 28 Sep 2026 12:03:36 +0000</pubDate>
      <link>https://dev.to/danorie/how-to-build-five-upgrade-banners-that-nobody-will-ever-see-22h5</link>
      <guid>https://dev.to/danorie/how-to-build-five-upgrade-banners-that-nobody-will-ever-see-22h5</guid>
      <description>&lt;p&gt;I run a small Shopify app that hides payment and shipping methods at checkout, and on the 23rd I shipped five little in-app banners to nudge free merchants towards the paid plan, with conditions, priorities and an opt-out all pinned down by tests. Four days later I went and measured how many merchants had seen one. Zero.&lt;/p&gt;

&lt;p&gt;Not "a few". Zero. And in the same 4 days a Pro trial appeared, which I very nearly wrote down as the banners working. It wasn't. Let's go through it, because the mistake is not in the code and I suspect you've got one of these somewhere too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;So the banners are these, each with its own condition, all evaluated by one pure function that decides which single banner a page gets, if any.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;free plan, exactly 3 active rules: "you've hit the limit"&lt;/li&gt;
&lt;li&gt;free plan, merchant picks a Pro-only field in the condition dropdown: an inline hint under the dropdown&lt;/li&gt;
&lt;li&gt;free plan, at least 1 active rule, not dismissed: "try Pro"&lt;/li&gt;
&lt;li&gt;any plan, rules saved but none switched on, not dismissed: "saved, but off"&lt;/li&gt;
&lt;li&gt;installed 7 days or more, at least 1 active rule, never asked before: a one-time "how is it working for you, write a review"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only one shows per page. Priority is over-limit, then at-limit, then not-running, then review, then the trial nudge, and there's a test that guards both the order and the copy.&lt;/p&gt;

&lt;p&gt;The review one writes &lt;code&gt;asked: true&lt;/code&gt; into a metafield on the app installation the moment it renders, so a merchant is asked once and never again. I was quite pleased with all of this. It is, I think, the most carefully specified feature in the app, which is going to turn out to be the joke.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting the audience, badly
&lt;/h2&gt;

&lt;p&gt;Before shipping I counted who could possibly see any of it, and I got the count wrong, so I'd rather say that here than have you find it in the tables.&lt;/p&gt;

&lt;p&gt;The app is installed on 29 stores. Some are mine (development stores I test on), and the way I told mine apart from real merchants was a flag Shopify puts on the shop record, &lt;code&gt;plan.partnerDevelopment&lt;/code&gt;. True meant "ours", and ours got left out. That gave me 18 merchants at launch and 20 by the weekend.&lt;/p&gt;

&lt;p&gt;But whose partner? The flag doesn't say. It's true for anybody's development store, and the way this came out is almost embarrassing: someone helping me tried to open two of those "ours" stores from my Shopify account to check a banner, and got "you don't have permission". They were somebody else's.&lt;/p&gt;

&lt;p&gt;So the real split is 4 stores of mine (2 confirmed, 2 I'm fairly sure of) and 25 third-party stores, which were 23 at launch, not 18, and the paid conversion I'd been quoting as 2 out of 20, 11%, is actually 2 out of 25, 8%. Same two paying merchants, smaller number, and I'd been reporting the bigger one for a week.&lt;/p&gt;

&lt;p&gt;The code, by the way, never made this mistake. &lt;code&gt;reviewPromptDue()&lt;/code&gt; looks at install age, active rules and whether I've already asked, and nothing else, and it was only my spreadsheet that had opinions about who counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  4 days later
&lt;/h2&gt;

&lt;p&gt;The review banner leaves a mark when it renders, so it's the one I can actually measure. 12 installations qualified for it, 2 of mine and 10 real merchants, and I queried the metafield on all 29 stores. &lt;code&gt;asked&lt;/code&gt; was set on none of them.&lt;/p&gt;

&lt;p&gt;Now, what does zero mean here? Either nobody who qualified had opened the app since the deploy, or the banner renders and the write to the metafield doesn't happen. Those are very different problems and the number alone doesn't tell you which one you've got.&lt;/p&gt;

&lt;p&gt;So I opened one of my own stores that was in a qualifying state (Pro plan, installed 11 days, 4 active rules, never asked) in a normal browser, once, and watched. The banner was there. The metafield went to &lt;code&gt;{"asked":true,"optedOut":false}&lt;/code&gt; with an &lt;code&gt;updatedAt&lt;/code&gt; of the exact second the page loaded.&lt;/p&gt;

&lt;p&gt;Eligibility works, the route works, the write works. Which leaves the other explanation: in 4 days, not one of the qualifying merchants I can observe opened the app.&lt;/p&gt;

&lt;p&gt;Why "I can observe"? Because of my own priority order. One of the 10 is on the free plan with exactly 3 active rules, so if that merchant opens the app they get the "you've hit the limit" banner, which outranks the review one and writes nothing. For that store I can't tell either way. So it's 9 merchants with no record of opening the app, and 1 I have no information about, and the only banner that leaves a mark can be pushed aside by one that doesn't. (It can also go quiet on its own: if the Admin API call that checks the metafield fails, the code treats the merchant as opted out and shows nothing, on the grounds that not asking is better than asking twice. So even "no record" is a slightly softer fact than it looks.)&lt;/p&gt;

&lt;p&gt;I want to be careful with the wording here, because "the banners are broken" is the sentence people will repeat and it's false. They render and they record. They just didn't reach anyone, because there was nobody in the room. (The 2 stores that now have &lt;code&gt;asked&lt;/code&gt; set are the 2 of mine that I opened to check, so the merchant count is still 0.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The trial that wasn't mine
&lt;/h2&gt;

&lt;p&gt;Over the same 4 days the numbers that moved were Pro trials 0 to 1, paid Pro 2 to 2, and listing reviews 0 to 0. One trial, right after shipping trial nudges! You can see how that ends up in a weekly report, can't you?&lt;/p&gt;

&lt;p&gt;But I looked at the timestamps. That store completed OAuth at 09:09 UTC on the 24th and started the Pro trial at 10:01 UTC, 52 minutes later. The trial banner only shows to a free merchant with at least 1 active rule, and the review banner needs 7 days of install age, so a brand new install that goes straight to Pro inside an hour was never in a state where any banner could have reached it.&lt;/p&gt;

&lt;p&gt;The timing is right and the causation simply isn't there. So the trial goes in the "happened anyway" column, and I'm a bit annoyed at how close I came to filing it under "banners".&lt;/p&gt;

&lt;h2&gt;
  
  
  And I can't measure clicks anyway
&lt;/h2&gt;

&lt;p&gt;The five upgrade buttons are plain links, &lt;code&gt;href&lt;/code&gt; straight to the plan page, so a click never touches my server. There's no tracking code in the app at all, 0 lines of it, and that's deliberate: the privacy page says there are no analytics or advertising trackers embedded in the app, and I'd have to change that public promise before I could count a single click.&lt;/p&gt;

&lt;p&gt;Is that worth doing for a click counter? I don't know yet. It's a policy decision rather than an afternoon of work, and I haven't made it.&lt;/p&gt;

&lt;p&gt;So the measured effect of the other four banners is not "zero". It's "never measured". Those are different too, and the second one is worse, because you can't even tell whether the thing you built is doing anything.&lt;/p&gt;

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

&lt;p&gt;Specifying a banner precisely and getting it in front of a person are separate problems, and I only solved the first one. An in-app message reaches the people who open the app, and a merchant who sets up a rule once and leaves (a perfectly sensible thing to do with a checkout app) will never see anything I put inside it, however good the conditions are.&lt;/p&gt;

&lt;p&gt;The other thing is duller but probably more important. Without a mark that gets written when something happens, you can't tell "built" from "reached", and sooner or later you'll take credit for something that happened on its own. The review banner writes a mark. The other four don't. Guess which one I can say anything about?&lt;/p&gt;

&lt;p&gt;Anyway, next week the job is getting in front of the 10 merchants who qualify, by some route that isn't inside the app, and deciding whether I'm willing to amend a privacy page for a click counter. The first four banners are still up, still correctly specified, and still unmeasured.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was drafted with AI help. The numbers and facts come from my own logs and repo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>saas</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>The comparison operator that returns true when there is nothing to compare</title>
      <dc:creator>MinSoo Kim</dc:creator>
      <pubDate>Fri, 25 Sep 2026 12:02:49 +0000</pubDate>
      <link>https://dev.to/danorie/the-comparison-operator-that-returns-true-when-there-is-nothing-to-compare-4f85</link>
      <guid>https://dev.to/danorie/the-comparison-operator-that-returns-true-when-there-is-nothing-to-compare-4f85</guid>
      <description>&lt;p&gt;I've been trying to add a "block the checkout if this rule matches" feature to a Shopify app I run, and I nearly shipped something that would have blocked every single cart in a store. Not some carts. All of them. The bug was one line long and it was already in production, doing something perfectly reasonable, and you'd have to squint to see anything wrong with it. Let's go through it, because I think the shape of it is a lot more common than the specific code, and you probably have one of these somewhere too.&lt;/p&gt;

&lt;p&gt;Quick context so you can follow. The app has a small rules engine: 15 kinds of conditions (shipping country, cart total, product, customer tag, whether the address looks like a PO box, that sort of thing), written once in TypeScript and shared between the checkout function that runs as wasm and the rule tester inside the app. The two existing targets use those rules to hide a payment method or to rename a delivery option. The new work was supposed to be small: same condition vocabulary, one more output, "block". Easy, right? I thought so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the function runs
&lt;/h2&gt;

&lt;p&gt;So this is the part I didn't know. Shopify's Cart and Checkout Validation Function is not a checkout-only thing. It runs at 3 points in the buyer's journey: &lt;code&gt;CART_INTERACTION&lt;/code&gt;, &lt;code&gt;CHECKOUT_INTERACTION&lt;/code&gt; and &lt;code&gt;CHECKOUT_COMPLETION&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The existing two targets only ever ran inside checkout, and the worst they could do to a buyer was hide a payment method a bit early or rename a delivery option. So for as long as that engine has been running, a wrong answer on a cart with no country had never cost anyone a sale. But at the cart stage there is no address yet. Nobody has typed one. And now the wrong answer would stop the sale. Obvious in hindsight? Completely. Did I think of it? I did not.&lt;/p&gt;

&lt;p&gt;That is the whole bug, really. The rest is just working out which line it will land on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line
&lt;/h2&gt;

&lt;p&gt;Our comparison operators don't fail closed. The &lt;code&gt;not_in&lt;/code&gt; branch explicitly returns &lt;code&gt;true&lt;/code&gt; when the value is missing. This isn't me guessing after the fact; it's what the code says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// shared/rules-engine/evaluate.ts&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;not_in&lt;/span&gt;&lt;span class="dl"&gt;"&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="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that with a merchant's rule in mind: "block if the shipping country is not in [US]". On a cart with no address, &lt;code&gt;a&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;, so the operator says &lt;em&gt;yes, the country is not in the list&lt;/em&gt;, then the rule matches, then the cart gets blocked. And at the cart stage every cart looks like that. From the moment a merchant turns that rule on, nobody will be able to get past the cart. What's the logic in that? There isn't any; it's just what the code does when you ask it to answer a question it wasn't built to answer.&lt;/p&gt;

&lt;p&gt;I want to be precise about what actually happened, because "blocked every order" is the kind of sentence that gets repeated. This lived on a branch (&lt;code&gt;feat/validation-rules&lt;/code&gt;). It never reached a merchant. The live app can't block anything and still has its 15 conditions. But if I had shipped the branch as it stood, that is what it would have gone on to do.&lt;/p&gt;

&lt;p&gt;4 of the 15 conditions read the address: &lt;code&gt;country&lt;/code&gt;, &lt;code&gt;province&lt;/code&gt;, &lt;code&gt;zip&lt;/code&gt; and &lt;code&gt;po_box&lt;/code&gt;. The branch adds 4 more address-shaped ones (length, whether there's a street number, contains-string, non-Latin characters), so on the branch it's 8 of 19 that depend on something a cart doesn't have yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the obvious fixes don't work
&lt;/h2&gt;

&lt;p&gt;So my first thought was the obvious one: make &lt;code&gt;not_in&lt;/code&gt; return &lt;code&gt;false&lt;/code&gt; when the value is missing. Fail closed, done. Right?&lt;/p&gt;

&lt;p&gt;But that operator is not mine to change any more. It's shared with the two targets that are already live, and merchants have built their rules on top of what it does today. Flip the operator and I'd be changing the behaviour of software that merchants have configured and rely on, to fix a feature nobody is using yet. I'm not going to do that.&lt;/p&gt;

&lt;p&gt;Second thought: use &lt;code&gt;buyerJourney.step&lt;/code&gt; and just skip address rules at &lt;code&gt;CART_INTERACTION&lt;/code&gt;. That's what the API is giving you the step for, surely? Surely?&lt;/p&gt;

&lt;p&gt;It's not enough. The address can also be empty &lt;em&gt;during&lt;/em&gt; checkout, before the buyer has got round to filling in the address form. So a rule can be evaluated at &lt;code&gt;CHECKOUT_INTERACTION&lt;/code&gt; with the same missing value and the same wrong &lt;code&gt;true&lt;/code&gt;. Filtering by step fixes the cart case and then leaves the checkout case exactly as broken as before.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did instead
&lt;/h2&gt;

&lt;p&gt;So the engine now refuses to answer instead of guessing, which I'm pretty happy with. Any rule that needs an address is held until the cart actually has one, and the function decides that by looking at the cart, not at which step it is on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ADDRESS_CONDITION_FIELDS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ConditionField&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;country&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="s2"&gt;province&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="s2"&gt;zip&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="s2"&gt;po_box&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// …plus the 4 address-shape conditions (length, street number, contains, non-Latin)&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ruleNeedsAddress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Rule&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ADDRESS_CONDITION_FIELDS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;field&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 typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Function side. Not buyerJourney.step: does the cart have an address or not.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;addressKnown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deliveryGroups&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deliveryAddress&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;countryCode&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;errors&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evaluateValidation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;addressKnown&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule tester inside the app doesn't take a default. It works out &lt;code&gt;addressKnown&lt;/code&gt; from what the merchant actually typed: if they filled in a country, the rule is evaluated; if they left it blank, it shows "waiting for address" rather than silently matching, which is the same thing the checkout does, just made visible to them.&lt;/p&gt;

&lt;p&gt;And there are regression tests pinning both directions now: cart stage with no address lets the cart through, checkout stage with an address blocks it. The original 4 (&lt;code&gt;country&lt;/code&gt;, &lt;code&gt;province&lt;/code&gt;, &lt;code&gt;zip&lt;/code&gt;, &lt;code&gt;po_box&lt;/code&gt;) never get evaluated on an address-less cart at all, because &lt;code&gt;ruleNeedsAddress&lt;/code&gt; holds the whole rule first. The 4 new address-shape conditions have their own test, and one sentence I made it say out loud, because it's the sentence I got wrong: on a cart with no address, all 4 of them answer "no", &lt;em&gt;including the negative forms&lt;/em&gt; (&lt;code&gt;not_in&lt;/code&gt;, &lt;code&gt;not_set&lt;/code&gt;). 173 tests pass on the branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bit that generalises
&lt;/h2&gt;

&lt;p&gt;The same operator is safe when the worst it can do is hide a payment button, and dangerous when it can stop a sale. Nothing about the operator changed between those two sentences. The thing that changed is how the caller reacts to a &lt;code&gt;true&lt;/code&gt;. Is the operator wrong, then? I don't think it is. Is it dangerous? Obviously, in the right place.&lt;/p&gt;

&lt;p&gt;So "fail open or fail closed?" isn't a property of the comparison. It's a property of the comparison &lt;em&gt;plus&lt;/em&gt; whoever is holding the result. An engine that returns &lt;code&gt;true&lt;/code&gt; for "I don't know" has a blast radius decided entirely by its callers, and when you reuse it somewhere new you're not just importing the vocabulary of conditions. You're importing the execution context that vocabulary was written in, and here that context was "the worst a wrong answer can do is hide a button". It wasn't written down anywhere. It was just true, until it wasn't.&lt;/p&gt;

&lt;p&gt;I don't have a clever rule for catching this in general, sorry. The honest version is: when a shared function gets a new caller, go and read every branch that handles missing input, one by one, and ask yourself what the new caller will do with each answer. Then write that down. It's boring, but it would have been a lot more expensive to learn after launch.&lt;/p&gt;

&lt;p&gt;Anyway, the branch is still a branch, and if you've got a shared rules engine of your own, I'd really go and read its missing-value branches this week. It gets the last checks (typegen, wasm build, a dev store run-through) before it goes anywhere near a real merchant, and now I'm slightly more nervous about the other 11 conditions than I was last week, which is probably the correct amount of nervous.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was drafted with AI help. The numbers and facts come from my own logs and repo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Why CookieMop waits 15 seconds after you close a tab, and why that is the hard part in Manifest V3</title>
      <dc:creator>MinSoo Kim</dc:creator>
      <pubDate>Sat, 19 Sep 2026 12:03:56 +0000</pubDate>
      <link>https://dev.to/danorie/why-cookiemop-waits-15-seconds-after-you-close-a-tab-and-why-that-is-the-hard-part-in-manifest-v3-phm</link>
      <guid>https://dev.to/danorie/why-cookiemop-waits-15-seconds-after-you-close-a-tab-and-why-that-is-the-hard-part-in-manifest-v3-phm</guid>
      <description>&lt;p&gt;CookieMop is a Chrome extension that deletes a site's cookies when you close its last tab, unless the site is on your whitelist. One sentence of product. The interesting engineering is in three words of that sentence: "last", "close", and the delay hiding between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not delete immediately
&lt;/h2&gt;

&lt;p&gt;Closing a tab by accident is common, and cookies are what keep you logged in. So the extension does not clean on the close event. It schedules a cleanup 15 seconds later (the default; users can change it, and zero means immediate) and only then checks whether the site is still gone. Reopen the tab within the window and the pending cleanup is cancelled. One Playwright test exists for exactly that path: revisiting the site during the delay cancels the cleanup.&lt;/p&gt;

&lt;p&gt;That sounds like a &lt;code&gt;setTimeout&lt;/code&gt;. In Manifest V3 it cannot be only a &lt;code&gt;setTimeout&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The service worker will not wait for you
&lt;/h2&gt;

&lt;p&gt;In Manifest V3 the background script is a service worker, and Chrome can terminate it whenever it is idle. A timer living inside a worker that gets killed at second 9 of a 15-second wait never fires. The old Manifest V2 answer, a persistent background page, is gone.&lt;/p&gt;

&lt;p&gt;The tool Chrome offers instead is &lt;code&gt;chrome.alarms&lt;/code&gt;, which survives the worker being unloaded. It has one catch that shaped the whole design: alarms are clamped to a minimum of 30 seconds. A 15-second delay cannot be expressed as an alarm.&lt;/p&gt;

&lt;p&gt;So the extension does both. While the worker is alive, a plain timer fires the cleanup on time. In parallel, every pending cleanup is written to storage with its &lt;code&gt;fireAt&lt;/code&gt; timestamp, and one alarm is set for the earliest of them, pushed out to at least 30 seconds if needed:&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;ALARM_MIN_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Chrome clamps MV3 alarms to a 30 s minimum&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;earliest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;pendings&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;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fireAt&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alarms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ALARM_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;earliest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ALARM_MIN_MS&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 the worker lives, the timer wins and the alarm finds nothing to do. If the worker dies, the alarm wakes a fresh worker, which reads the pending list from storage and processes whatever is due. There is a test for that too: kill the service worker, and the alarm still fires the pending cleanup. The cost of the fallback is that a cleanup can land at 30 seconds instead of 15 when Chrome happened to unload the worker. I decided that late is fine and never is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Last tab" means last tab of the site, not the page
&lt;/h2&gt;

&lt;p&gt;The second word. If you have &lt;code&gt;mail.google.com&lt;/code&gt; in one tab and &lt;code&gt;docs.google.com&lt;/code&gt; in another, closing one must not log you out of the other. So the unit of tracking is not the hostname but the registrable domain, the eTLD+1: &lt;code&gt;mail.google.com&lt;/code&gt; becomes &lt;code&gt;google.com&lt;/code&gt;, and &lt;code&gt;a.b.example.co.uk&lt;/code&gt; becomes &lt;code&gt;example.co.uk&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Doing that properly means knowing which suffixes are "public", and the full Public Suffix List is thousands of entries. CookieMop ships no dependencies and no build step, so it carries a compact table of the common two-part suffixes instead: &lt;code&gt;co.uk&lt;/code&gt;, &lt;code&gt;co.kr&lt;/code&gt;, &lt;code&gt;com.au&lt;/code&gt;, &lt;code&gt;co.jp&lt;/code&gt;, 257 entries in total. A hostname ending in one of those needs three labels to be a site; anything else needs two. IP addresses and single-label hosts like &lt;code&gt;localhost&lt;/code&gt; are left as they are. It is not the whole list, and I would rather say that here than pretend. It covers the cases people actually log in to.&lt;/p&gt;

&lt;p&gt;The same table decides the lists. A whitelist rule on a subdomain survives a cleanup of its parent domain, and a more specific whitelist entry beats a greylist entry. Greylisted sites are the third state: they survive tab close and are cleaned when the browser restarts, in a startup pass that runs before you have opened anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The race nobody sees
&lt;/h2&gt;

&lt;p&gt;There is a comment in the background script that I keep because it cost an afternoon. Recording a tab's domain on &lt;code&gt;tabs.onUpdated&lt;/code&gt; and taking it back on &lt;code&gt;tabs.onRemoved&lt;/code&gt; are both async read-then-write round trips to storage. If a tab is opened and closed fast enough, the "remove" read can overtake the still-pending "record" write, and the extension thinks a site was never open. The fix is a small promise queue that serializes every tab-record read and write, and the reason it is worth writing down is that it never shows up in manual testing. It shows up in a Playwright run that opens and closes tabs faster than a person can.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "clean" means
&lt;/h2&gt;

&lt;p&gt;By default only cookies are removed. The user can widen the scope to localStorage, or to all site data: IndexedDB, CacheStorage and service worker registrations, through &lt;code&gt;chrome.browsingData&lt;/code&gt;. Widening it is a real trade-off, because the same storage that tracks you also holds a half-written draft on some sites, so the narrower default stays.&lt;/p&gt;

&lt;p&gt;Everything runs locally. The extension makes no network requests, has no accounts and no analytics, and the repository on GitHub is the code that ships to the store. If you want to check the timer logic yourself, &lt;code&gt;src/background.js&lt;/code&gt; is about 430 lines and the alarm fallback is the part worth reading first.&lt;/p&gt;

&lt;p&gt;Have you ever lost a login to a tab you closed by mistake, or is 15 seconds too long for the way you browse?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Update, November 7, 2026: CookieMop launched on Product Hunt: &lt;a href="https://www.producthunt.com/products/cookiemop" rel="noopener noreferrer"&gt;https://www.producthunt.com/products/cookiemop&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;chrome.alarms API, minimum alarm period in Manifest V3: &lt;a href="https://developer.chrome.com/docs/extensions/reference/api/alarms" rel="noopener noreferrer"&gt;https://developer.chrome.com/docs/extensions/reference/api/alarms&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Extension service worker lifecycle (idle termination): &lt;a href="https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle" rel="noopener noreferrer"&gt;https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Public Suffix List, what "registrable domain" means: &lt;a href="https://publicsuffix.org/" rel="noopener noreferrer"&gt;https://publicsuffix.org/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;CookieMop source, the exact code shipped to the Chrome Web Store: &lt;a href="https://github.com/thoopring/cookiemop" rel="noopener noreferrer"&gt;https://github.com/thoopring/cookiemop&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This post was drafted with AI help. The numbers and facts come from my own logs and repo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>browser</category>
    </item>
    <item>
      <title>The 10 percent that bought nothing: what a flooring calculator should actually tell you</title>
      <dc:creator>MinSoo Kim</dc:creator>
      <pubDate>Wed, 16 Sep 2026 12:02:49 +0000</pubDate>
      <link>https://dev.to/danorie/the-10-percent-that-bought-nothing-what-a-flooring-calculator-should-actually-tell-you-1ene</link>
      <guid>https://dev.to/danorie/the-10-percent-that-bought-nothing-what-a-flooring-calculator-should-actually-tell-you-1ene</guid>
      <description>&lt;p&gt;Wall Math is a set of three small calculators: paint, wallpaper, flooring. The flooring one is the simplest arithmetic on the site and the one that taught me the most about what a calculator owes its user. The whole engine function is nine lines. Here is the part that matters:&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;requiredArea&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;area&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;extra&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&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;boxes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requiredArea&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;perBox&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="nx"&gt;area&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requiredArea&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;boxes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;purchasedArea&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boxes&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;perBox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;excessArea&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boxes&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;perBox&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;area&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;roundingExtra&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boxes&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;perBox&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;requiredArea&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;Area, allowance, divide by the carton, round up. Every flooring calculator on the internet does this. The difference is in what gets returned, and the two extra fields at the bottom are the reason this post exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two kinds of "extra"
&lt;/h2&gt;

&lt;p&gt;When you buy flooring you always take home more than your floor. There are two reasons, and they are not the same kind of thing.&lt;/p&gt;

&lt;p&gt;The first is the allowance you chose for cuts, mistakes and a spare plank or two. Ten percent is the number people repeat. It is a decision, and it is yours.&lt;/p&gt;

&lt;p&gt;The second is that cartons come in fixed sizes. You cannot buy 3.86 cartons, so you buy 4, and the last carton's remainder lands on your garage floor whether you wanted it or not. That is not a decision. It is arithmetic.&lt;/p&gt;

&lt;p&gt;Most calculators add these together and show one number: "you need 4 boxes." The user then reads that as "my 10 percent allowance is included," which is true, and concludes the allowance did something, which is often false.&lt;/p&gt;

&lt;h2&gt;
  
  
  The worked example that changed the output
&lt;/h2&gt;

&lt;p&gt;A 12-by-15 foot living room is 180 square feet. Take three vinyl plank products from one manufacturer, all cut to the same 7-by-48-inch plank, with cartons of 51.33, 23.64 and 18.91 square feet. Those three figures come from Shaw's own product pages, read on 2026-09-10, and the exact styles are listed in the sources below.&lt;/p&gt;

&lt;p&gt;With a 10 percent allowance, the area to cover becomes 198 square feet:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Carton&lt;/th&gt;
&lt;th&gt;198 ÷ carton&lt;/th&gt;
&lt;th&gt;Buy&lt;/th&gt;
&lt;th&gt;Coverage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;51.33&lt;/td&gt;
&lt;td&gt;3.86&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;205.32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23.64&lt;/td&gt;
&lt;td&gt;8.38&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;212.76&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;18.91&lt;/td&gt;
&lt;td&gt;10.47&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;208.01&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now run the same floor with no allowance at all: 180 ÷ 51.33 is 3.51, so still 4 cartons. The other two drop to 8 and 10.&lt;/p&gt;

&lt;p&gt;For the largest carton, the 10 percent bought exactly nothing. Four cartons either way. That floor was already going to arrive with 25.32 square feet spare, because you cannot buy three and a half cartons, and 25.32 is more than the 18 square feet that a 10 percent allowance on 180 square feet adds. The rounding had already handed you more than the allowance was ever going to.&lt;/p&gt;

&lt;p&gt;On the smallest carton, the same 10 percent is the difference between 10 and 11 cartons. Real money, and also real safety, since dropping it leaves 9.10 square feet spare instead of 28.01.&lt;/p&gt;

&lt;p&gt;Same room. Same rule of thumb. It costs a carton in one case and zero in another, and the thing that decides which is a number printed on the side of the box that most people have never looked up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the calculator returns now
&lt;/h2&gt;

&lt;p&gt;So the result object separates them. &lt;code&gt;excessArea&lt;/code&gt; is everything above the measured floor. &lt;code&gt;roundingExtra&lt;/code&gt; is only the part above the area you asked for, the part you could not have avoided at that carton size. The page shows both, and a small chart on the rounding guide draws three bars for a 120 square foot floor: the floor, the floor with 10 percent, and what you actually buy in 23.64 square foot cartons. At 0, 10 and 15 percent allowance that floor is 6 cartons every time. Six, six, six. The allowance slider moves and the answer does not.&lt;/p&gt;

&lt;p&gt;I think that is the single most useful thing the page does. Not the count. The count any spreadsheet can produce. The useful part is telling someone, before they drive to the store, that the decision they are agonising over changes nothing for their carton, or that it changes exactly one carton and here is what that carton buys them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nobody upstream publishes the 10 percent
&lt;/h2&gt;

&lt;p&gt;We went looking for where the rule comes from. Shaw's vinyl planning and installation page and its flooring overview page, both read on 2026-09-10, state no waste or overage percentage and give no quantity guidance at all. What they do say is to "always work from multiple cartons to achieve a uniform appearance," which is worth knowing if your plan was to open one box at a time and return the rest.&lt;/p&gt;

&lt;p&gt;So the calculator labels the allowance as the user's input, not a recommendation. The assumption string in the result says so in plain words, and the page repeats it. Which number did you assume was a rule the last time you bought flooring?&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking arithmetic you publish
&lt;/h2&gt;

&lt;p&gt;Every worked number in the guides, including the 0 to 15 percent allowance table, was computed by hand and matched against the engine's regression tests before the pages went live. That is slow, and it is the only way I know to publish a table of numbers under my own name without flinching. The whole formula fits on one line: floor area × (1 + allowance) ÷ square feet per carton, rounded up. The work is in saying which part of the answer was your choice.&lt;/p&gt;

&lt;p&gt;The calculator is here: &lt;a href="https://www.wallmath.com/flooring-calculator/" rel="noopener noreferrer"&gt;https://www.wallmath.com/flooring-calculator/&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Shaw Prime Plank, style 0616V: 51.33 sq ft per carton, 7 in × 48 in planks: &lt;a href="https://shawfloors.com/en-us/vinyl/prime-plank-greyed-oak-7-/0616v-00532" rel="noopener noreferrer"&gt;https://shawfloors.com/en-us/vinyl/prime-plank-greyed-oak-7-/0616v-00532&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Shaw Distinction Plus, style 2045V: 23.64 sq ft per carton: &lt;a href="https://shawfloors.com/en-us/vinyl/distinction-plus-barrel-oak-7-/2045v-07066" rel="noopener noreferrer"&gt;https://shawfloors.com/en-us/vinyl/distinction-plus-barrel-oak-7-/2045v-07066&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Shaw Paladin Plus, style 0278V: 18.91 sq ft per carton: &lt;a href="https://shawfloors.com/en-us/vinyl/paladin-plus-fresh-pine-7-/0278v-05052" rel="noopener noreferrer"&gt;https://shawfloors.com/en-us/vinyl/paladin-plus-fresh-pine-7-/0278v-05052&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Shaw vinyl planning and installation page, no waste percentage stated (read 2026-09-10): &lt;a href="https://shawfloors.com/en-us/plan-and-install/vinyl" rel="noopener noreferrer"&gt;https://shawfloors.com/en-us/plan-and-install/vinyl&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Wall Math rounding guide (allowance vs whole-box rounding): &lt;a href="https://www.wallmath.com/flooring-boxes/" rel="noopener noreferrer"&gt;https://www.wallmath.com/flooring-boxes/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This post was drafted with AI help. The numbers and facts come from my own logs and repo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>javascript</category>
      <category>math</category>
    </item>
    <item>
      <title>The checkout never calls our server: how HideKit's rules run inside Shopify</title>
      <dc:creator>MinSoo Kim</dc:creator>
      <pubDate>Mon, 14 Sep 2026 12:03:29 +0000</pubDate>
      <link>https://dev.to/danorie/the-checkout-never-calls-our-server-how-hidekits-rules-run-inside-shopify-2b5i</link>
      <guid>https://dev.to/danorie/the-checkout-never-calls-our-server-how-hidekits-rules-run-inside-shopify-2b5i</guid>
      <description>&lt;p&gt;HideKit is a small Shopify app. A merchant writes rules like "hide cash on delivery for orders under ₩100,000 shipped to Korea" or "rename Standard Shipping to Ground for US addresses", and the checkout follows them. If ten thousand merchants installed it tomorrow, our server would not get one extra request at checkout time. That is the part I want to explain, because it decided almost everything else about how the app is built.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the rules live
&lt;/h2&gt;

&lt;p&gt;The rules do not live in our database. They live in one JSON document in a Shopify metafield attached to the merchant's payment customization, under the namespace &lt;code&gt;$app:payment-rules&lt;/code&gt; with the key &lt;code&gt;function-configuration&lt;/code&gt;. A second customization holds the delivery rules. Our admin app writes to that metafield, and nothing else of ours is involved after that.&lt;/p&gt;

&lt;p&gt;At checkout, Shopify runs a Function. Ours targets &lt;code&gt;cart.payment-methods.transform.run&lt;/code&gt;, and a sibling targets &lt;code&gt;cart.delivery-options.transform.run&lt;/code&gt;. Shopify hands the function an input it asked for in a GraphQL query: the cart total and currency, discount applications, the buyer's company if it is a B2B checkout, the customer's amount spent, order count and tags, the delivery address down to province and zip, each line's weight and collection membership, and the checkout language. The function reads the metafield, evaluates the rules against that input, and returns a list of operations: hide this method, rename that one, move this one to a different position. Three operation types, one metafield read, zero network calls. That is the whole runtime. If our servers are down, the checkout does not notice.&lt;/p&gt;

&lt;p&gt;The function is compiled to WebAssembly and executed by Shopify, so the engine had to be plain TypeScript with no I/O. That constraint turned out to be a gift.&lt;/p&gt;

&lt;h2&gt;
  
  
  One engine, two callers
&lt;/h2&gt;

&lt;p&gt;Because the engine is a pure module, &lt;code&gt;shared/rules-engine&lt;/code&gt;, the admin app can import the same code. HideKit has a page called the Rule Tester where a merchant types in a cart: country, total, products, tags, and a list of payment methods (the default is three: cash on delivery, credit card, bank deposit). The page runs the merchant's saved rules through the same &lt;code&gt;evaluateRules&lt;/code&gt; function the checkout uses and shows which rules fire and what each method would look like. No test orders, no fake customers, and no chance that the tester and the checkout disagree, because there is only one implementation.&lt;/p&gt;

&lt;p&gt;"Why isn't it hidden?" is the support question every app in this category gets. The tester exists so the merchant can answer it themselves before a customer ever sees the checkout. Which method do your customers keep picking that you wish they wouldn't? That is usually the first rule a merchant writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing a thing you cannot step through
&lt;/h2&gt;

&lt;p&gt;You cannot attach a debugger to a function running inside Shopify's checkout. So the tests run the compiled wasm against fixtures. There were 89 tests in the suite at the last count I wrote down, including an 18-case condition matrix run through the wasm build and boundary fixtures such as a rule at 20 with carts at 20 and 19.99, because "greater than or equal" is exactly the kind of thing that gets flipped in a refactor.&lt;/p&gt;

&lt;p&gt;The end-to-end check was done by hand on a development store with a rule that reads "country is KR AND total is at least ₩100,000": a ₩58,000 cart showed cash on delivery, a ₩116,000 cart hid it. Boring, and the only proof that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The incident that became a smoke test
&lt;/h2&gt;

&lt;p&gt;Cleaning up a development preview once removed the payment customization that owned the metafield. The rules were still visible in the merchant's admin, pointing at an owner that no longer existed. The fix was to recreate the customization with the released function's ID, and doing it through the app's own UI turned the fix into a full pass of the create path: UI to server to the customization-create mutation to &lt;code&gt;metafieldsSet&lt;/code&gt;. It is the most useful bug we have had, because it forced the exact flow a new merchant goes through.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the architecture cannot do
&lt;/h2&gt;

&lt;p&gt;Living inside Shopify means living with Shopify's limits, and we list them in the app instead of discovering them with a merchant. On non-Plus stores, checkout does not allow hiding the Shopify Payments gateway itself; only individual methods like cash on delivery or manual payments can be hidden. There is also a known checkout bug where a customer who selected a method that then becomes hidden can get stuck, so onboarding flags it up front. Two limits, both written on the listing, because a refund request is a worse place to learn about them.&lt;/p&gt;

&lt;p&gt;I would build it this way again. The rules being the merchant's data, in their store, evaluated by the platform, is not only a nice property for uptime. It means uninstalling the app leaves nothing behind in the theme, and it means the thing the merchant tests is the thing that runs.&lt;/p&gt;

&lt;p&gt;If you want to see the tester, the listing is here: &lt;a href="https://apps.shopify.com/hidekit" rel="noopener noreferrer"&gt;https://apps.shopify.com/hidekit&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Payment customization function target: &lt;a href="https://shopify.dev/docs/api/functions/reference/payment-customization" rel="noopener noreferrer"&gt;https://shopify.dev/docs/api/functions/reference/payment-customization&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Delivery customization function target: &lt;a href="https://shopify.dev/docs/api/functions/reference/delivery-customization" rel="noopener noreferrer"&gt;https://shopify.dev/docs/api/functions/reference/delivery-customization&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Shopify Functions overview (WebAssembly execution, input queries): &lt;a href="https://shopify.dev/docs/apps/build/functions" rel="noopener noreferrer"&gt;https://shopify.dev/docs/apps/build/functions&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;HideKit listing, including the platform limits section: &lt;a href="https://apps.shopify.com/hidekit" rel="noopener noreferrer"&gt;https://apps.shopify.com/hidekit&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This post was drafted with AI help. The numbers and facts come from my own logs and repo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
