<?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: Ofri Peretz</title>
    <description>The latest articles on DEV Community by Ofri Peretz (@ofri-peretz).</description>
    <link>https://dev.to/ofri-peretz</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%2F3669992%2F6b34d598-2f6e-4b24-89ac-4edb086ffb9b.png</url>
      <title>DEV Community: Ofri Peretz</title>
      <link>https://dev.to/ofri-peretz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ofri-peretz"/>
    <language>en</language>
    <item>
      <title>Everyone Greps for SQL Injection. Nobody Greps for the Other Eight.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sun, 27 Sep 2026 19:27:57 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/everyone-greps-for-sql-injection-nobody-greps-for-the-other-eight-4pjm</link>
      <guid>https://dev.to/ofri-peretz/everyone-greps-for-sql-injection-nobody-greps-for-the-other-eight-4pjm</guid>
      <description>&lt;p&gt;Ask a developer to find injection in a codebase and they grep for string concatenation near a database call. They will find the SQL. They will walk past eight other interpreters on the way.&lt;/p&gt;

&lt;p&gt;An interpreter nobody remembers is still an interpreter. Build a string, hand it to something that parses it, and you have the same bug — whether or not that parser speaks SQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape, not the syntax
&lt;/h2&gt;

&lt;p&gt;The taxonomy already knew this. &lt;a href="https://ofriperetz.dev/go/r/oifoecdzhl?utm_source=devto&amp;amp;from=injection-beyond-sql" rel="noopener noreferrer"&gt;CWE-943&lt;/a&gt; is "Improper Neutralization of Special Elements in Data Query Logic" — the parent class that SQL injection is merely the famous child of. Its siblings are the same defect aimed at different parsers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Injection&lt;/th&gt;
&lt;th&gt;CWE&lt;/th&gt;
&lt;th&gt;The interpreter you forgot&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GraphQL&lt;/td&gt;
&lt;td&gt;&lt;a href="https://ofriperetz.dev/go/r/oifoecdzhl?utm_source=devto&amp;amp;from=injection-beyond-sql" rel="noopener noreferrer"&gt;CWE-943&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;the query resolver&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LDAP&lt;/td&gt;
&lt;td&gt;&lt;a href="https://ofriperetz.dev/go/r/53boeup8fm?utm_source=devto&amp;amp;from=injection-beyond-sql" rel="noopener noreferrer"&gt;CWE-90&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;the directory filter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XPath&lt;/td&gt;
&lt;td&gt;&lt;a href="https://ofriperetz.dev/go/r/q81s0sg350?utm_source=devto&amp;amp;from=injection-beyond-sql" rel="noopener noreferrer"&gt;CWE-643&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;the XML path engine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XXE&lt;/td&gt;
&lt;td&gt;&lt;a href="https://ofriperetz.dev/go/r/z83zibdpu3?utm_source=devto&amp;amp;from=injection-beyond-sql" rel="noopener noreferrer"&gt;CWE-611&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;the XML entity resolver&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Template&lt;/td&gt;
&lt;td&gt;&lt;a href="https://ofriperetz.dev/go/r/7s1i4oqkq7?utm_source=devto&amp;amp;from=injection-beyond-sql" rel="noopener noreferrer"&gt;CWE-94&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;the template compiler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Directive&lt;/td&gt;
&lt;td&gt;&lt;a href="https://ofriperetz.dev/go/r/p8zyd4svrq?utm_source=devto&amp;amp;from=injection-beyond-sql" rel="noopener noreferrer"&gt;CWE-96&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;the server-side include&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Format string&lt;/td&gt;
&lt;td&gt;&lt;a href="https://ofriperetz.dev/go/r/up96k5xh8m?utm_source=devto&amp;amp;from=injection-beyond-sql" rel="noopener noreferrer"&gt;CWE-134&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;the format specifier parser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Object / prototype&lt;/td&gt;
&lt;td&gt;&lt;a href="https://ofriperetz.dev/go/r/1kazi0cikub?utm_source=devto&amp;amp;from=injection-beyond-sql" rel="noopener noreferrer"&gt;CWE-915&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;the JavaScript engine itself&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Be precise about what that parent covers: the honest version is narrower than the tidy one. MITRE lists exactly four children under CWE-943 — check the Research Concepts view (View-1000) on that page — and all four are query languages: &lt;a href="https://ofriperetz.dev/go/sql-injection-node-postgres-pattern?utm_source=devto&amp;amp;from=injection-beyond-sql" rel="noopener noreferrer"&gt;SQL&lt;/a&gt; (CWE-89), LDAP (CWE-90), XPath (CWE-643) and XQuery (&lt;a href="https://ofriperetz.dev/go/r/2g332hx5v93?utm_source=devto&amp;amp;from=injection-beyond-sql" rel="noopener noreferrer"&gt;CWE-652&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Everything else in the table is &lt;em&gt;commonly mapped&lt;/em&gt; there, not formally filed under it. GraphQL has no dedicated CWE at all; neither do the &lt;a href="https://ofriperetz.dev/go/getting-started-eslint-plugin-mongodb-security?utm_source=devto&amp;amp;from=injection-beyond-sql" rel="noopener noreferrer"&gt;NoSQL operator injections&lt;/a&gt;. And XXE, format string and prototype pollution are not siblings — they are separate defects that happen to share a blind spot, not a parent.&lt;/p&gt;

&lt;p&gt;Eight interpreters plus SQL, in two distinct shapes — data-query injection (the CWE-943 family) and code/context injection (everything else in the table). SQL got the name people recognise. The parser inside your LDAP filter did not, and that is the entire reason it goes unaudited.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three that fail in ways SQL does not
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;XXE needs no injected operator at all.&lt;/strong&gt; Parse attacker-supplied XML with entity resolution enabled and the parser fetches local files on their behalf. There is no quote to escape, no operator to smuggle. The payload is the document.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LDAP fails silently.&lt;/strong&gt; &lt;code&gt;(uid=&lt;/code&gt; plus a string is a filter, and a stray &lt;code&gt;)&lt;/code&gt; turns an authentication check into a wildcard matching every entry in the directory. No error, no stack trace — a login that simply succeeds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Template injection escalates further than SQL.&lt;/strong&gt; &lt;a href="https://ofriperetz.dev/go/r/22tmnjr54l4?utm_source=devto&amp;amp;from=injection-beyond-sql" rel="noopener noreferrer"&gt;PortSwigger's server-side template injection research&lt;/a&gt; showed a template engine will hand over remote code execution as readily as a database hands over rows. A templating call is not a formatting convenience; it is an evaluator.&lt;/p&gt;

&lt;p&gt;GraphQL is the odd one, because injection is only half of it. A query the client shapes is also a cost problem — the caller, not you, decides how many nested resolvers run. That is resource exhaustion (&lt;a href="https://ofriperetz.dev/go/r/1jwfv9lhpa4?utm_source=devto&amp;amp;from=injection-beyond-sql" rel="noopener noreferrer"&gt;CWE-400&lt;/a&gt;) wearing a query's clothes, and why a depth limit is a security control, not a performance tweak.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why grep finds SQL and misses the rest
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://ofriperetz.dev/go/r/g6zs5f0wnx?utm_source=devto&amp;amp;from=injection-beyond-sql" rel="noopener noreferrer"&gt;OWASP&lt;/a&gt; folds this whole family into A03. Most tooling does not, because a pattern matcher looks for a &lt;em&gt;sink it has been taught&lt;/em&gt; — a &lt;code&gt;query(&lt;/code&gt;, an &lt;code&gt;execute(&lt;/code&gt;. Nobody teaches it &lt;code&gt;XPathEvaluator&lt;/code&gt;, &lt;code&gt;libxmljs&lt;/code&gt;, an LDAP &lt;code&gt;filter:&lt;/code&gt;, a &lt;code&gt;compile(&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is also the honest limit of pattern matching. Seeing a &lt;code&gt;+&lt;/code&gt; flowing into a sink does not prove the value came from a request, and it goes quiet when the concatenation happens one helper away. That gap between "looks dangerous" and "is reachable" is the whole subject of &lt;a href="https://ofriperetz.dev/go/taint-vs-heuristic-detection?utm_source=devto&amp;amp;from=injection-beyond-sql" rel="noopener noreferrer"&gt;taint analysis&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually do
&lt;/h2&gt;

&lt;p&gt;Do not start from a checklist. Grep your own code for the &lt;em&gt;constructors&lt;/em&gt;: &lt;code&gt;XPathEvaluator&lt;/code&gt;, &lt;code&gt;libxmljs&lt;/code&gt;, an LDAP &lt;code&gt;filter:&lt;/code&gt;, a template &lt;code&gt;compile(&lt;/code&gt;, any &lt;code&gt;%s&lt;/code&gt; you assemble by hand. For each, ask one question — can user input reach this string?&lt;/p&gt;

&lt;p&gt;Grep is the triage list, not the verdict: it tells you &lt;em&gt;where to look&lt;/em&gt;, taint tells you &lt;em&gt;whether it reaches&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;If the answer is "probably not, but I would have to check", that is exactly the answer most people give about SQL right before they find the other eight.&lt;/p&gt;

&lt;p&gt;Which interpreter is live in your stack right now that you have never grepped for? My money is on GraphQL.&lt;/p&gt;

&lt;p&gt;More on how these classes get named and ranked: &lt;a href="https://ofriperetz.dev/go/cwe-taxonomy-explained?utm_source=devto&amp;amp;from=injection-beyond-sql" rel="noopener noreferrer"&gt;the CWE taxonomy&lt;/a&gt; and &lt;a href="https://ofriperetz.dev/go/owasp-top-10-explained?utm_source=devto&amp;amp;from=injection-beyond-sql" rel="noopener noreferrer"&gt;the OWASP Top 10&lt;/a&gt;. I write these at &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=injection-beyond-sql" rel="noopener noreferrer"&gt;dev.to/ofri-peretz&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint?utm_source=devto&amp;amp;from=injection-beyond-sql" rel="noopener noreferrer"&gt;⭐ Star the repo if you would rather your linter knew about the other eight interpreters too.&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>security</category>
      <category>webdev</category>
      <category>node</category>
    </item>
    <item>
      <title>One Import Moves a commander CLI to burgee. --json Still Says null.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Wed, 23 Sep 2026 15:28:33 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/one-import-moves-a-commander-cli-to-burgee-json-still-says-null-6h8</link>
      <guid>https://dev.to/ofri-peretz/one-import-moves-a-commander-cli-to-burgee-json-still-says-null-6h8</guid>
      <description>&lt;p&gt;I changed one line in a commander CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- import { Command } from "commander";
&lt;/span&gt;&lt;span class="gi"&gt;+ import { Command } from "burgee/commander";
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Help, output, errors and exit codes stayed byte-identical on seven invocations. The same file also answers &lt;code&gt;--schema&lt;/code&gt;, &lt;code&gt;--mcp&lt;/code&gt; and &lt;code&gt;completion &amp;lt;shell&amp;gt;&lt;/code&gt;. And &lt;code&gt;--json&lt;/code&gt; answers &lt;code&gt;"data":null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One import describes the CLI to an agent; answering takes a &lt;code&gt;return&lt;/code&gt;. I &lt;a href="https://ofriperetz.dev/go/i-built-what-i-benchmark-heres-how-i-try-not-to-cheat?utm_source=devto&amp;amp;from=burgee-change-one-import" rel="noopener noreferrer"&gt;maintain burgee&lt;/a&gt;; everything below ran on &lt;code&gt;commander@15.0.0&lt;/code&gt; and &lt;code&gt;burgee@0.11.1&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The program
&lt;/h2&gt;

&lt;p&gt;Plain commander, ESM, as &lt;code&gt;cli.commander.js&lt;/code&gt;:&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="cp"&gt;#!/usr/bin/env node
&lt;/span&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Command&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;commander&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;program&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;program&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lines&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Count and rank the lines in a text file&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;version&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1.0.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;program&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;count&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;count the lines in a file&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;file&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;file to read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--skip-blank&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;ignore empty lines&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;action&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trimEnd&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&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;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;skipBlank&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&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;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; lines in &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;program&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;longest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;print the longest lines in a file&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;file&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;file to read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-n, --top &amp;lt;count&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;how many lines to show&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;3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;action&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trimEnd&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&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;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;program&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The swap
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;commander@15.0.0 burgee@0.11.1   &lt;span class="c"&gt;# or: yarn/pnpm/bun add&lt;/span&gt;
npm pkg &lt;span class="nb"&gt;set type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;module
&lt;span class="nb"&gt;cp &lt;/span&gt;cli.commander.js cli.js
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'alpha\n\nbravo charlie\ndelta echo foxtrot golf\n\nhotel\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; sample.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All four work, as does &lt;code&gt;require()&lt;/code&gt;. TypeScript's legacy &lt;code&gt;node&lt;/code&gt; resolver cannot see the subpath. After the import swap in &lt;code&gt;cli.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;diff &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;node cli.commander.js &lt;span class="nt"&gt;--help&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;node cli.js &lt;span class="nt"&gt;--help&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;identical
&lt;span class="go"&gt;identical
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same for &lt;code&gt;count --help&lt;/code&gt;, both commands, a missing argument and a typo. Usage errors still exit &lt;code&gt;1&lt;/code&gt;; burgee's native exit &lt;code&gt;2&lt;/code&gt; for "rewrite the command" does not come with the swap. Once &lt;code&gt;commander&lt;/code&gt; leaves &lt;code&gt;dependencies&lt;/code&gt;, the declared Node range goes from &lt;code&gt;&amp;gt;=22.12.0&lt;/code&gt; to &lt;code&gt;^20.19.0 || &amp;gt;=22.13.0&lt;/code&gt;. The swapped file ran on Node 20 and 22; only 22.12.x drops out.&lt;/p&gt;

&lt;h2&gt;
  
  
  What answers with no other edit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--schema&lt;/code&gt;&lt;/strong&gt;: the command tree as data, &lt;a href="https://ofriperetz.dev/go/securing-ai-agents-in-the-vercel-ai-sdk?utm_source=devto&amp;amp;from=burgee-change-one-import" rel="noopener noreferrer"&gt;a JSON Schema per command&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node cli.js &lt;span class="nt"&gt;--schema&lt;/span&gt; | jq &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'.commands[0].inputSchema'&lt;/span&gt;
&lt;span class="go"&gt;{"type":"object","properties":{"file":{"type":"string","description":"file to read"},"skipBlank":{"type":"boolean","flag":"--skip-blank","description":"ignore empty lines"}},"required":["file"],"additionalProperties":false}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Errors under &lt;code&gt;--json&lt;/code&gt;&lt;/strong&gt;: typed, with the fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node cli.js count sample.txt &lt;span class="nt"&gt;--skp-blank&lt;/span&gt; &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;span class="go"&gt;{"ok":false,"error":{"code":"commander.unknownOption","message":"unknown option '--skp-blank'","fix":"--skip-blank"}}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;a href="https://ofriperetz.dev/go/r/2fuz2t9t523?utm_source=devto&amp;amp;from=burgee-change-one-import" rel="noopener noreferrer"&gt;MCP&lt;/a&gt; discovery&lt;/strong&gt;: every command becomes a tool, and an undeclared one says so instead of guessing a hint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"tools/list"}'&lt;/span&gt; | node cli.js &lt;span class="nt"&gt;--mcp&lt;/span&gt; | jq &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'.result.tools[] | {name, annotations}'&lt;/span&gt;
&lt;span class="go"&gt;{"name":"count","annotations":{"effects":"undeclared"}}
{"name":"longest","annotations":{"effects":"undeclared"}}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.effects("read_only")&lt;/code&gt; on &lt;code&gt;count&lt;/code&gt; yields &lt;code&gt;readOnlyHint: true&lt;/code&gt;. That call is a one-way door: real commander throws a &lt;code&gt;TypeError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Completions&lt;/strong&gt;: &lt;code&gt;completion &amp;lt;shell&amp;gt;&lt;/code&gt; for bash, zsh, fish, pwsh and fig, as static output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where one import stops
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node cli.js count sample.txt &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;span class="go"&gt;6 lines in sample.txt
{"ok":true,"data":null,"meta":{"provenance":{}}}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;burgee's envelope wraps what an action &lt;em&gt;returns&lt;/em&gt;. commander discards return values, so commander programs rarely write one. Under &lt;code&gt;--mcp&lt;/code&gt;, that printed line lands on the JSON-RPC stream, where no client can parse it.&lt;/p&gt;

&lt;p&gt;The second edit, in &lt;code&gt;cli.answering.js&lt;/code&gt;: return the result, and keep prose off stdout when a machine asked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gi"&gt;+const machine = ["--json", "--mcp"].some((f) =&amp;gt; process.argv.includes(f));
+const say = (text) =&amp;gt; {
+  if (!machine) console.log(text);
+};
&lt;/span&gt; ...
&lt;span class="gd"&gt;-    console.log(`${lines.length} lines in ${file}`);
&lt;/span&gt;&lt;span class="gi"&gt;+    say(`${lines.length} lines in ${file}`);
+    return { file, lines: lines.length };
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node cli.answering.js count sample.txt &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;span class="go"&gt;{"ok":true,"data":{"file":"sample.txt","lines":6},"meta":{"provenance":{}}}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Humans still see &lt;code&gt;6 lines in sample.txt&lt;/code&gt;. MCP tool calls get the same envelope. The &lt;code&gt;argv&lt;/code&gt; test is crude. 0.11.1's façade has no public "machine asked?" flag yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two defects, fixed in 0.11.1
&lt;/h2&gt;

&lt;p&gt;Drafting on 0.9.2, I hit two defects in what the swap adds. An MCP call ignored the schema's &lt;code&gt;"flag":"--skip-blank"&lt;/code&gt; and passed &lt;code&gt;--skipBlank&lt;/code&gt;, which the program refused. Completions offered &lt;code&gt;--no-skip-blank&lt;/code&gt; and &lt;code&gt;--no-version&lt;/code&gt;, likewise refused. I fixed both in 0.11.1. The same call now answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"count","arguments":{"file":"sample.txt","skipBlank":true}}}'&lt;/span&gt; | node cli.answering.js &lt;span class="nt"&gt;--mcp&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.result.content[0].text'&lt;/span&gt;
&lt;span class="go"&gt;{"ok":true,"data":{"file":"sample.txt","lines":4},"meta":{"provenance":{"skipBlank":{"source":"flag"}}}}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still open: an action's throw stays uncaught under &lt;code&gt;--json&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the grade covers
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://ofriperetz.dev/go/r/unfeqptkco?utm_source=devto&amp;amp;from=burgee-change-one-import" rel="noopener noreferrer"&gt;commander's own suite&lt;/a&gt; (v15.0.0, 110 files, unmodified) runs against &lt;code&gt;burgee/commander&lt;/code&gt; next to a control on real commander. Re-run for this article: &lt;strong&gt;1360 / 1360&lt;/strong&gt;, control 1360 / 1360. That grade measures what you &lt;em&gt;keep&lt;/em&gt;. The two defects sat in what you &lt;em&gt;gain&lt;/em&gt;, which commander's tests cannot see.&lt;/p&gt;

&lt;h2&gt;
  
  
  When commander alone is the right size
&lt;/h2&gt;

&lt;p&gt;On disk, commander 15.0.0 unpacks to 207,368 bytes; burgee 0.11.1 and the &lt;a href="https://ofriperetz.dev/go/burgee-zero-dependency-cli-stack?utm_source=devto&amp;amp;from=burgee-change-one-import" rel="noopener noreferrer"&gt;five sibling packages&lt;/a&gt; it installs, to 1,266,628. In a bundle, burgee's own &lt;code&gt;lighter-than-commander&lt;/code&gt; gate measures &lt;strong&gt;not met, 1.524×&lt;/strong&gt;. If no agent will call your CLI, commander is the smaller choice.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://ofriperetz.dev/go/r/11og8jstylb?utm_source=devto&amp;amp;from=burgee-change-one-import" rel="noopener noreferrer"&gt;burgee vs commander&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/j58a8rly66?utm_source=devto&amp;amp;from=burgee-change-one-import" rel="noopener noreferrer"&gt;compatibility&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/burgee?utm_source=devto&amp;amp;from=burgee-change-one-import" rel="noopener noreferrer"&gt;source&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Which command in your CLI prints something an agent currently scrapes with a regex, and what would returning it instead break?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>A commander Stack Installs 70 Packages, burgee's Family 9. Like for Like, the Family Weighs 2.4 More.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Wed, 23 Sep 2026 08:13:43 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/a-commander-stack-installs-70-packages-burgees-family-9-like-for-like-the-family-weighs-24x-aed</link>
      <guid>https://dev.to/ofri-peretz/a-commander-stack-installs-70-packages-burgees-family-9-like-for-like-the-family-weighs-24x-aed</guid>
      <description>&lt;p&gt;I maintain burgee, nine CLI packages, and counted them against the stack they replace, one usual package per layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i commander chalk ora inquirer string-width cosmiconfig execa signal-exit terminal-link
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;70 packages, maintained by 25 npm accounts.&lt;/strong&gt; burgee's nine install &lt;strong&gt;9 packages from 1 account&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is the flattering row. &lt;a href="https://ofriperetz.dev/go/eslint-plugin-dependency-weight?utm_source=devto&amp;amp;from=burgee-zero-dependency-cli-stack" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; printed the rows against my plugins; same method, same rule here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer by layer
&lt;/h2&gt;

&lt;p&gt;Each incumbent is the first whole package in burgee's own "Replaces" column (the terminal row lists half of ansi-escapes first; with it, 66 packages), so the stack comes from &lt;a href="https://ofriperetz.dev/go/i-built-what-i-benchmark-heres-how-i-try-not-to-cheat?utm_source=devto&amp;amp;from=burgee-zero-dependency-cli-stack" rel="noopener noreferrer"&gt;my design surface, not a neutral sample&lt;/a&gt;. Resolved 2026-09-23, one package at a time; KB is 1,024 bytes of files, whole tree included.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;incumbent&lt;/th&gt;
&lt;th&gt;pkgs&lt;/th&gt;
&lt;th&gt;KB&lt;/th&gt;
&lt;th&gt;burgee family&lt;/th&gt;
&lt;th&gt;pkgs&lt;/th&gt;
&lt;th&gt;KB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="mailto:commander@15.0.0"&gt;commander@15.0.0&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;203&lt;/td&gt;
&lt;td&gt;&lt;a href="mailto:burgee@0.10.0"&gt;burgee@0.10.0&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;1169&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="mailto:chalk@6.0.0"&gt;chalk@6.0.0&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;td&gt;&lt;a href="mailto:roundel@0.4.3"&gt;roundel@0.4.3&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;76&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="mailto:ora@9.4.1"&gt;ora@9.4.1&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;278&lt;/td&gt;
&lt;td&gt;&lt;a href="mailto:flagstaff@0.3.7"&gt;flagstaff@0.3.7&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;546&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="mailto:inquirer@14.2.2"&gt;inquirer@14.2.2&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;td&gt;1002&lt;/td&gt;
&lt;td&gt;&lt;a href="mailto:caique@0.4.3"&gt;caique@0.4.3&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;302&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="mailto:string-width@8.2.2"&gt;string-width@8.2.2&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;&lt;a href="mailto:linegauge@0.4.4"&gt;linegauge@0.4.4&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;83&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="mailto:cosmiconfig@10.0.1"&gt;cosmiconfig@10.0.1&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;1789&lt;/td&gt;
&lt;td&gt;&lt;a href="mailto:seniority@0.4.3"&gt;seniority@0.4.3&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;149&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="mailto:execa@10.0.1"&gt;execa@10.0.1&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;632&lt;/td&gt;
&lt;td&gt;&lt;a href="mailto:bellpull@0.2.3"&gt;bellpull@0.2.3&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;96&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="mailto:signal-exit@4.1.0"&gt;signal-exit@4.1.0&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;75&lt;/td&gt;
&lt;td&gt;&lt;a href="mailto:closeout@0.4.1"&gt;closeout@0.4.1&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="mailto:terminal-link@5.0.0"&gt;terminal-link@5.0.0&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;&lt;a href="mailto:paratext@0.5.4"&gt;paratext@0.5.4&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;91&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;On bytes, burgee's side is heavier in six of nine layers.&lt;/strong&gt; Installed together, the incumbents weigh 3,851 KB; burgee, 1,577. The incumbent trees overlap (79 summed, 70 installed); burgee's overlap completely (20 summed, 9 installed).&lt;/p&gt;

&lt;h2&gt;
  
  
  Where that byte gap comes from
&lt;/h2&gt;

&lt;p&gt;Three layers carry it, none cleanly.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;cosmiconfig is 1,789 KB, and 1,702 of that is js-yaml and its argparse.&lt;/strong&gt; seniority parses no YAML: it reads the JSON subset and refuses the rest by name. Against cosmiconfig's own test suite it passes 186 of 243.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;execa has no graded drop-in.&lt;/strong&gt; bellpull's compat row is cross-spawn, not execa.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;inquirer&lt;/strong&gt; is graded through &lt;code&gt;@inquirer/core&lt;/code&gt; (41 of 41), not &lt;code&gt;inquirer&lt;/code&gt; itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep only layers where burgee's drop-in scores what the incumbent scores on &lt;a href="https://ofriperetz.dev/go/r/4ourcwqz96?utm_source=devto&amp;amp;from=burgee-zero-dependency-cli-stack" rel="noopener noreferrer"&gt;its own suite&lt;/a&gt;: commander, chalk, ora, &lt;code&gt;@inquirer/core@12.0.3&lt;/code&gt;, string-width, signal-exit (not terminal-link: 8 of 10). &lt;strong&gt;28 packages, 662 KB.&lt;/strong&gt; burgee still installs all nine (sibling dependencies pull in the other three), 1,577 KB. &lt;strong&gt;Like for like, burgee is 2.4× heavier on disk&lt;/strong&gt;, while the count stays 28 against 9, maintainers 14 against one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three gates burgee publishes as not met
&lt;/h2&gt;

&lt;p&gt;burgee's &lt;a href="https://ofriperetz.dev/go/r/2chfqwzmyn4?utm_source=devto&amp;amp;from=burgee-zero-dependency-cli-stack" rel="noopener noreferrer"&gt;README&lt;/a&gt; states nine gates; three fail their ≤ 1.0× target. I re-ran each with burgee's own fixtures:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;gate&lt;/th&gt;
&lt;th&gt;published&lt;/th&gt;
&lt;th&gt;re-run&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;burgee&lt;/code&gt; bundle vs cac&lt;/td&gt;
&lt;td&gt;2.636×&lt;/td&gt;
&lt;td&gt;2.661×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;burgee/commander&lt;/code&gt; bundle vs commander&lt;/td&gt;
&lt;td&gt;1.514×&lt;/td&gt;
&lt;td&gt;1.520×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cold start vs cac&lt;/td&gt;
&lt;td&gt;1.443×&lt;/td&gt;
&lt;td&gt;1.67–2.31×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The bundle rows agree within a percent. The cold-start re-runs, at load average above 80, read worse than burgee's published figure. Either way, not met. burgee's parity rows compare its bundles against cac, commander or yargs &lt;strong&gt;plus&lt;/strong&gt; cosmiconfig, exit-hook and restore-cursor (0.282×, 0.468×, 0.531×). Those stacks include cosmiconfig, which my like-for-like rule excludes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "zero dependencies" means here
&lt;/h2&gt;

&lt;p&gt;Six of nine declare no dependencies. burgee, flagstaff and caique depend only on those six: &lt;strong&gt;no dependency outside the family&lt;/strong&gt;, which is not zero. burgee's README table says 0 runtime dependencies; the registry lists five, all siblings.&lt;/p&gt;

&lt;p&gt;One account is also a &lt;a href="https://ofriperetz.dev/go/eslint-plugin-maintenance-signals?utm_source=devto&amp;amp;from=burgee-zero-dependency-cli-stack" rel="noopener noreferrer"&gt;concentration&lt;/a&gt;. Mid-measurement, my pipeline published &lt;a href="mailto:burgee@0.10.0"&gt;burgee@0.10.0&lt;/a&gt; at 05:34 UTC, then the closeout and linegauge it requires at 05:39 and 05:42; for eight minutes &lt;code&gt;npm i burgee&lt;/code&gt; failed with &lt;code&gt;ETARGET&lt;/code&gt;. And the family declares Node 24, where the incumbent stack's floor is Node 22.18. If you support Node 22.18, the incumbents are your option.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check yours
&lt;/h2&gt;

&lt;p&gt;Part 1's method, plus bytes and maintainers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm init &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
npm i &lt;span class="nt"&gt;--package-lock-only&lt;/span&gt; &amp;lt;your dependencies&amp;gt;
node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"console.log(Object.keys(require('./package-lock.json').packages).filter(k=&amp;gt;k.startsWith('node_modules/')).length)"&lt;/span&gt;
npm ci &lt;span class="nt"&gt;--ignore-scripts&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"let t=0;const f=require('fs'),w=d=&amp;gt;f.readdirSync(d,{withFileTypes:true}).forEach(e=&amp;gt;{const p=d+'/'+e.name;e.isDirectory()?e.name!=='.bin'&amp;amp;&amp;amp;w(p):e.isFile()&amp;amp;&amp;amp;e.name!=='.package-lock.json'&amp;amp;&amp;amp;(t+=f.statSync(p).size)});w('node_modules');console.log(t)"&lt;/span&gt;
node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"const l=require('./package-lock.json').packages,m=new Set(),u=[];Promise.all(Object.keys(l).filter(k=&amp;gt;k.startsWith('node_modules/')).map(k=&amp;gt;fetch('https://registry.npmjs.org/'+k.split('node_modules/').pop().replace('/','%2F')).then(r=&amp;gt;r.ok?r.json():Promise.reject()).then(d=&amp;gt;(d.maintainers||[]).forEach(x=&amp;gt;m.add(x.name))).catch(()=&amp;gt;u.push(k)))).then(()=&amp;gt;console.log(m.size,'unfetched',u.length))"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Part 1 subtracted a 69-package ESLint baseline; here the shared baseline is Node, zero packages. pnpm, yarn 1 and bun resolve the same counts. The family ships often and its caret ranges float: pin all nine as tabled, or expect drift.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Run it on your CLI: how many npm accounts can ship code into your tree, and did you know that number before today?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;[If the method earns a place in your release review, ⭐ &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/burgee?utm_source=devto&amp;amp;from=burgee-zero-dependency-cli-stack" rel="noopener noreferrer"&gt;star burgee&lt;/a&gt;.](&lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/burgee?utm_source=devto&amp;amp;from=burgee-zero-dependency-cli-stack" rel="noopener noreferrer"&gt;https://ofriperetz.dev/go/gh/ofri-peretz/burgee?utm_source=devto&amp;amp;from=burgee-zero-dependency-cli-stack&lt;/a&gt;)&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>cli</category>
      <category>webdev</category>
    </item>
    <item>
      <title>55 Places My Code Predates the Language. All Auto-Fixed.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Wed, 23 Sep 2026 04:04:28 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/55-places-my-code-predates-the-language-all-auto-fixed-21dd</link>
      <guid>https://dev.to/ofri-peretz/55-places-my-code-predates-the-language-all-auto-fixed-21dd</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;latest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is wrong with that line. It passes review, ships, and works. It is also how you wrote JavaScript before 2022, and I have written it a thousand times since.&lt;/p&gt;

&lt;p&gt;I ran four modernization rules over &lt;strong&gt;389 &lt;code&gt;.ts&lt;/code&gt;/&lt;code&gt;.tsx&lt;/code&gt; files&lt;/strong&gt; across four of my own repos. &lt;strong&gt;55 findings in 36 files&lt;/strong&gt; — and every single one carried an autofix. After &lt;code&gt;--fix&lt;/code&gt;, the remaining count was &lt;strong&gt;zero&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The two that fired
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;prefer-at&lt;/code&gt; — 11 findings. The end-of-array idiom, mechanically rewritten:&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;// before&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;latest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;accrual&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;accrual&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;articles&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="cm"&gt;/* … */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// after --fix&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;latest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;accrual&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;articles&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="cm"&gt;/* … */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;prefer-template-literal&lt;/code&gt; — 44 findings:&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;// before&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stderr&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdout&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude exited &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// after --fix&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stderr&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdout&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;`claude exited &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this survives review forever:&lt;/strong&gt; a reviewer's job is to reject &lt;em&gt;broken&lt;/em&gt; code, and none of this is broken. &lt;code&gt;arr[arr.length - 1]&lt;/code&gt; is correct in every runtime that ever existed. The only thing that can flag it is a tool that knows what year it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two that found nothing
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;no-instanceof-array&lt;/code&gt; and &lt;code&gt;prefer-event-target&lt;/code&gt; returned &lt;strong&gt;0 findings across all 389 files&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A rule that never fires is not broken — it is a rule for a pattern you do not have. &lt;code&gt;instanceof Array&lt;/code&gt; breaks across realms; if you have never hit it, silence is correct. The failure mode to fear is the opposite: I have a rule elsewhere that flags every &lt;code&gt;.map()&lt;/code&gt; in JSX, 476 findings of noise. &lt;strong&gt;Yield tells you nothing about quality on its own&lt;/strong&gt; — the same trap as &lt;a href="https://ofriperetz.dev/go/precision-recall-f1-for-static-analysis?utm_source=devto&amp;amp;from=modernization-lint-as-codemod" rel="noopener noreferrer"&gt;counting rules instead of measuring them&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lint as codemod, not as style
&lt;/h2&gt;

&lt;p&gt;Most lint rules ask you to &lt;em&gt;decide&lt;/em&gt; something. These ask you to &lt;em&gt;apply&lt;/em&gt; something — the semantics are identical and the fixer is exact.&lt;/p&gt;

&lt;p&gt;You do not triage 55 findings — you run the fixer once, read the diff as one commit, and the rule holds the line. A migration plus a ratchet, the same shape as &lt;a href="https://ofriperetz.dev/go/hardcoded-secrets-ai-agents-autofix?utm_source=devto&amp;amp;from=modernization-lint-as-codemod" rel="noopener noreferrer"&gt;an autofix turning a hardcoded secret into a one-command repair&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Check the diff, though — "auto-fixable" means exact, not invisible, and there are two catches. &lt;code&gt;.at()&lt;/code&gt; is typed &lt;code&gt;at(index: number): T | undefined&lt;/code&gt;, while &lt;code&gt;arr[arr.length - 2]&lt;/code&gt; is typed &lt;code&gt;T&lt;/code&gt;, so a chained access can stop compiling: my &lt;code&gt;accrual.at(-2).articles&lt;/code&gt; above is &lt;code&gt;TS2532: Object is possibly 'undefined'&lt;/code&gt; under &lt;code&gt;--strict&lt;/code&gt;. The &lt;code&gt;?? null&lt;/code&gt; on the first example absorbs it; a bare chain needs a guard. And &lt;code&gt;.at()&lt;/code&gt; is ES2022 — Node 18+ and any 2023+ browser are fine, older targets need a polyfill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The config
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// eslint.config.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;modernization&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eslint-plugin-modernization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;**/*.ts&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;**/*.tsx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;modernization&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;modernization/prefer-at&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;error&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;modernization/prefer-template-literal&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;error&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;modernization/no-instanceof-array&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;error&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;modernization/prefer-event-target&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;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="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 shell"&gt;&lt;code&gt;npm  &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; eslint-plugin-modernization   &lt;span class="c"&gt;# npm&lt;/span&gt;
yarn add     &lt;span class="nt"&gt;--dev&lt;/span&gt;      eslint-plugin-modernization   &lt;span class="c"&gt;# yarn&lt;/span&gt;
pnpm add     &lt;span class="nt"&gt;--save-dev&lt;/span&gt; eslint-plugin-modernization   &lt;span class="c"&gt;# pnpm&lt;/span&gt;
bun  add     &lt;span class="nt"&gt;--dev&lt;/span&gt;      eslint-plugin-modernization   &lt;span class="c"&gt;# bun&lt;/span&gt;

npx eslint &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--fix&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Measured against &lt;strong&gt;3.1.2&lt;/strong&gt;, peer range &lt;code&gt;^8.40.0 || ^9.0.0 || ^10.0.0&lt;/code&gt;, Node 18+. &lt;code&gt;prefer-template-literal&lt;/code&gt; does not exist before 3.x, so an older install rejects the config. ESLint only.&lt;/p&gt;

&lt;p&gt;All four at &lt;code&gt;error&lt;/code&gt; is safe &lt;em&gt;for these findings&lt;/em&gt;, but be precise why: in 3.1.2 only &lt;code&gt;prefer-at&lt;/code&gt; and &lt;code&gt;prefer-template-literal&lt;/code&gt; carry a fixer. All 55 findings came from those two, which is why &lt;code&gt;--fix&lt;/code&gt; emptied the list — not because the plugin is 100% auto-fixable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Object.entries(require('eslint-plugin-modernization').rules)
  .map(([k,r]) =&amp;gt; k + ': ' + (r.meta.fixable || 'no fixer')).join('&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;')"&lt;/span&gt;
&lt;span class="c"&gt;# no-instanceof-array: no fixer&lt;/span&gt;
&lt;span class="c"&gt;# prefer-at: code&lt;/span&gt;
&lt;span class="c"&gt;# prefer-event-target: no fixer&lt;/span&gt;
&lt;span class="c"&gt;# prefer-template-literal: code&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it against the published package: a monorepo symlink resolves to unreleased source and answers a different question. &lt;code&gt;prefer-event-target&lt;/code&gt; gains a fixer after 3.1.2.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://ofriperetz.dev/go/r/a10uutyu14?utm_source=devto&amp;amp;from=modernization-lint-as-codemod" rel="noopener noreferrer"&gt;Rule docs&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/npm/eslint-plugin-modernization?utm_source=devto&amp;amp;from=modernization-lint-as-codemod" rel="noopener noreferrer"&gt;npm&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;Numbers measured 2026-08-12 against four repos I own — my code, not a public corpus, so treat 55 as a shape.&lt;/p&gt;

&lt;p&gt;Re-run 2026-09-04 over the 189 &lt;code&gt;.ts&lt;/code&gt;/&lt;code&gt;.tsx&lt;/code&gt; files of this blog's public &lt;code&gt;apps/blog/src&lt;/code&gt;, plugin at 3.1.2: &lt;strong&gt;8 findings in 6 files&lt;/strong&gt; — six &lt;code&gt;prefer-at&lt;/code&gt;, two &lt;code&gt;prefer-template-literal&lt;/code&gt;. Three weeks after the codemod, the old idiom had crept back eight times. That is the argument for leaving the rules at &lt;code&gt;error&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two guards on it. The harness reports &lt;code&gt;unmatched: 0&lt;/code&gt;, so all 189 files were configured — an earlier run returned a confident &lt;strong&gt;0&lt;/strong&gt; that was 189 files matching no config. And on that tree the same day, the noisy rule above returns &lt;strong&gt;110&lt;/strong&gt;: yield is not quality.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's the oldest idiom still alive in your codebase — and is it there because it's correct, or because nothing ever flagged it?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>eslint</category>
    </item>
    <item>
      <title>My Benchmark Judged Five Models Against a Threshold Built for Three</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Thu, 17 Sep 2026 08:25:12 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/my-benchmark-judged-five-models-against-a-threshold-built-for-three-a0l</link>
      <guid>https://dev.to/ofri-peretz/my-benchmark-judged-five-models-against-a-threshold-built-for-three-a0l</guid>
      <description>&lt;p&gt;I went looking for a missing Bonferroni correction in my own benchmark. I found something worse on the way: the one significance test I &lt;em&gt;did&lt;/em&gt; have was a dictionary.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;criticalValues&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="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;3.841&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;5.991&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;7.815&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;significant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chiSq&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;criticalValues&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;df&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mf"&gt;5.991&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three degrees of freedom in the table. Anything else falls through the &lt;code&gt;||&lt;/code&gt; to 5.991 — the df=2 threshold. &lt;code&gt;df&lt;/code&gt; here is &lt;code&gt;models.length - 1&lt;/code&gt;, so the moment I compared five models, every verdict was measured against a bar meant for three.&lt;/p&gt;

&lt;p&gt;Both predicates, on six statistics inside that gap:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;χ²&lt;/th&gt;
&lt;th&gt;df&lt;/th&gt;
&lt;th&gt;old verdict&lt;/th&gt;
&lt;th&gt;true p&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;7.0&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;significant&lt;/td&gt;
&lt;td&gt;0.1359&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8.0&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;significant&lt;/td&gt;
&lt;td&gt;0.0916&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9.0&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;significant&lt;/td&gt;
&lt;td&gt;0.0611&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7.0&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;significant&lt;/td&gt;
&lt;td&gt;0.2206&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10.0&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;significant&lt;/td&gt;
&lt;td&gt;0.0752&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12.0&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;significant&lt;/td&gt;
&lt;td&gt;0.0620&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Six for six — guaranteed: I chose points inside the gap. A probe shows the mechanism, not how often it fired. One of them has a true tail probability of &lt;strong&gt;0.22&lt;/strong&gt; and the old predicate called it &lt;code&gt;p &amp;lt; 0.05&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  It fired once, and got the right answer
&lt;/h2&gt;

&lt;p&gt;I went through every stored result. Eleven run files, three&lt;br&gt;
of them carrying a chi-squared verdict, and exactly one lands in the gap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"chiSquared"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;18.43&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"df"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pValue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt; 0.05"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"significant"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Judged against 5.991 instead of the 9.488 its four degrees of freedom&lt;br&gt;
required. And it was &lt;strong&gt;right anyway&lt;/strong&gt; — the true p is 0.001017, comfortably&lt;br&gt;
significant on any threshold.&lt;/p&gt;

&lt;p&gt;That is the whole problem in one row. The instrument was broken, the answer was&lt;br&gt;
correct, and the output could not tell you which. A wrong method that returns&lt;br&gt;
the right answer is not a near miss; it is a bug with no symptom — the kind&lt;br&gt;
that stays.&lt;/p&gt;
&lt;h2&gt;
  
  
  The error had a direction
&lt;/h2&gt;

&lt;p&gt;The table cannot make a real result disappear. The fallback is &lt;em&gt;lower&lt;/em&gt; than every threshold it stands in for, so the mistake only ever converts noise into a finding. It never withholds one.&lt;/p&gt;

&lt;p&gt;That asymmetry is the tell. A bug that fails randomly is a bug. A bug that fails exclusively in the direction that flatters your results is a bug you were never going to notice: every time it fired it told you what you hoped to hear.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why it survived review
&lt;/h2&gt;

&lt;p&gt;Because it looks like rigor. A table of critical values is textbook furniture, and the numbers are right — 3.841, 5.991 and 7.815 are correct for df 1, 2 and 3. Nothing in the diff is &lt;em&gt;wrong&lt;/em&gt;. The defect is the &lt;code&gt;|| 5.991&lt;/code&gt; — eight characters that turn "I don't know" into a confident answer.&lt;/p&gt;

&lt;p&gt;It reads as harmless. I found the same function in a second runner where someone had already met this problem and fixed it — two more rows, &lt;code&gt;4: 9.488, 5: 11.07&lt;/code&gt;, behind the same &lt;code&gt;|| 5.991&lt;/code&gt;. The cliff moved from df≥4 to df≥6 and got harder to see, because the table looked more complete. Extending a lookup table is not fixing a lookup table.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix is arithmetic, not a bigger dictionary
&lt;/h2&gt;

&lt;p&gt;The tail of a chi-squared distribution is a function. Compute it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;chiSquaredPValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chiSq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;df&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chiSq&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;df&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;upperGamma&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;df&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;chiSq&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// regularized incomplete gamma&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the interface, not the implementation: &lt;code&gt;upperGamma&lt;/code&gt; and a Lanczos log-gamma are not exported, so read it rather than paste it. It lives in &lt;code&gt;benchmarks/lib/stats.ts&lt;/code&gt; on the branch carrying this fix (&lt;a href="https://ofriperetz.dev/go/r/25nh97r3t0r?utm_source=devto&amp;amp;from=significance-from-a-lookup-table" rel="noopener noreferrer"&gt;ofri-peretz/eslint#952&lt;/a&gt;), and &lt;code&gt;npm --prefix benchmarks run stats:check&lt;/code&gt; runs it in a second. It reproduces 3.841, 5.991 and 7.815 to three decimals — and 9.488 and 11.07, which the table never had.&lt;/p&gt;

&lt;p&gt;Then the check, which matters more than the fix. It asserts the &lt;em&gt;rule&lt;/em&gt;, not the absence of one bad number: for a fixed statistic, more degrees of freedom must yield a &lt;strong&gt;larger&lt;/strong&gt; p-value. That is the relationship the fallback inverted, so it fails loudly on the old code and passes on the new. A fix without a check that would have caught it is a fix you get to make twice.&lt;/p&gt;

&lt;p&gt;The uncomfortable part is not that my benchmark had a bug. It is that the bug sat in the code for four months, judging every verdict against a bar meant for three, and the one time it mattered it agreed with me.&lt;/p&gt;




&lt;p&gt;Foundations: &lt;a href="https://ofriperetz.dev/go/statistical-significance-p-value?utm_source=devto&amp;amp;from=significance-from-a-lookup-table" rel="noopener noreferrer"&gt;what a p-value actually claims&lt;/a&gt;, &lt;a href="https://ofriperetz.dev/go/sample-size-and-statistical-power?utm_source=devto&amp;amp;from=significance-from-a-lookup-table" rel="noopener noreferrer"&gt;statistical power&lt;/a&gt;, &lt;a href="https://ofriperetz.dev/go/composite-scores-and-weighting?utm_source=devto&amp;amp;from=significance-from-a-lookup-table" rel="noopener noreferrer"&gt;composite scores&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;More of these — &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=significance-from-a-lookup-table" rel="noopener noreferrer"&gt;follow on dev.to&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If your own harness prints a p-value, did you check it was computed rather than looked up?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>eslint</category>
      <category>javascript</category>
      <category>testing</category>
      <category>benchmarking</category>
    </item>
    <item>
      <title>Your ESLint Plugin Installs 205 Packages. 69 Are ESLint.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sun, 13 Sep 2026 08:48:55 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/your-eslint-plugin-installs-205-packages-69-are-eslint-g1a</link>
      <guid>https://dev.to/ofri-peretz/your-eslint-plugin-installs-205-packages-69-are-eslint-g1a</guid>
      <description>&lt;p&gt;&lt;code&gt;npm i -D eslint-plugin-import&lt;/code&gt; installs &lt;strong&gt;205 packages&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That number is true and almost useless, which is the problem with every dependency-weight argument I have read. &lt;strong&gt;69 of those packages are ESLint itself.&lt;/strong&gt; Since npm 7, peer dependencies install automatically, so any plugin declaring &lt;code&gt;eslint&lt;/code&gt; as a peer drags the entire linter into the count — and every plugin should declare it that way.&lt;/p&gt;

&lt;p&gt;So I measured the baseline first, then subtracted it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What each plugin actually adds
&lt;/h2&gt;

&lt;p&gt;Resolved with &lt;code&gt;npm install --package-lock-only&lt;/code&gt; into an empty project, one plugin at a time, on 2026-08-12. Bare &lt;code&gt;eslint&lt;/code&gt; alone resolves to &lt;strong&gt;69 packages&lt;/strong&gt; — that is the zero point.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;plugin&lt;/th&gt;
&lt;th&gt;tree&lt;/th&gt;
&lt;th&gt;adds&lt;/th&gt;
&lt;th&gt;direct deps&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-import&lt;/td&gt;
&lt;td&gt;205&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;136&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-react&lt;/td&gt;
&lt;td&gt;204&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;135&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-jsx-a11y&lt;/td&gt;
&lt;td&gt;194&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;125&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-unicorn&lt;/td&gt;
&lt;td&gt;110&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-sonarjs&lt;/td&gt;
&lt;td&gt;83&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-n&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-promise&lt;/td&gt;
&lt;td&gt;70&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now look at the two columns on the right together, because they do not agree.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;eslint-plugin-promise&lt;/code&gt; declares &lt;strong&gt;one&lt;/strong&gt; direct dependency and adds &lt;strong&gt;one&lt;/strong&gt; package. &lt;code&gt;eslint-plugin-unicorn&lt;/code&gt; declares &lt;strong&gt;twenty&lt;/strong&gt; and adds 41. &lt;code&gt;eslint-plugin-sonarjs&lt;/code&gt; declares 13 and adds 14. But &lt;code&gt;eslint-plugin-import&lt;/code&gt; declares 19 and adds &lt;strong&gt;136&lt;/strong&gt; — seven per declared dependency, because its dependencies have dependencies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Direct dependency count does not predict install weight.&lt;/strong&gt; It is the number people quote, including me before I measured it, because it is the one visible on the npm page without resolving anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number you actually install
&lt;/h2&gt;

&lt;p&gt;Nobody installs one plugin. A conventional React setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i &lt;span class="nt"&gt;-D&lt;/span&gt; eslint eslint-plugin-react eslint-plugin-jsx-a11y eslint-plugin-import
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;228 packages.&lt;/strong&gt; Not 69 + 135 + 125 + 136 — the trees overlap heavily and npm dedupes them, so the sum is far less than the parts. That is the other half of why per-plugin numbers mislead: they are neither additive nor independent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where mine land
&lt;/h2&gt;

&lt;p&gt;I maintain a plugin suite, so here is my own row, measured identically:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;plugin&lt;/th&gt;
&lt;th&gt;tree&lt;/th&gt;
&lt;th&gt;adds&lt;/th&gt;
&lt;th&gt;direct deps&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-react-features&lt;/td&gt;
&lt;td&gt;71&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-modernization&lt;/td&gt;
&lt;td&gt;71&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-browser-security&lt;/td&gt;
&lt;td&gt;71&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-import-next&lt;/td&gt;
&lt;td&gt;97&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two packages: one shared internal toolkit, which itself has zero dependencies, plus the plugin. &lt;code&gt;import-next&lt;/code&gt; adds 28 because it carries a real resolver — &lt;a href="https://ofriperetz.dev/go/eslint-plugin-import-vs-eslint-plugin-import-next-up-to-100x-faster?utm_source=devto&amp;amp;from=eslint-plugin-dependency-weight" rel="noopener noreferrer"&gt;that is the trade it makes for being much faster&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I am not the leanest here and I want to be precise about that: &lt;strong&gt;&lt;code&gt;eslint-plugin-promise&lt;/code&gt; adds one package and I add two.&lt;/strong&gt; If dependency weight is your only axis, promise is the leaner choice.&lt;/p&gt;

&lt;p&gt;One more honest reading of the table. &lt;code&gt;eslint-plugin-security&lt;/code&gt; resolves to &lt;strong&gt;3 packages total&lt;/strong&gt; — the smallest number on this page by a wide margin. It does not declare &lt;code&gt;eslint&lt;/code&gt; as a peer dependency at all, which is why the 69-package baseline never appears, and it is &lt;a href="https://ofriperetz.dev/go/eslint-plugin-security-is-unmaintained-heres-what-nobody-tells-you-96h?utm_source=devto&amp;amp;from=eslint-plugin-dependency-weight" rel="noopener noreferrer"&gt;the plugin I have argued elsewhere is unmaintained&lt;/a&gt;. A small tree is not a proxy for a healthy package. It can just mean the package stopped changing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check yours
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; /tmp/w &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /tmp/w &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm init &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
npm i &lt;span class="nt"&gt;--package-lock-only&lt;/span&gt; &amp;lt;plugin&amp;gt;
node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"console.log(Object.keys(require('./package-lock.json').packages).filter(k=&amp;gt;k.startsWith('node_modules/')).length)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--package-lock-only&lt;/code&gt; resolves the graph without downloading anything, so it runs in about a second. Measure bare &lt;code&gt;eslint&lt;/code&gt; first, subtract, and compare like with like. Then measure the union you actually install, because that is the only number that reaches your &lt;code&gt;node_modules&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;Every figure above was resolved on 2026-08-12 and will drift as these packages release. Re-run the four lines above rather than citing mine.&lt;/p&gt;

&lt;p&gt;Re-run on 2026-09-04, all fourteen numbers on this page came back &lt;strong&gt;identical&lt;/strong&gt; — the 69-package baseline, every plugin tree, and the 228-package combined install. Three weeks of ecosystem churn moved none of them, which is itself the finding: these trees are stable because the packages at the top of the table are not releasing.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Run it on your config — what's the gap between the packages you asked for and the packages you got?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;[📦 &lt;code&gt;npm i -D eslint-plugin-react-features&lt;/code&gt; — two packages, and you can check that number with the four lines above before you install it. If it earns its place in your CI, ⭐ &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint?utm_source=devto&amp;amp;from=eslint-plugin-dependency-weight" rel="noopener noreferrer"&gt;star the repo&lt;/a&gt;.](&lt;a href="https://ofriperetz.dev/go/npm/eslint-plugin-react-features?utm_source=devto&amp;amp;from=eslint-plugin-dependency-weight" rel="noopener noreferrer"&gt;https://ofriperetz.dev/go/npm/eslint-plugin-react-features?utm_source=devto&amp;amp;from=eslint-plugin-dependency-weight&lt;/a&gt;)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;a href="https://ofriperetz.dev/go/npm/eslint-plugin-react-features?utm_source=devto&amp;amp;from=eslint-plugin-dependency-weight" rel="noopener noreferrer"&gt;eslint-plugin-react-features&lt;/a&gt; is part of the &lt;a href="https://ofriperetz.dev/go/r/8v7joo8tnv?utm_source=devto&amp;amp;from=eslint-plugin-dependency-weight" rel="noopener noreferrer"&gt;Interlace ESLint ecosystem&lt;/a&gt; — every tree in the table above is measurable the same way. Source on &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint?utm_source=devto&amp;amp;from=eslint-plugin-dependency-weight" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · Follow: &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=eslint-plugin-dependency-weight" rel="noopener noreferrer"&gt;Dev.to/ofri-peretz&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>eslint</category>
      <category>node</category>
    </item>
    <item>
      <title>The 3 Most-Installed ESLint Plugins Went Quiet in 2025</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sat, 12 Sep 2026 08:31:59 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/the-3-most-installed-eslint-plugins-went-quiet-in-2025-23ni</link>
      <guid>https://dev.to/ofri-peretz/the-3-most-installed-eslint-plugins-went-quiet-in-2025-23ni</guid>
      <description>&lt;p&gt;Pick the maintenance signal you can get in one second: &lt;strong&gt;days since the last release.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I pulled it from the npm registry for 18 ESLint plugins on 2026-08-12. The three with the deepest install base are the three that have gone quietest.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;plugin&lt;/th&gt;
&lt;th&gt;days since release&lt;/th&gt;
&lt;th&gt;releases in 12mo&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-jsx-a11y&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;655&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-react&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;495&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-import&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;417&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-promise&lt;/td&gt;
&lt;td&gt;107&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-security&lt;/td&gt;
&lt;td&gt;61&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-sonarjs&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-unicorn&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-n&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-jest&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nine of the eighteen I measured shipped nothing at all in the last twelve months. Nearly two years of silence on the plugin that half the accessibility tooling in React depends on.&lt;/p&gt;




&lt;h2&gt;
  
  
  Dormant is not dead
&lt;/h2&gt;

&lt;p&gt;Before drawing the obvious conclusion, the honest caveat: &lt;strong&gt;a plugin that stops releasing may simply be finished.&lt;/strong&gt; &lt;code&gt;jsx-a11y&lt;/code&gt; encodes WAI-ARIA rules. ARIA does not change every quarter. A stable spec produces a stable plugin, and "no releases" is the correct output for a package with nothing left to do.&lt;/p&gt;

&lt;p&gt;So days-since-release is a &lt;em&gt;prompt&lt;/em&gt;, not a verdict. Three signals actually separate finished from abandoned, and all three are free:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The npm deprecation flag.&lt;/strong&gt; &lt;code&gt;eslint-plugin-standard&lt;/code&gt; carries one. That is the maintainer telling you directly, and it is machine-readable — no interpretation required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Supersession.&lt;/strong&gt; &lt;code&gt;eslint-plugin-node&lt;/code&gt; last shipped 2,328 days ago — over six years — because it was replaced by &lt;code&gt;eslint-plugin-n&lt;/code&gt;, which shipped 3 days ago. One of those numbers is alarming and the other explains it. A rename is invisible to anyone reading only the old package's page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Whether it still works on your ESLint.&lt;/strong&gt; This is the one that matters and the one nobody checks until it breaks. A plugin frozen before flat config became the default is not "stable," it is on a countdown.&lt;/p&gt;

&lt;h2&gt;
  
  
  The genuinely dead tier
&lt;/h2&gt;

&lt;p&gt;Sorted by silence, these are the ones where no reading rescues the number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;eslint-plugin-scanjs-rules      3,296 days   (9 years)
eslint-plugin-node              2,328 days   superseded by eslint-plugin-n
eslint-plugin-standard          2,088 days   npm-deprecated
eslint-plugin-flowtype          1,748 days   Flow itself receded
eslint-plugin-xss               1,507 days
eslint-plugin-security-node       951 days
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of these still installs cleanly today. npm will not warn you, your lockfile will not warn you, and a config that references them keeps passing — because a plugin whose rules never fire looks exactly like a codebase with no problems. That is the same failure shape as &lt;a href="https://ofriperetz.dev/go/proxy-metrics?utm_source=devto&amp;amp;from=eslint-plugin-maintenance-signals" rel="noopener noreferrer"&gt;a CI check that was disabled rather than failing&lt;/a&gt;: silence reads as success.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that came back
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;eslint-plugin-security&lt;/code&gt; is the interesting row. It went &lt;strong&gt;3.0.1 in June 2024 → nothing for 20 months → 4.0.0 in February 2026&lt;/strong&gt;, and shipped again in June. If you had measured it in January you would have called it abandoned, correctly, and been wrong by March.&lt;/p&gt;

&lt;p&gt;That is why &lt;a href="https://ofriperetz.dev/go/claims-registry-evidence-framework?utm_source=devto&amp;amp;from=eslint-plugin-maintenance-signals" rel="noopener noreferrer"&gt;a claim needs an expiry date rather than a verdict&lt;/a&gt;. "Unmaintained" is not a property of a package. It is a measurement with a date on it, and this one moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure your own config
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do not use &lt;code&gt;time.modified&lt;/code&gt; for this.&lt;/strong&gt; It is the obvious field and it is the wrong one: npm bumps it on any metadata change, so deprecating a package or transferring ownership makes a dead package look freshly touched. &lt;code&gt;eslint-plugin-flowtype&lt;/code&gt; reports &lt;code&gt;time.modified&lt;/code&gt; of 2026-01-24 — seven months ago, apparently maintained. Its last actual release was 2021-10-29. That is the 1,748-day row above, and the gap between the two fields is 1,548 days of false reassurance.&lt;/p&gt;

&lt;p&gt;Read the publish date of the current version instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm view &amp;lt;plugin&amp;gt; version                  &lt;span class="c"&gt;# e.g. 8.0.3&lt;/span&gt;
npm view &amp;lt;plugin&amp;gt; time.8.0.3               &lt;span class="c"&gt;# the date that version shipped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or resolve the whole set at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"for (const p of Object.keys(require('./package.json').devDependencies ?? {})) &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  fetch('https://registry.npmjs.org/'+p).then(r=&amp;gt;r.json()) &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
    .then(j =&amp;gt; console.log(p, j.time[j['dist-tags'].latest].slice(0,10)))"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then apply the three checks above to anything past a year. Most will be fine. The point is knowing which ones you are betting on — &lt;a href="https://ofriperetz.dev/go/eslint-plugin-import-38m-downloads-heres-what-it-still-gets-wrong?utm_source=devto&amp;amp;from=eslint-plugin-maintenance-signals" rel="noopener noreferrer"&gt;&lt;code&gt;eslint-plugin-import&lt;/code&gt; at 417 days is a dependency worth having an opinion about&lt;/a&gt;, given what it does on every lint run.&lt;/p&gt;




&lt;p&gt;All figures resolved from the npm registry on 2026-08-12 and already drifting — &lt;code&gt;eslint-plugin-jest&lt;/code&gt; was at 0 days when I measured it. Re-run the one-liner rather than citing mine.&lt;/p&gt;

&lt;p&gt;Source at &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint?utm_source=devto&amp;amp;from=eslint-plugin-maintenance-signals" rel="noopener noreferrer"&gt;github.com/ofri-peretz/eslint&lt;/a&gt; · packages at &lt;a href="https://ofriperetz.dev/go/r/29m8v07vw3v?utm_source=devto&amp;amp;from=eslint-plugin-maintenance-signals" rel="noopener noreferrer"&gt;npmjs.com/~ofriperetz&lt;/a&gt; · more at &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=eslint-plugin-maintenance-signals" rel="noopener noreferrer"&gt;dev.to/ofri-peretz&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's the oldest plugin in your lockfile — and did you know it before you looked?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>eslint</category>
      <category>opensource</category>
    </item>
    <item>
      <title>ESLint Ships in 459 KB, So My Blog Posts Lint Your Code</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Wed, 02 Sep 2026 13:07:33 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/i-shipped-eslint-to-the-browser-in-362-kb-now-my-blog-posts-lint-your-code-not-mine-3a4m</link>
      <guid>https://dev.to/ofri-peretz/i-shipped-eslint-to-the-browser-in-362-kb-now-my-blog-posts-lint-your-code-not-mine-3a4m</guid>
      <description>&lt;p&gt;Every article about a lint rule has the same hole in it.&lt;/p&gt;

&lt;p&gt;I paste a snippet, I paste the finding it produces. You think: &lt;em&gt;fine, but does it fire on &lt;strong&gt;my&lt;/strong&gt; code?&lt;/em&gt; The article cannot answer — it can only ever show you someone else's code.&lt;/p&gt;

&lt;p&gt;So I shipped the linter instead. On &lt;a href="https://ofriperetz.dev/go/the-jwt-algorithm-none-attack-the-vulnerability-in-1-line-of-code-d9g?utm_source=devto&amp;amp;from=eslint-in-the-browser-live-lint-playground" rel="noopener noreferrer"&gt;the JWT article&lt;/a&gt; there is now a &lt;em&gt;Try it live&lt;/em&gt; button: paste your own &lt;code&gt;jwt.verify&lt;/code&gt; call and the published rule runs on it, in your browser.&lt;/p&gt;

&lt;p&gt;The reason is a number I did not expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  ESLint ships in 459 KB
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;eslint/universal&lt;/code&gt; exports the &lt;code&gt;Linter&lt;/code&gt; class with no Node dependencies in its public surface: you hand it source text and a flat config, it hands you messages back.&lt;/p&gt;

&lt;p&gt;It is a hard floor, not a polyfill: the &lt;code&gt;./universal&lt;/code&gt; export first appears in &lt;strong&gt;ESLint 9.11.0&lt;/strong&gt;. ESLint 9.10 and every ESLint 8 resolve it to nothing, so this recipe does not degrade on older majors — it fails at build time.&lt;/p&gt;

&lt;p&gt;With &lt;strong&gt;two real security plugins bundled inside it&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;bytes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;raw bundle&lt;/td&gt;
&lt;td&gt;1,764,382&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;brotli -q 11&lt;/code&gt; locally&lt;/td&gt;
&lt;td&gt;370,746&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;what the CDN actually sends&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;470,563&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Quote the last row, not the second. I first published 362 KB, having confirmed &lt;code&gt;content-encoding: br&lt;/code&gt; — which proves the encoding, not the size. The CDN compresses on the fly below &lt;code&gt;-q 11&lt;/code&gt;, so the honest number is &lt;strong&gt;459 KB&lt;/strong&gt;. Same artifact byte for byte; only the compressor differs.&lt;/p&gt;

&lt;p&gt;Still the whole argument. At several megabytes you write a blog post about the rule. At 459 KB you ship the rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The recipe
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The worker&lt;/strong&gt; holds the linter and never talks to a server:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Linter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eslint/universal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eslint-plugin-jwt&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;PLUGINS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// enumerated, never dynamic&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;linter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Linter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pluginId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rules&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;findings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;linter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pluginId&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="nx"&gt;PLUGINS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pluginId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;languageOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ecmaVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sourceType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;module&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nx"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;findings&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// no network, ever&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The build step&lt;/strong&gt; is where the real work is — aliases and a banner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;buildSync&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;entryPoints&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;src/workers/lint.worker.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;outfile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public/lint-worker.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;bundle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;minify&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;iife&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;browser&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// A worker global has no `process`. ESLint touches more of it than&lt;/span&gt;
  &lt;span class="c1"&gt;// NODE_ENV — cwd, platform, emitWarning — so stub it in a banner.&lt;/span&gt;
  &lt;span class="na"&gt;banner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;js&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;var process={env:{NODE_ENV:"production"},&lt;/span&gt;&lt;span class="dl"&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;platform:"browser",cwd:function(){return "/"},argv:[]};&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;path-browserify&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;node:path&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;path-browserify&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;shims&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;os&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;shims&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;util&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;shims&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// If anything in your graph pulls oxc-resolver, its native&lt;/span&gt;
    &lt;span class="c1"&gt;// bindings ride in and break the build. Rules never touch it.&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;oxc-resolver&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;shims&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;shims&lt;/code&gt; is a no-op &lt;code&gt;Proxy&lt;/code&gt;, imported but never exercised.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The client seam&lt;/strong&gt; is a lazy worker. The part worth copying is the failure path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onerror&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;terminate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ← this line&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;worker failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nulling the singleton separates a playground that self-heals from one dead until reload. Without it every later lint posts into a corpse and the UI shows a clean result forever: "no findings" and "the analyzer is dead" look identical. So the surface renders &lt;strong&gt;"unknown, not clean"&lt;/strong&gt; on failure, never an empty list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three traps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Don't let your framework bundle it.&lt;/strong&gt; Asking Next.js to build the worker means teaching both webpack and Turbopack about &lt;code&gt;node:&lt;/code&gt; schemes: two fragile configs for one artifact. Use esbuild yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A real worker has no &lt;code&gt;process&lt;/code&gt;.&lt;/strong&gt; My spike ran under Node so &lt;code&gt;ReferenceError: process is not defined&lt;/code&gt; only appeared in the browser. A spike passing is not the thing working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Linting the artifact will OOM your editor.&lt;/strong&gt; ESLint tried to lint the 1.7 MB bundle and died with &lt;code&gt;Abort trap: 6&lt;/code&gt;: heap exhaustion, not a native crash. Add &lt;code&gt;public/**&lt;/code&gt; to &lt;code&gt;globalIgnores&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Both run published npm packages, the tarballs &lt;code&gt;npm install&lt;/code&gt; gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://ofriperetz.dev/go/the-jwt-algorithm-none-attack-the-vulnerability-in-1-line-of-code-d9g?utm_source=devto&amp;amp;from=eslint-in-the-browser-live-lint-playground" rel="noopener noreferrer"&gt;JWT &lt;code&gt;alg:none&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; — remove &lt;code&gt;"none"&lt;/code&gt; from the algorithms array, watch the finding clear.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://ofriperetz.dev/go/getting-started-eslint-plugin-node-security?utm_source=devto&amp;amp;from=eslint-in-the-browser-live-lint-playground" rel="noopener noreferrer"&gt;node-security&lt;/a&gt;&lt;/strong&gt; — three rules on an upload handler.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing you type leaves the page — and the UI says so in as many words, because pasting auth code into a stranger's site deserves an explicit answer rather than an assumption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;Measured &lt;strong&gt;2026-09-03&lt;/strong&gt; against eslint 9.39.4, eslint-plugin-jwt 2.2.14, eslint-plugin-node-security 5.2.3, esbuild 0.28.2, on Node 24.18. The build step itself inherits ESLint's floor, &lt;code&gt;^18.18.0 || ^20.9.0 || &amp;gt;=21.1.0&lt;/code&gt; — there is no Oxlint variant of this recipe, because &lt;code&gt;eslint/universal&lt;/code&gt; is ESLint's own export.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# from the repo root&lt;/span&gt;
node apps/blog/scripts/build-lint-worker.mjs
&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &amp;lt; apps/blog/public/lint-worker.js                  &lt;span class="c"&gt;# 1764382&lt;/span&gt;
brotli &lt;span class="nt"&gt;-q&lt;/span&gt; 11 &lt;span class="nt"&gt;-c&lt;/span&gt; apps/blog/public/lint-worker.js | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;  &lt;span class="c"&gt;# 370746&lt;/span&gt;
&lt;span class="c"&gt;# what a reader actually downloads — measure this one, not the line above&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Accept-Encoding: br'&lt;/span&gt; https://ofriperetz.dev/lint-worker.js | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;   &lt;span class="c"&gt;# 470563&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those bytes drift as plugins ship rules; quote them with a date.&lt;/p&gt;

&lt;p&gt;Browser-hosted linting is not new; &lt;a href="https://ofriperetz.dev/go/r/1cdhf7jnpf4?utm_source=devto&amp;amp;from=eslint-in-the-browser-live-lint-playground" rel="noopener noreferrer"&gt;ESLint&lt;/a&gt; and &lt;a href="https://ofriperetz.dev/go/r/mo10nxkr1q?utm_source=devto&amp;amp;from=eslint-in-the-browser-live-lint-playground" rel="noopener noreferrer"&gt;typescript-eslint&lt;/a&gt; both run excellent playgrounds. What is unusual is placement: not a destination you navigate to, but the rule the paragraph is arguing, running on your code, at the moment you wonder about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint?utm_source=devto&amp;amp;from=eslint-in-the-browser-live-lint-playground" rel="noopener noreferrer"&gt;⭐ Star the repo if you have ever wanted docs that run instead of assert.&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>eslint</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I Ran My Plugins Against a Competitor's Own Test Suite</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Tue, 01 Sep 2026 06:07:09 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/i-ran-my-plugins-against-a-competitors-own-test-suite-3ad1</link>
      <guid>https://dev.to/ofri-peretz/i-ran-my-plugins-against-a-competitors-own-test-suite-3ad1</guid>
      <description>&lt;p&gt;Every "we replace X" claim in the linting space is unfalsifiable, including the ones I have made. The competitor's rules are a list on a README, so comparison collapses into counting rule names.&lt;/p&gt;

&lt;p&gt;But &lt;a href="https://ofriperetz.dev/go/npm/eslint-plugin-security?utm_source=devto&amp;amp;from=eslint-security-corpus-parity" rel="noopener noreferrer"&gt;&lt;code&gt;eslint-plugin-security&lt;/code&gt;&lt;/a&gt; ships its &lt;strong&gt;test suite&lt;/strong&gt; inside the npm tarball. That is &lt;a href="https://ofriperetz.dev/go/how-to-design-a-ground-truth-corpus?utm_source=devto&amp;amp;from=eslint-security-corpus-parity" rel="noopener noreferrer"&gt;a corpus&lt;/a&gt;: 84 code samples its authors wrote specifically to be caught — and a corpus someone else wrote is the only kind that can embarrass you, which is &lt;a href="https://ofriperetz.dev/go/i-built-what-i-benchmark-heres-how-i-try-not-to-cheat?utm_source=devto&amp;amp;from=eslint-security-corpus-parity" rel="noopener noreferrer"&gt;the whole problem with benchmarking your own tool&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So I stopped counting rules and ran their tests against my plugins.&lt;/p&gt;




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

&lt;p&gt;Their tests use ESLint's &lt;code&gt;RuleTester&lt;/code&gt;. Rather than parse samples out of the source, intercept the runner and collect them:&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;RuleTester&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eslint&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;captured&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="nx"&gt;RuleTester&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&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;tests&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;tests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invalid&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="nx"&gt;captured&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node_modules/eslint-plugin-security/test/rules&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`.../test/rules/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;84 samples. Then lint each one twice — once with their plugin, once with mine — and count which ones produce at least one finding.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;flags&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-security (on its own tests)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;71 / 84&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;my plugins&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;51 / 84&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;They win, and the honest headline is that I cover 61% of their corpus. But the distribution matters more than the total, because &lt;strong&gt;29 of my 33 misses are a single rule&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 29  detect-buffer-noassert
  1  detect-disable-mustache-escape
  1  detect-no-csrf-before-method-override
  1  detect-pseudoRandomBytes
  1  detect-unsafe-regex
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Excluding that one rule, it is &lt;strong&gt;51 of 55 — 93%&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obsolete rule
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;detect-buffer-noassert&lt;/code&gt; flags &lt;code&gt;buf.readUInt8(0, true)&lt;/code&gt;. The &lt;code&gt;noAssert&lt;/code&gt; argument told Node.js to skip offset validation, so a read could run past the end of the buffer.&lt;/p&gt;

&lt;p&gt;It was removed in Node 10. On Node 24:&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;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;alloc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readUInt8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// extra argument ignored&lt;/span&gt;
&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readUInt8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// still throws ERR_OUT_OF_RANGE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parameter has done nothing for about seven years. Their rule still ships in &lt;code&gt;recommended&lt;/code&gt;, and their suite tests it across 29 variants — every &lt;code&gt;read*&lt;/code&gt; method — which is why one dead rule dominates the gap.&lt;/p&gt;

&lt;p&gt;I am not claiming that is wrong of them. Removing a rule breaks configs, and the cost of keeping it is nearly zero. But it is the difference between "61% coverage" and "93% coverage of everything that can still bite you," and no rule-name comparison would ever surface it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I catch that they miss
&lt;/h2&gt;

&lt;p&gt;The reverse direction is the part I did not expect. Running their corpus through their own plugin leaves 13 samples unflagged, and my rules catch all 13:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  9  detect-non-literal-fs-filename
  4  detect-child-process
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those are their test cases, written for their rules, that their current implementation does not fire on. Combined, the two plugins flag &lt;strong&gt;84 of 84&lt;/strong&gt; — every sample is caught by someone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I lose
&lt;/h2&gt;

&lt;p&gt;Corpus coverage is one criterion and it is the flattering one, so here is the criterion that is not.&lt;/p&gt;

&lt;p&gt;On their curated samples my precision looks excellent, because every sample is a real vulnerability. Pointed at ordinary code, it is a different story: I ran my Node rules over 61 files of my own automation scripts last week and got &lt;strong&gt;279 findings&lt;/strong&gt;, and every one I inspected was a false positive — a timing-attack warning on &lt;code&gt;if (key === -1)&lt;/code&gt;, a zip-slip warning on a string literal. I have open fixes for those. A corpus benchmark cannot see them, because a corpus contains no boring code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coverage and precision are different measurements, and a test suite only measures the first.&lt;/strong&gt; Anyone quoting one number at you is quoting the one that flatters them — that is &lt;a href="https://ofriperetz.dev/go/bias-in-measurement?utm_source=devto&amp;amp;from=eslint-security-corpus-parity" rel="noopener noreferrer"&gt;measurement bias&lt;/a&gt; with a marketing budget. I just did it too: 93% is the number I would put on a slide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;The whole thing is about 40 lines and no AI, no benchmark harness, no service. Any plugin that ships its tests can be measured this way, in both directions, in a few minutes.&lt;/p&gt;

&lt;p&gt;Point it at mine: &lt;a href="https://ofriperetz.dev/go/npm/eslint-plugin-node-security?utm_source=devto&amp;amp;from=eslint-security-corpus-parity" rel="noopener noreferrer"&gt;&lt;code&gt;eslint-plugin-node-security&lt;/code&gt;&lt;/a&gt; ships its tests too, and I would rather read your numbers than my own.&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;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; eslint-plugin-node-security
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;Measured 2026-08-12 against eslint-plugin-security 4.0.1 on ESLint 9.39.2, with my plugins under &lt;code&gt;@typescript-eslint/parser&lt;/code&gt; (they are TypeScript-native; the default parser under-reports them by 13 samples). Related: &lt;a href="https://ofriperetz.dev/go/what-ground-truth-caught-that-unit-tests-missed?utm_source=devto&amp;amp;from=eslint-security-corpus-parity" rel="noopener noreferrer"&gt;what ground truth caught that unit tests missed&lt;/a&gt;, and &lt;a href="https://ofriperetz.dev/go/eslint-plugin-security-is-unmaintained-heres-what-nobody-tells-you-96h?utm_source=devto&amp;amp;from=eslint-security-corpus-parity" rel="noopener noreferrer"&gt;the maintenance question about this same plugin&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Source at &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint?utm_source=devto&amp;amp;from=eslint-security-corpus-parity" rel="noopener noreferrer"&gt;github.com/ofri-peretz/eslint&lt;/a&gt; · packages at &lt;a href="https://ofriperetz.dev/go/r/29m8v07vw3v?utm_source=devto&amp;amp;from=eslint-security-corpus-parity" rel="noopener noreferrer"&gt;npmjs.com/~ofriperetz&lt;/a&gt; · more at &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=eslint-security-corpus-parity" rel="noopener noreferrer"&gt;dev.to/ofri-peretz&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If someone ran your test suite against a competitor, what would it show?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>eslint</category>
    </item>
    <item>
      <title>My 9-Reviewer AI Gate Failed Articles It Scored 9.1</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Wed, 26 Aug 2026 00:01:20 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/my-9-reviewer-ai-gate-failed-articles-it-scored-91-58bk</link>
      <guid>https://dev.to/ofri-peretz/my-9-reviewer-ai-gate-failed-articles-it-scored-91-58bk</guid>
      <description>&lt;p&gt;Nine LLM reviewers score every article I publish. The gate is simple: zero blockers, and a mean score above my floor.&lt;/p&gt;

&lt;p&gt;Yesterday it failed an article it had scored &lt;strong&gt;9.1&lt;/strong&gt;. Then it failed a second one three separate times. Neither reviewer ever disagreed with the writing — all three failures were the harness misreading its own reviewers.&lt;/p&gt;




&lt;h2&gt;
  
  
  A perfect score, filed as a blocker
&lt;/h2&gt;

&lt;p&gt;The Checklist reviewer returned &lt;strong&gt;10.0&lt;/strong&gt; and wrote this under &lt;code&gt;BLOCKERS&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;no biography/family/military/location/heritage; no political position;
the numbers are incident/operational facts, so the rule doesn't bite.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a clean bill of health. It failed the gate.&lt;/p&gt;

&lt;p&gt;The parser dropped lines meaning "nothing here" with &lt;code&gt;/^none/i&lt;/code&gt;. The prompt asks reviewers to &lt;em&gt;explain&lt;/em&gt; why nothing is wrong, so they write the explanation instead of the word — and &lt;strong&gt;"no biography" is not "none"&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it survives review:&lt;/strong&gt; the regex is correct for the string it was written against. Nobody writes a test for the sentence a model &lt;em&gt;didn't&lt;/em&gt; emit.&lt;/p&gt;

&lt;p&gt;The fix wasn't a longer regex. Guessing every phrasing of "nothing is wrong" is unbounded. The prompts already state the contract — fully in scope means &lt;code&gt;BLOCKERS: None&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;SCORE: 10&lt;/code&gt; — so a 10 that also reports a blocker is self-contradictory:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;blockers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;improvements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;blockers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// demote, never drop&lt;/span&gt;
  &lt;span class="nx"&gt;blockers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Demoted, not deleted. If a reviewer ever scores 10 on something genuinely broken, the text still reaches a human.&lt;/p&gt;

&lt;h2&gt;
  
  
  The word that made good reviews look like outages
&lt;/h2&gt;

&lt;p&gt;My runner treats a quota message as a transport failure, so a billing error can never be scored as a bad article. The pattern included a bare &lt;code&gt;rate limit&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It was tested against the reviewer's &lt;strong&gt;entire output&lt;/strong&gt;. My corpus is security articles. Reviewers say "rate limit" constantly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;· Discovery &amp;amp; Hook: USAGE LIMIT — SCORE: 8.0
· Voice &amp;amp; Agenda:   USAGE LIMIT — SCORE: 9.5
· Quality:          USAGE LIMIT — SCORE: 9.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three finished reviews, scores visible in the text that got thrown away. That article lost 4 of 9 reviewers and failed two batch runs before I looked. It reads as an account problem, which is exactly why it survived.&lt;/p&gt;

&lt;p&gt;The tell separates the two cleanly: a real quota banner is the &lt;em&gt;only&lt;/em&gt; thing on stdout. A review that merely discusses limits has a &lt;code&gt;SCORE:&lt;/code&gt; line sitting right there.&lt;/p&gt;

&lt;h2&gt;
  
  
  An excluded reviewer voting anyway
&lt;/h2&gt;

&lt;p&gt;One reviewer is informational — its rubric weights brand fit 40%, which structurally caps anything outside that niche. It was excluded from the gate &lt;em&gt;score&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;It was not excluded from blockers. So it vetoed through the back door — and what it files under &lt;code&gt;BLOCKERS&lt;/code&gt; is its own arithmetic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✗ Axis 1 — Content Quality: 8.5
✗ Weighted = 8.5 x 0.6 + 5 x 0.4 = 7.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four "blockers" on an article it had just called original and reproducible. A half-applied exclusion is not an exclusion — and a &lt;a href="https://ofriperetz.dev/go/composite-scores-and-weighting?utm_source=devto&amp;amp;from=llm-judge-gate-false-negatives" rel="noopener noreferrer"&gt;weighted composite&lt;/a&gt; is exactly the shape that hides one, because the arithmetic looks like a finding.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;Every one of these is the harness misreading agreement as disagreement, and all three fail in the same direction: &lt;strong&gt;quietly, toward rejection.&lt;/strong&gt; A false pass is loud — something bad ships and you see it. A false &lt;em&gt;fail&lt;/em&gt; looks exactly like a strict gate doing its job, so it can run for weeks while you assume the work is merely not good enough yet. It is the same asymmetry that makes &lt;a href="https://ofriperetz.dev/go/we-ranked-5-ai-models-by-security-the-leaderboard-is-wrong?utm_source=devto&amp;amp;from=llm-judge-gate-false-negatives" rel="noopener noreferrer"&gt;a leaderboard wrong in the flattering direction&lt;/a&gt;, and the same reason &lt;a href="https://ofriperetz.dev/go/bias-in-measurement?utm_source=devto&amp;amp;from=llm-judge-gate-false-negatives" rel="noopener noreferrer"&gt;measurement bias&lt;/a&gt; survives longest when it agrees with what you expected.&lt;/p&gt;

&lt;p&gt;If you run an LLM as a judge, assert on the disagreements it cannot logically have: a perfect score with a blocker, a reviewer excluded from the score that still blocks, a transport error carrying a parsed result. Those are contradictions, and contradictions are testable without predicting a single word the model will say.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SCORE: 10&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;BLOCKERS:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;- none found here&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;blockers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SCORE: 7&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;BLOCKERS:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;- the claim has no date&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;blockers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both directions. A one-sided test would have passed on all three bugs — my earlier fix for the &lt;em&gt;opposite&lt;/em&gt; failure is what introduced the second one. That is the same lesson &lt;a href="https://ofriperetz.dev/go/what-ground-truth-caught-that-unit-tests-missed?utm_source=devto&amp;amp;from=llm-judge-gate-false-negatives" rel="noopener noreferrer"&gt;ground truth taught me that unit tests could not&lt;/a&gt;: a test written from the failure you already know about only ever proves you fixed that one.&lt;/p&gt;




&lt;p&gt;More on the tooling behind this at &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint?utm_source=devto&amp;amp;from=llm-judge-gate-false-negatives" rel="noopener noreferrer"&gt;github.com/ofri-peretz/eslint&lt;/a&gt;. The three defects above were found on 2026-08-11 across 39 gated articles.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's the last false negative you found in your own tooling — and how long had it been running?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>testing</category>
      <category>eslint</category>
    </item>
    <item>
      <title>I Linted My Own Design System. All 8 Breaks Were Pasted In.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Thu, 20 Aug 2026 09:23:07 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/i-linted-my-own-design-system-all-8-breaks-were-pasted-in-3hm4</link>
      <guid>https://dev.to/ofri-peretz/i-linted-my-own-design-system-all-8-breaks-were-pasted-in-3hm4</guid>
      <description>&lt;p&gt;Your design system has tokens. Your linter does not know they exist.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;eslint-plugin-react&lt;/code&gt; has 104 rules and not one of them can tell you that &lt;code&gt;#ffffff1f&lt;/code&gt; should have been &lt;code&gt;var(--surface-highlight)&lt;/code&gt;. That gap is invisible in review, because a hex value in a &lt;code&gt;className&lt;/code&gt; looks exactly like every other string.&lt;/p&gt;

&lt;p&gt;So I ran four rules over my own code: 401 &lt;code&gt;.tsx&lt;/code&gt; files across a design system, a docs site, a blog, and a control-room app. &lt;strong&gt;144 violations in 50 files.&lt;/strong&gt; The design system defines &lt;strong&gt;192 tokens&lt;/strong&gt;. It also breaks them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Raw color literals: 30 findings, and they cluster
&lt;/h2&gt;

&lt;p&gt;The rule is &lt;a href="https://ofriperetz.dev/go/r/1hmw4sbvl2w?utm_source=devto&amp;amp;from=design-system-token-drift-eslint" rel="noopener noreferrer"&gt;&lt;code&gt;no-raw-color-literal&lt;/code&gt;&lt;/a&gt; — any hex, &lt;code&gt;rgb()&lt;/code&gt;, or &lt;code&gt;rgba()&lt;/code&gt; in JSX source.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/ui/src/effects/shimmer-button.tsx:102&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rounded-2xl px-4 py-1.5 text-sm font-medium shadow-[inset_0_-8px_10px_#ffffff1f]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;⚠️ Raw color literal in source — use a design token (R19) | MEDIUM
 Fix: Replace with a CSS custom property (var(--your-token)) or a Tailwind
      theme class wired to it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this survives review:&lt;/strong&gt; the hex is inside an arbitrary Tailwind value inside a class string. A reviewer scanning for color changes greps for &lt;code&gt;color&lt;/code&gt; or &lt;code&gt;bg-&lt;/code&gt;. Nobody greps for &lt;code&gt;#&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is the part I did not expect. The design system package had &lt;strong&gt;8&lt;/strong&gt; of these, and all 8 were in &lt;code&gt;ambient/&lt;/code&gt;, &lt;code&gt;effects/&lt;/code&gt;, and &lt;code&gt;patterns/&lt;/code&gt; — components I pasted in from component galleries. The core primitives had &lt;strong&gt;zero&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The system didn't drift. It leaked at the seam where other people's code came in.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;data-slot&lt;/code&gt;: 67 findings, the biggest by far
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;⚠️ JSX element has data-testid but no data-slot (R6) | MEDIUM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://ofriperetz.dev/go/r/1qfwkf9odgs?utm_source=devto&amp;amp;from=design-system-token-drift-eslint" rel="noopener noreferrer"&gt;&lt;code&gt;require-data-slot&lt;/code&gt;&lt;/a&gt; fires when an element carries a &lt;code&gt;data-testid&lt;/code&gt; but no &lt;code&gt;data-slot&lt;/code&gt;. Both are hooks; only one is a contract. A &lt;code&gt;data-testid&lt;/code&gt; says "a test grabs this." A &lt;code&gt;data-slot&lt;/code&gt; says "consumers may style this." Ship the first without the second and every consumer targets your test IDs, and your next refactor breaks their CSS.&lt;/p&gt;

&lt;p&gt;67 of my 144 findings were this one rule. It is the least dramatic and the most expensive — a &lt;code&gt;data-testid&lt;/code&gt; standing in for a styling contract is a &lt;a href="https://ofriperetz.dev/go/proxy-metrics?utm_source=devto&amp;amp;from=design-system-token-drift-eslint" rel="noopener noreferrer"&gt;proxy metric&lt;/a&gt; with a refactor bill attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;A design system fails at its boundaries, not its center. The primitives I wrote while thinking about tokens obey them. The components I imported while thinking about &lt;em&gt;shipping&lt;/em&gt; do not — and they arrive pre-broken, which means no diff ever shows the moment the violation entered.&lt;/p&gt;

&lt;p&gt;That is why this has to be a lint rule and not a review convention. Review catches what a diff shows. Pasted code shows up as one clean green addition.&lt;/p&gt;

&lt;h2&gt;
  
  
  The config
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// eslint.config.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;reactFeatures&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eslint-plugin-react-features&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;**/*.tsx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-features&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;reactFeatures&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-features/no-raw-color-literal&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;error&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;react-features/no-arbitrary-token-class&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;error&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;react-features/require-data-slot&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;warn&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;react-features/no-inline-style&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;warn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="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 shell"&gt;&lt;code&gt;npm i &lt;span class="nt"&gt;-D&lt;/span&gt; eslint-plugin-react-features
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://ofriperetz.dev/go/npm/eslint-plugin-react-features?utm_source=devto&amp;amp;from=design-system-token-drift-eslint" rel="noopener noreferrer"&gt;&lt;code&gt;eslint-plugin-react-features&lt;/code&gt; on npm&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/1h2bvr8vcub?utm_source=devto&amp;amp;from=design-system-token-drift-eslint" rel="noopener noreferrer"&gt;all four rule docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Start the last two at &lt;code&gt;warn&lt;/code&gt;. On a codebase that has never had these rules, &lt;code&gt;require-data-slot&lt;/code&gt; alone will be your largest number, and turning it red on day one just teaches people to disable it.&lt;/p&gt;

&lt;p&gt;An honest caveat: 47 of this plugin's 61 rules overlap &lt;code&gt;eslint-plugin-react&lt;/code&gt; by name. These four do not — nothing in the incumbent enforces a token system, because the incumbent cannot know what your tokens are.&lt;/p&gt;




&lt;p&gt;Source at &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint?utm_source=devto&amp;amp;from=design-system-token-drift-eslint" rel="noopener noreferrer"&gt;github.com/ofri-peretz/eslint&lt;/a&gt;. Every number here came from a run on 2026-08-11 against the four repos named above — four repos I own, which is a sample of one opinion about components, not &lt;a href="https://ofriperetz.dev/go/how-to-design-a-ground-truth-corpus?utm_source=devto&amp;amp;from=design-system-token-drift-eslint" rel="noopener noreferrer"&gt;a ground-truth corpus&lt;/a&gt;. I ran my own rule over my own code and reported what it found, which is &lt;a href="https://ofriperetz.dev/go/i-built-what-i-benchmark-heres-how-i-try-not-to-cheat?utm_source=devto&amp;amp;from=design-system-token-drift-eslint" rel="noopener noreferrer"&gt;the only version of benchmarking I trust myself to do&lt;/a&gt;. Re-run it on yours and you will get different numbers.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Run one of these on your own components — what's your &lt;code&gt;data-slot&lt;/code&gt; number? Mine was 67, and I wrote the rule.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>webdev</category>
      <category>eslint</category>
      <category>css</category>
    </item>
    <item>
      <title>Your Token Is Not Safer in a Cookie. It Is Safer From JavaScript.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Fri, 14 Aug 2026 09:43:04 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/your-token-is-not-safer-in-a-cookie-it-is-safer-from-javascript-5e45</link>
      <guid>https://dev.to/ofri-peretz/your-token-is-not-safer-in-a-cookie-it-is-safer-from-javascript-5e45</guid>
      <description>&lt;p&gt;Every team I have worked on has had the localStorage argument. Someone says tokens belong&lt;br&gt;
in cookies. Someone else says cookies mean CSRF. Both cite a blog post. The token stays&lt;br&gt;
where it was.&lt;/p&gt;

&lt;p&gt;The argument is unwinnable as stated, because both sides are describing the container —&lt;br&gt;
and the container is not what decides the outcome.&lt;/p&gt;




&lt;h2&gt;
  
  
  The axis everyone argues about
&lt;/h2&gt;

&lt;p&gt;Ask why one mechanism is safer and you get answers about persistence, capacity, expiry,&lt;br&gt;
whether it survives a tab close. Real differences. None of them has anything to do with an&lt;br&gt;
attacker.&lt;/p&gt;

&lt;h2&gt;
  
  
  The axis that decides it
&lt;/h2&gt;

&lt;p&gt;One question: &lt;strong&gt;can JavaScript running on your origin read it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If yes, anything achieving script execution on your page reads it too. Not encryption —&lt;br&gt;
you have nowhere to put the key. Not obfuscation — the attacker has your bundle. Not "we&lt;br&gt;
only write it after login" — the attacker runs after login too.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;Readable by page JS&lt;/th&gt;
&lt;th&gt;Under XSS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;localStorage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sessionStorage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IndexedDB&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache API&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cookie without &lt;code&gt;HttpOnly&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cookie with &lt;code&gt;HttpOnly&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;survives&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Five of those six rows are the same row. The debate spends its energy on a distinction&lt;br&gt;
that exists only in the last line — and it is not the cookie that creates it, it is the&lt;br&gt;
flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;HttpOnly&lt;/code&gt; is the boundary
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;HttpOnly&lt;/code&gt; is unusual: a capability the browser withholds from your own code. You cannot&lt;br&gt;
opt back in at runtime. No API reads it, no devtools trick your bundle can perform. The&lt;br&gt;
value goes out on requests to its domain and is otherwise unreachable.&lt;/p&gt;

&lt;p&gt;Almost nothing else in the browser works this way. Everything in the first five rows is a&lt;br&gt;
filing cabinet in a public lobby — useful, convenient, not a vault, and never described as&lt;br&gt;
one by anyone who built it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The corollary people skip
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;HttpOnly&lt;/code&gt; protects the token from being &lt;em&gt;read&lt;/em&gt;. It does not stop it being &lt;em&gt;used&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Script on your origin can still &lt;code&gt;fetch&lt;/code&gt; with &lt;code&gt;credentials: "include"&lt;/code&gt;, and the browser&lt;br&gt;
attaches the cookie. The attacker does not hold your session; they operate it, from your&lt;br&gt;
page, while it is open. Smaller blast radius — no exfiltration, no replay next week — but&lt;br&gt;
not nothing.&lt;/p&gt;

&lt;p&gt;The honest summary: &lt;code&gt;HttpOnly&lt;/code&gt; converts &lt;em&gt;permanent theft&lt;/em&gt; into &lt;em&gt;temporary misuse&lt;/em&gt;. Real&lt;br&gt;
and worthwhile. Not immunity.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what counts as sensitive
&lt;/h2&gt;

&lt;p&gt;Stop asking "is this sensitive" — a word that invites negotiation. Ask: &lt;strong&gt;if an attacker&lt;br&gt;
had this value, what could they do, and for how long?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Session token&lt;/strong&gt; — act as the user until expiry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh token&lt;/strong&gt; — mint new sessions, far longer. Higher risk, and the one most often
parked in &lt;code&gt;localStorage&lt;/code&gt; &lt;em&gt;because&lt;/em&gt; it needs to persist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cached API response with personal data&lt;/strong&gt; — disclosure, not takeover. The Cache API is
the mechanism people forget is storage at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature flag, theme, draft&lt;/strong&gt; — nothing. Put it anywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  And the CSRF half?
&lt;/h2&gt;

&lt;p&gt;Never on the same axis either. CSRF is the browser &lt;em&gt;attaching&lt;/em&gt; a cookie to a request the&lt;br&gt;
user did not intend — a property of ambient authority, not storage — and &lt;code&gt;SameSite&lt;/code&gt;&lt;br&gt;
addresses it directly.&lt;/p&gt;

&lt;p&gt;That is why the debate feels unresolvable: one camp describes a read primitive, the other&lt;br&gt;
a send primitive. &lt;code&gt;HttpOnly&lt;/code&gt; answers the first, &lt;code&gt;SameSite&lt;/code&gt; the second — orthogonal flags on&lt;br&gt;
the same cookie.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this lands in the standards
&lt;/h2&gt;

&lt;p&gt;Reading storage you should not have access to is&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/ga4vp29bce?utm_source=devto&amp;amp;from=client-storage-trust-boundary" rel="noopener noreferrer"&gt;CWE-522&lt;/a&gt; — insufficiently protected&lt;br&gt;
credentials — under&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/1w8dz7dsugr?utm_source=devto&amp;amp;from=client-storage-trust-boundary" rel="noopener noreferrer"&gt;A07:2021&lt;/a&gt;.&lt;br&gt;
&lt;code&gt;HttpOnly&lt;/code&gt; and &lt;code&gt;Secure&lt;/code&gt; are specified in&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/1ddgz712p2y?utm_source=devto&amp;amp;from=client-storage-trust-boundary" rel="noopener noreferrer"&gt;RFC 6265 §4.1.2.5–6&lt;/a&gt;;&lt;br&gt;
&lt;code&gt;SameSite&lt;/code&gt; arrived later, in&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/12q8n02v2xm?utm_source=devto&amp;amp;from=client-storage-trust-boundary" rel="noopener noreferrer"&gt;RFC 6265bis&lt;/a&gt;, still&lt;br&gt;
a draft despite universal implementation. Both are on&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/12gpdyvdiyp?utm_source=devto&amp;amp;from=client-storage-trust-boundary" rel="noopener noreferrer"&gt;MDN's Set-Cookie page&lt;/a&gt;.&lt;br&gt;
For severity language, use&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/cvss-scores-explained?utm_source=devto&amp;amp;from=client-storage-trust-boundary" rel="noopener noreferrer"&gt;CVSS&lt;/a&gt; and&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/owasp-top-10-explained?utm_source=devto&amp;amp;from=client-storage-trust-boundary" rel="noopener noreferrer"&gt;the OWASP categories&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the argument keeps happening
&lt;/h2&gt;

&lt;p&gt;All five equivalent rows are reached the same way: script execution on your origin. So&lt;br&gt;
storage is &lt;em&gt;downstream&lt;/em&gt; of the XSS question, and a team that has not settled how untrusted&lt;br&gt;
data reaches a DOM sink is arguing about where to put the token while leaving the door&lt;br&gt;
open. &lt;a href="https://ofriperetz.dev/go/dom-sink-taxonomy?utm_source=devto&amp;amp;from=client-storage-trust-boundary" rel="noopener noreferrer"&gt;DOM sinks and sources&lt;/a&gt;, the&lt;br&gt;
companion piece in this series, works through why those sources outnumber the sinks.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What is in your &lt;code&gt;localStorage&lt;/code&gt; right now? Not what you think — open devtools and look. I&lt;br&gt;
have found a refresh token, a full user object with an email, and a cached permissions&lt;br&gt;
array in codebases I would have defended.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I write about measurement and static analysis at&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=client-storage-trust-boundary" rel="noopener noreferrer"&gt;dev.to/ofri-peretz&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>javascript</category>
      <category>node</category>
    </item>
  </channel>
</rss>
