<?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>Rename the Variable, Change the Security Verdict</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Mon, 19 Oct 2026 07:36:46 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/rename-the-variable-change-the-security-verdict-5a25</link>
      <guid>https://dev.to/ofri-peretz/rename-the-variable-change-the-security-verdict-5a25</guid>
      <description>&lt;p&gt;Four lines, run through my own XPath injection rule today:&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;xpath&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;//user[@id='&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;     &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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;doc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// silent&lt;/span&gt;
&lt;span class="nx"&gt;xpath&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;//user[@id='&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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;doc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// FIRES&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;QueryValidateSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;QueryInputSchema&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// FIRES&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SchemaA&lt;/span&gt;             &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;SchemaB&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// silent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first two are the same vulnerability. The last two are the same statement.&lt;br&gt;
The only thing that changed is what the variables are called.&lt;/p&gt;
&lt;h2&gt;
  
  
  The heuristic
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;no-xpath-injection&lt;/code&gt; has to decide whether a value is attacker-controlled. Here&lt;br&gt;
is how it decides:&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;varName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;inputNode&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="nf"&gt;toLowerCase&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;req&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;request&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;query&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;params&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;input&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;user&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;search&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;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;keyword&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;varName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// tainted&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 whole test. Not where the value came from — what it is spelled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Both errors, one cause
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The false positive.&lt;/strong&gt; &lt;code&gt;QueryInputSchema&lt;/code&gt; lowercases to &lt;code&gt;queryinputschema&lt;/code&gt;,&lt;br&gt;
which contains &lt;code&gt;query&lt;/code&gt; and also &lt;code&gt;input&lt;/code&gt;. Tainted. There is no XPath call in&lt;br&gt;
that statement at all — it is a Zod schema being re-exported. The rule reports&lt;br&gt;
an injection risk on an assignment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The false negative.&lt;/strong&gt; &lt;code&gt;id&lt;/code&gt; contains none of the seven substrings, so the&lt;br&gt;
value is not tainted, so a concatenation flowing into &lt;code&gt;xpath.select&lt;/code&gt; — a sink&lt;br&gt;
this rule already recognises, it is in the default &lt;code&gt;xpathFunctions&lt;/code&gt; list — is&lt;br&gt;
not reported. Textbook XPath injection, silently accepted.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;//user[...]&lt;/code&gt; string does contain &lt;code&gt;user&lt;/code&gt;. It does not matter: the check&lt;br&gt;
runs on the identifier, never on the literal.&lt;/p&gt;

&lt;p&gt;One predicate, opposite failures. That is what makes this a single defect&lt;br&gt;
rather than two bugs that happen to share a file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worse than being wrong
&lt;/h2&gt;

&lt;p&gt;A rule with a 70% false-positive rate is annoying and legible — you read the&lt;br&gt;
findings, you see the noise, you tune it or drop it.&lt;/p&gt;

&lt;p&gt;This is not that. This rule is &lt;strong&gt;right about the shape of the problem&lt;/strong&gt; and&lt;br&gt;
wrong about the input to it, which means its errors correlate with naming&lt;br&gt;
conventions rather than with code. A codebase that says &lt;code&gt;userInput&lt;/code&gt; gets&lt;br&gt;
flagged everywhere. A codebase that says &lt;code&gt;val&lt;/code&gt;, &lt;code&gt;x&lt;/code&gt;, &lt;code&gt;raw&lt;/code&gt; or &lt;code&gt;id&lt;/code&gt; gets a clean&lt;br&gt;
report and the same vulnerabilities.&lt;/p&gt;

&lt;p&gt;Two teams, identical risk, opposite verdicts — and neither has any way to tell&lt;br&gt;
from the output which one they are.&lt;/p&gt;

&lt;p&gt;It also fails toward silence in exactly the case that matters. Short, neutral&lt;br&gt;
identifiers are what you find in the compact, clever code most likely to be&lt;br&gt;
doing something sharp with strings.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it should read instead
&lt;/h2&gt;

&lt;p&gt;The value's origin, not its spelling. Did it come from a request handler&lt;br&gt;
parameter, a &lt;code&gt;process.argv&lt;/code&gt;, a &lt;code&gt;req.*&lt;/code&gt; member, a function boundary the rule&lt;br&gt;
cannot see past? That is&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/taint-vs-heuristic-detection?utm_source=devto&amp;amp;from=rule-that-reads-variable-names" rel="noopener noreferrer"&gt;taint analysis&lt;/a&gt;,&lt;br&gt;
and it is more expensive than a substring match — which is precisely why the&lt;br&gt;
substring match was written.&lt;/p&gt;

&lt;p&gt;The honest framing is that this rule is a heuristic wearing a taint rule's&lt;br&gt;
messaging. The fix is not a longer keyword list. A longer list moves the&lt;br&gt;
boundary; it does not stop the verdict being a function of names.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that finds these
&lt;/h2&gt;

&lt;p&gt;Take any rule you rely on. Write the vulnerable case. Then &lt;strong&gt;rename every&lt;br&gt;
identifier in it to a single letter&lt;/strong&gt; and run it again.&lt;/p&gt;

&lt;p&gt;If the verdict changes, the rule is reading names. You now know something about&lt;br&gt;
your tooling that no amount of reading its findings would have told you —&lt;br&gt;
because a finding you never get is indistinguishable from a clean file, and&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/precision-recall-f1-for-static-analysis?utm_source=devto&amp;amp;from=rule-that-reads-variable-names" rel="noopener noreferrer"&gt;precision and recall measured on a corpus that shares your naming conventions&lt;br&gt;
will both look fine&lt;/a&gt;.&lt;br&gt;
It is the same reason &lt;a href="https://ofriperetz.dev/go/what-ground-truth-caught-that-unit-tests-missed?utm_source=devto&amp;amp;from=rule-that-reads-variable-names" rel="noopener noreferrer"&gt;a labelled fixture set catches what unit tests&lt;br&gt;
miss&lt;/a&gt;:&lt;br&gt;
the tests assert the rule's behaviour on the names its author chose.&lt;/p&gt;

&lt;p&gt;This is a known class in our own tracker, with a lint check that gates new&lt;br&gt;
occurrences and a documented list of the ones still outstanding. This rule is&lt;br&gt;
on that list. Measured against the workspace build of&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/npm/eslint-plugin-secure-coding?utm_source=devto&amp;amp;from=rule-that-reads-variable-names" rel="noopener noreferrer"&gt;&lt;code&gt;eslint-plugin-secure-coding&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
today — if you are on the published release, re-run before assuming.&lt;/p&gt;

&lt;p&gt;The rule is&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/15ycy9y2rx5?utm_source=devto&amp;amp;from=rule-that-reads-variable-names" rel="noopener noreferrer"&gt;&lt;code&gt;no-xpath-injection&lt;/code&gt;&lt;/a&gt;;&lt;br&gt;
the predicate is in&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/1g2thii15zm?utm_source=devto&amp;amp;from=rule-that-reads-variable-names" rel="noopener noreferrer"&gt;its source&lt;/a&gt;.&lt;br&gt;
Both are open, which is the only reason this article can be specific.&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=rule-that-reads-variable-names" rel="noopener noreferrer"&gt;⭐ Star the repo if you would rather find your linter's blind spots in an article than in an incident.&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-secure-coding?utm_source=devto&amp;amp;from=rule-that-reads-variable-names" rel="noopener noreferrer"&gt;eslint-plugin-secure-coding&lt;/a&gt; is part of the &lt;a href="https://ofriperetz.dev/go/r/8v7joo8tnv?utm_source=devto&amp;amp;from=rule-that-reads-variable-names" rel="noopener noreferrer"&gt;Interlace ESLint ecosystem&lt;/a&gt;. Source on &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint?utm_source=devto&amp;amp;from=rule-that-reads-variable-names" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · Follow: &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=rule-that-reads-variable-names" rel="noopener noreferrer"&gt;Dev.to/ofri-peretz&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Rename the variables in your worst file to single letters. Does your linter still find the bug?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>eslint</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Six Rules Found Nothing. Only Five of Them Were Waiting.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Thu, 15 Oct 2026 07:28:49 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/six-rules-found-nothing-only-five-of-them-were-waiting-2h1a</link>
      <guid>https://dev.to/ofri-peretz/six-rules-found-nothing-only-five-of-them-were-waiting-2h1a</guid>
      <description>&lt;p&gt;I measured fourteen lint rules against a real codebase. Six found nothing.&lt;/p&gt;

&lt;p&gt;The tempting move is to switch those six off. The report says zero, zero looks&lt;br&gt;
like dead weight, and a shorter config is easier to defend. That instinct is&lt;br&gt;
wrong at least five times out of six, and here is the ten-second check that&lt;br&gt;
tells you which.&lt;/p&gt;
&lt;h2&gt;
  
  
  A zero is three different facts
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Dead.&lt;/strong&gt; The rule cannot fire. A regression, a bad selector, an AST assumption&lt;br&gt;
that stopped being true. It will report zero on every codebase forever and&lt;br&gt;
nobody will notice, because zero is what a working rule looks like too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Waiting.&lt;/strong&gt; The rule can fire; your code does not contain the pattern. This is&lt;br&gt;
the correct output and the rule is earning its keep as a ratchet — silent now,&lt;br&gt;
loud the day someone writes the thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unconfigured.&lt;/strong&gt; The rule can fire, but not until you give it options. Silent&lt;br&gt;
on every codebase, including the ones full of exactly what it was built to&lt;br&gt;
catch.&lt;/p&gt;

&lt;p&gt;The report renders all three as &lt;code&gt;0&lt;/code&gt;. The rule list does not distinguish them&lt;br&gt;
either.&lt;/p&gt;
&lt;h2&gt;
  
  
  Ask the rule to fire
&lt;/h2&gt;

&lt;p&gt;You do not have to reason about which case you are in. Every rule worth using&lt;br&gt;
ships &lt;code&gt;invalid&lt;/code&gt; test fixtures — the smallest piece of code its author swears it&lt;br&gt;
must flag. Take one and lint 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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;eslint&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;ESLint&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;overrideConfigFile&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;cwd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/tmp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                   &lt;span class="c1"&gt;// else: "outside of base path"&lt;/span&gt;
  &lt;span class="na"&gt;overrideConfig&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="s1"&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;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;parser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tsParser&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;       &lt;span class="c1"&gt;// @typescript-eslint/parser&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="s1"&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;plugin&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="s1"&gt;react-features/no-is-prefix-prop&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;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="kd"&gt;const&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;eslint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lintText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;interface Props { isLooped: boolean; }&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;filePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/tmp/t.tsx&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="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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parser line is not optional. Most of the fixtures below are &lt;code&gt;interface&lt;/code&gt;&lt;br&gt;
declarations, and the default parser cannot read them — you get a fatal parse&lt;br&gt;
error where you expected the rule's verdict, and a message whose &lt;code&gt;ruleId&lt;/code&gt; is&lt;br&gt;
&lt;code&gt;null&lt;/code&gt;. Print those; never skip them.&lt;/p&gt;

&lt;p&gt;Fires: the rule works, your code is clean, keep it. Silent: you have learned&lt;br&gt;
something the report could not tell you.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it found
&lt;/h2&gt;

&lt;p&gt;Five of the six fired immediately:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;rule&lt;/th&gt;
&lt;th&gt;fixture&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;react-class-to-hooks&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;class MyComponent extends Component { }&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-default-test-id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;function Card({ "data-testid": d = "card" })&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-is-prefix-prop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;interface Props { isLooped: boolean; }&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-kind-prop-discriminator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;`interface Props { type: "checkbox" \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;{% raw %}&lt;code&gt;no-wrapper-sub-component&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;function MyButton(props) { &amp;lt;Button {...props} /&amp;gt; }&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Waiting, all five. The codebase genuinely has none of it.&lt;/p&gt;

&lt;p&gt;The sixth did not. &lt;code&gt;required-attributes&lt;/code&gt; stayed silent on &lt;code&gt;&amp;lt;Button /&amp;gt;&lt;/code&gt; — its&lt;br&gt;
own fixture, at &lt;code&gt;error&lt;/code&gt;, with nothing else enabled.&lt;/p&gt;
&lt;h2&gt;
  
  
  The one that could not fire
&lt;/h2&gt;

&lt;p&gt;Reading its test file explains 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="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;Button /&amp;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;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;attribute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;type&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="c1"&gt;// ← the part that matters&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule takes its attribute list from configuration. Without options there is&lt;br&gt;
nothing it has been asked to require, so it correctly does nothing. Supply the&lt;br&gt;
options and the same fixture reports one error.&lt;/p&gt;

&lt;p&gt;That is not a bug. It is worse than a bug in one specific way: &lt;strong&gt;a bug gets&lt;br&gt;
noticed.&lt;/strong&gt; This rule sits in a config, at &lt;code&gt;error&lt;/code&gt;, looking enabled, and reports&lt;br&gt;
zero on every codebase in the world — including one that violates it on every&lt;br&gt;
line. The report cannot distinguish that from success.&lt;/p&gt;

&lt;p&gt;Five waiting, one unconfigured, none dead. I would have guessed a different&lt;br&gt;
split, and switching off "the six that found nothing" would have thrown away&lt;br&gt;
five working ratchets and left the one real problem in place.&lt;/p&gt;
&lt;h2&gt;
  
  
  The habit
&lt;/h2&gt;

&lt;p&gt;Before you delete a rule for being quiet, make it speak. If it will not, you&lt;br&gt;
have found something more interesting than a finding.&lt;/p&gt;

&lt;p&gt;The same instinct applies one level up: a lint run that reports zero is not&lt;br&gt;
evidence that a tool ran. A harness of mine once reported 0 findings across 100&lt;br&gt;
files because every file was silently ignored — a perfectly believable number,&lt;br&gt;
and completely false. Zero is the one output that looks identical whether&lt;br&gt;
everything worked or nothing did, which is why&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/precision-recall-f1-for-static-analysis?utm_source=devto&amp;amp;from=zero-yield-rules-dead-or-waiting" rel="noopener noreferrer"&gt;a count means nothing without the measurement behind it&lt;/a&gt;&lt;br&gt;
and why &lt;a href="https://ofriperetz.dev/go/aggregate-benchmarks-lie-heres-what-700-ai-functions-look-like-by-security-domain?utm_source=devto&amp;amp;from=zero-yield-rules-dead-or-waiting" rel="noopener noreferrer"&gt;an aggregate hides the distribution that produced it&lt;/a&gt;.&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;--save-dev&lt;/span&gt; eslint-plugin-react-features
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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=zero-yield-rules-dead-or-waiting" 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=zero-yield-rules-dead-or-waiting" rel="noopener noreferrer"&gt;Interlace ESLint ecosystem&lt;/a&gt;. Source on &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint?utm_source=devto&amp;amp;from=zero-yield-rules-dead-or-waiting" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · Follow: &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=zero-yield-rules-dead-or-waiting" rel="noopener noreferrer"&gt;Dev.to/ofri-peretz&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Which rule in your config has never once fired — and have you ever asked it to?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>eslint</category>
      <category>webdev</category>
      <category>testing</category>
    </item>
    <item>
      <title>13 Repos Keep a Credential in Web Storage. Seven Are Governments.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sun, 11 Oct 2026 08:26:36 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/13-repos-keep-a-credential-in-web-storage-seven-are-governments-20be</link>
      <guid>https://dev.to/ofri-peretz/13-repos-keep-a-credential-in-web-storage-seven-are-governments-20be</guid>
      <description>&lt;p&gt;"Never put a JWT in localStorage" is the most repeated piece of frontend security advice and the least measured. So I measured it.&lt;/p&gt;

&lt;p&gt;258,117 source files. &lt;strong&gt;1,423 touch web storage&lt;/strong&gt; — that is the denominator, found by reading source text before any rule ran. They live in 189 public repositories.&lt;/p&gt;

&lt;p&gt;Thirteen of those repositories put a credential in storage — eleven of them a bearer token. &lt;strong&gt;Seven of the thirteen are government digital services.&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;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Helsinki · &lt;code&gt;kukkuu-admin&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;localStorage.setItem(API_TOKEN, user.access_token)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UK ONS · &lt;code&gt;eq-author-app&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;localStorage.setItem("accessToken", user.ra)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;US GSA · &lt;code&gt;srt-ui&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;localStorage.setItem('token', data.token)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Canada · &lt;code&gt;cds-snc&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;localStorage.setItem("token", response.access_token)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;France · &lt;code&gt;betagouv&lt;/code&gt; ×2&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;xsrfToken&lt;/code&gt;, &lt;code&gt;NATIVE_TOKEN_KEY&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;British Columbia · &lt;code&gt;sso-requests&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sessionStorage.setItem(TOKEN_SESSION, …)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The non-government six are &lt;code&gt;cloudflare-os&lt;/code&gt;, &lt;code&gt;headlamp&lt;/code&gt;, &lt;code&gt;mozilla_fxa&lt;/code&gt;, &lt;code&gt;solid-playground&lt;/code&gt;, &lt;code&gt;wix_skills&lt;/code&gt; and a Twilio app that stores a passcode.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rate is the boring half
&lt;/h2&gt;

&lt;p&gt;Thirteen out of 189 is about 7%. If you came expecting an epidemic, there isn't one — most code touching web storage keeps credentials out of it.&lt;/p&gt;

&lt;p&gt;What makes seven percent interesting is &lt;em&gt;where&lt;/em&gt; it lands. Public-sector frontends are built by rotating contractors against procurement deadlines, and they inherit whatever the OIDC tutorial did. The tutorial put the token in &lt;code&gt;localStorage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is a mechanism, not an accusation. Two of the seven — Helsinki and the UK ONS — store a &lt;strong&gt;refresh&lt;/strong&gt; token beside the access token, and that is the part that matters: an access token expires in minutes, a refresh token is a long-lived credential sitting where any injected script can read it synchronously. That is the whole argument: web storage is &lt;a href="https://ofriperetz.dev/go/client-storage-trust-boundary?utm_source=devto&amp;amp;from=token-in-localstorage-field-study" rel="noopener noreferrer"&gt;a trust boundary you share with every script on the page&lt;/a&gt;, and one &lt;a href="https://ofriperetz.dev/go/dom-sink-taxonomy?utm_source=devto&amp;amp;from=token-in-localstorage-field-study" rel="noopener noreferrer"&gt;DOM sink&lt;/a&gt; is all it takes to cross it. The six non-government hits are developer tools, playgrounds and internal consoles, where the threat model is genuinely different.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two of the thirteen are not the mistake you think
&lt;/h2&gt;

&lt;p&gt;Reading the code changes the verdict twice.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;bcgov_sso-requests&lt;/code&gt; uses &lt;code&gt;sessionStorage&lt;/code&gt;, not &lt;code&gt;localStorage&lt;/code&gt; — same XSS exposure, but the token dies with the tab instead of persisting. &lt;code&gt;betagouv_sante-psy&lt;/code&gt; stores an &lt;strong&gt;XSRF&lt;/strong&gt; token, which is a different risk class entirely: it is meant to be readable by your own page, and it is not a bearer credential. That is why the count above says &lt;em&gt;credential&lt;/em&gt; and not &lt;em&gt;auth token&lt;/em&gt;: eleven of the thirteen are bearer tokens, and the two that are not are this XSRF token and a Twilio app passcode. &lt;code&gt;bcgov&lt;/code&gt; is a bearer token — it is on this list for the store it chose, not for what it stores.&lt;/p&gt;

&lt;p&gt;Counting them all as "a JWT in localStorage" would have been technically defensible and substantively wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  My own rule is right 13 times out of 19
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;no-jwt-in-storage&lt;/code&gt; flagged 19 repositories. I read all 39 files. Six repos are false positives, and all six fail the same way — the rule matched an identifier &lt;em&gt;name&lt;/em&gt; rather than proving a credential:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sessionId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// a demo session id&lt;/span&gt;
&lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app_authServer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// a server URL&lt;/span&gt;
&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AUTH_STATUS_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// the string "success"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also flagged: a co-browse session descriptor, a telemetry session, a sandbox id, and an example &lt;code&gt;user&lt;/code&gt; object. That is &lt;strong&gt;68% precision&lt;/strong&gt; on real source.&lt;/p&gt;

&lt;p&gt;I am reporting it because the thirteen are only believable if you know what the instrument does when it is wrong. A field study that publishes its hit rate and not its miss rate is a marketing page.&lt;/p&gt;

&lt;p&gt;One more, worth its own line: &lt;code&gt;no-cookie-auth-tokens&lt;/code&gt; fired &lt;strong&gt;zero&lt;/strong&gt; times across all 1,423 files. A rule that never fires in a corpus this size is telling you about the rule, not the corpus.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it
&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 — after `npm i -D eslint-plugin-browser-security`&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;browser&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-browser-security&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,tsx,js,jsx}&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;browser-security&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;browser&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;browser-security/no-jwt-in-storage&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expect to read the hits rather than trust them — &lt;a href="https://ofriperetz.dev/go/ground-truth-in-security-testing?utm_source=devto&amp;amp;from=token-in-localstorage-field-study" rel="noopener noreferrer"&gt;ground truth is the hard half&lt;/a&gt; of any audit like this. The corpus here is an adoption scan, not a random draw from GitHub, so "7 of 13" describes what I looked at. Point it at a different corpus and the mix will move.&lt;/p&gt;




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

&lt;p&gt;&lt;em&gt;Open your own repo and grep for &lt;code&gt;setItem&lt;/code&gt;. Is the thing you find a bearer token, or did you just name a variable badly?&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://ofriperetz.dev/go/ground-truth-in-security-testing?utm_source=devto&amp;amp;from=token-in-localstorage-field-study" rel="noopener noreferrer"&gt;Ground Truth in Security Testing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ofriperetz.dev/go/cwe-taxonomy-explained?utm_source=devto&amp;amp;from=token-in-localstorage-field-study" rel="noopener noreferrer"&gt;The CWE Taxonomy, Explained&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://ofriperetz.dev/go/npm/eslint-plugin-browser-security?utm_source=devto&amp;amp;from=token-in-localstorage-field-study" rel="noopener noreferrer"&gt;npm&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint?utm_source=devto&amp;amp;from=token-in-localstorage-field-study" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>javascript</category>
      <category>eslint</category>
    </item>
    <item>
      <title>I Linted 14 Public AI SDK Repos. 12 Ship a Call With No Token Ceiling.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Wed, 07 Oct 2026 07:49:42 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/i-linted-14-public-ai-sdk-repos-12-ship-a-call-with-no-token-ceiling-2349</link>
      <guid>https://dev.to/ofri-peretz/i-linted-14-public-ai-sdk-repos-12-ship-a-call-with-no-token-ceiling-2349</guid>
      <description>&lt;p&gt;I argued last week that &lt;a href="https://ofriperetz.dev/go/agent-resource-bounds?utm_source=devto&amp;amp;from=ai-agent-unbounded-loop-audit" rel="noopener noreferrer"&gt;an AI SDK call with no bounds is three CWEs in one missing config object&lt;/a&gt;. Fair question back: does anyone actually ship that?&lt;/p&gt;

&lt;p&gt;So I linted for it. 20,004 source files across 14 public repositories, 474 of which import the Vercel AI SDK, &lt;strong&gt;116 of which contain a real generation call&lt;/strong&gt;. That 116 is the denominator; everything below is a fraction of it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bound&lt;/th&gt;
&lt;th&gt;Files missing it&lt;/th&gt;
&lt;th&gt;Repositories&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;output cap (&lt;code&gt;maxOutputTokens&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;103 / 116&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;12 of 14&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;request timeout&lt;/td&gt;
&lt;td&gt;50 / 116&lt;/td&gt;
&lt;td&gt;9 of 14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;abort signal on a stream&lt;/td&gt;
&lt;td&gt;44 / 116&lt;/td&gt;
&lt;td&gt;7 of 14&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Read the repo column, not the percentage
&lt;/h2&gt;

&lt;p&gt;88.8% is the number that would travel, and it is the one I trust least. One repository, &lt;code&gt;cloudflare_agents&lt;/code&gt;, contributes 52 of those 103 files — just over half. Percentages of files measure how a codebase is split into modules as much as how it was written.&lt;/p&gt;

&lt;p&gt;The repo count is the robust statistic. Twelve of fourteen survives deleting any single project from the corpus. The percentage does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five of the fourteen are Vercel's own
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ai-elements&lt;/code&gt;, &lt;code&gt;vercel_chat&lt;/code&gt;, &lt;code&gt;vercel_examples&lt;/code&gt;, &lt;code&gt;vercel_streamdown&lt;/code&gt; and &lt;code&gt;vercel-labs_agent-eval&lt;/code&gt; are the SDK vendor's repositories, and they are in the findings.&lt;/p&gt;

&lt;p&gt;That is not a gotcha — it is the mechanism. Example code is deliberately minimal; a ceiling is noise in a snippet teaching you &lt;code&gt;streamText&lt;/code&gt;. But the minimal shape is the shape that gets copied, and it is missing the same parameter in the reader's production handler, where it is no longer noise. The omission is correct upstream and wrong downstream, which is exactly why nothing catches it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an unbounded call looks like in the wild
&lt;/h2&gt;

&lt;p&gt;Not exotic. This is the common form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;streamText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one worth showing is subtler — &lt;code&gt;302ai_302-AI-Studio&lt;/code&gt; builds its options indirectly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;streamTextOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;baseConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;streamTextOptions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I assumed this was a false positive: a rule cannot see into an object built elsewhere. It isn't. &lt;code&gt;baseConfig&lt;/code&gt; sets &lt;code&gt;model&lt;/code&gt;, &lt;code&gt;messages&lt;/code&gt;, &lt;code&gt;providerOptions&lt;/code&gt; and &lt;code&gt;tools&lt;/code&gt; — and the whole 1,500-line file contains exactly one occurrence of &lt;code&gt;timeout&lt;/code&gt;, &lt;code&gt;abortSignal&lt;/code&gt; or &lt;code&gt;maxOutputTokens&lt;/code&gt; between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bound I threw out
&lt;/h2&gt;

&lt;p&gt;The audit measured a fourth — step count — and I am not reporting it. Two reasons, either sufficient. The SDK already defaults &lt;code&gt;stopWhen&lt;/code&gt; to &lt;code&gt;stepCountIs(1)&lt;/code&gt;, so the loop is bounded unless you raised it yourself; a rule firing there reports a non-defect. And spot-checking four of its flagged files found one that sets both &lt;code&gt;stopWhen&lt;/code&gt; and &lt;code&gt;stepCountIs&lt;/code&gt; and was flagged anyway.&lt;/p&gt;

&lt;p&gt;A rule that fires on a safe default doesn't make the number bigger. It makes the other three unbelievable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it on yours
&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 — after `npm i -D eslint-plugin-vercel-ai-security`&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;ai&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-vercel-ai-security&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="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;vercel-ai-security&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ai&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;vercel-ai-security/require-max-tokens&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Honest limits, because n = 14 is small — and a corpus you assembled yourself is &lt;a href="https://ofriperetz.dev/go/ground-truth-in-security-testing?utm_source=devto&amp;amp;from=ai-agent-unbounded-loop-audit" rel="noopener noreferrer"&gt;the hard half of any audit&lt;/a&gt;. This is an adoption-scan convenience sample, not a random draw from npm, and two directories in it turned out to be the same project cloned twice — caught by content fingerprint, since every checkout reported the same &lt;code&gt;git remote&lt;/code&gt;. The claim is that this is what the SDK's own ecosystem looks like. It is not a population rate.&lt;/p&gt;

&lt;p&gt;The prior sweep I tried to reuse recorded findings but no denominator and no repo list, so it could not answer this at all. A count without its denominator isn't a small result. It's not a result.&lt;/p&gt;




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

&lt;p&gt;&lt;em&gt;Check the handler you shipped this week: is there a ceiling on it, or did the example not have one either?&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://ofriperetz.dev/go/agent-resource-bounds?utm_source=devto&amp;amp;from=ai-agent-unbounded-loop-audit" rel="noopener noreferrer"&gt;Your LLM Call Has No Ceiling&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ofriperetz.dev/go/cwe-taxonomy-explained?utm_source=devto&amp;amp;from=ai-agent-unbounded-loop-audit" rel="noopener noreferrer"&gt;The CWE Taxonomy, Explained&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ofriperetz.dev/go/getting-started-eslint-plugin-vercel-ai-security?utm_source=devto&amp;amp;from=ai-agent-unbounded-loop-audit" rel="noopener noreferrer"&gt;Getting Started with eslint-plugin-vercel-ai-security&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://ofriperetz.dev/go/npm/eslint-plugin-vercel-ai-security?utm_source=devto&amp;amp;from=ai-agent-unbounded-loop-audit" rel="noopener noreferrer"&gt;npm&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint?utm_source=devto&amp;amp;from=ai-agent-unbounded-loop-audit" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>security</category>
      <category>eslint</category>
    </item>
    <item>
      <title>Your LLM Call Has No Ceiling. TypeScript Is Fine With It. Three CWEs Aren't.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Fri, 02 Oct 2026 09:10:34 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/your-llm-call-has-no-ceiling-typescript-is-fine-with-it-three-cwes-arent-dkd</link>
      <guid>https://dev.to/ofri-peretz/your-llm-call-has-no-ceiling-typescript-is-fine-with-it-three-cwes-arent-dkd</guid>
      <description>&lt;p&gt;&lt;code&gt;generateText&lt;/code&gt; with a model and a prompt and nothing else is a valid call. TypeScript is satisfied. The type system has no opinion about how many tokens come back, how long it takes, or who pays for the answer nobody reads.&lt;/p&gt;

&lt;p&gt;Three rules in &lt;code&gt;eslint-plugin-vercel-ai-security&lt;/code&gt; fire on a call written that way, and they map to three &lt;em&gt;different&lt;/em&gt; CWEs — 770, 400 and 404. (A CWE is a label, not a verdict — &lt;a href="https://ofriperetz.dev/go/cwe-taxonomy-explained?utm_source=devto&amp;amp;from=agent-resource-bounds" rel="noopener noreferrer"&gt;what the taxonomy actually claims&lt;/a&gt; — and counting CWEs is a &lt;a href="https://ofriperetz.dev/go/proxy-metrics?utm_source=devto&amp;amp;from=agent-resource-bounds" rel="noopener noreferrer"&gt;proxy metric&lt;/a&gt;.) Same missing config object. Three separate ways to lose.&lt;/p&gt;

&lt;p&gt;A note on the bound people expect: step count. The SDK defaults &lt;code&gt;stopWhen&lt;/code&gt; to &lt;code&gt;stepCountIs(1)&lt;/code&gt;, so a tool-calling loop does not run away on its own — that hole gets opened deliberately, by raising the ceiling, not by forgetting it. The three below are genuinely unbounded when you say nothing.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The output with no cap — CWE-770
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// no maxOutputTokens&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it gets written:&lt;/strong&gt; the parameter is optional, and the happy path never needs it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it survives review:&lt;/strong&gt; output length reads as a &lt;em&gt;quality&lt;/em&gt; knob, not a resource bound. But this is billed per token. An unbounded output is an unbounded invoice, and nothing in the diff looks like money.&lt;/p&gt;

&lt;p&gt;Fix: set &lt;code&gt;maxOutputTokens&lt;/code&gt; to the longest answer you would pay for — there is no correct number, only the difference between a ceiling and none. Note the rename: this was &lt;code&gt;maxTokens&lt;/code&gt; in v4. Guidance written against v4 still &lt;em&gt;reads&lt;/em&gt; correct in review and bounds nothing.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The request that never returns — CWE-400
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// no timeout&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it gets written:&lt;/strong&gt; &lt;code&gt;fetch&lt;/code&gt;-shaped APIs feel like they time out. This one doesn't — &lt;code&gt;timeout&lt;/code&gt; is optional and the SDK sets no default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it survives review:&lt;/strong&gt; staging latency is fine, so nobody asks what happens when the provider hangs instead of failing.&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;await&lt;/span&gt; &lt;span class="nf"&gt;generateText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;totalMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;timeout&lt;/code&gt; has been first-class since &lt;strong&gt;6.0.14&lt;/strong&gt; and also takes &lt;code&gt;stepMs&lt;/code&gt;, &lt;code&gt;chunkMs&lt;/code&gt;, &lt;code&gt;toolMs&lt;/code&gt;. Below that, hand-roll an &lt;code&gt;AbortController&lt;/code&gt;; the rule accepts either, but reach for the parameter first.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The stream nobody can cancel — CWE-404
&lt;/h2&gt;



&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;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;streamText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// no abortSignal&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will file this under polish. Here is why that is wrong: the client is gone, and the server keeps generating — and billing — for a reader who will never see a token of it.&lt;/p&gt;

&lt;p&gt;Why &lt;em&gt;shutdown&lt;/em&gt; (CWE-404) rather than consumption (400)? The resource was acquired correctly and never released — the handle outlives the request that justified it. A release bug, not an acquisition bug.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ac&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;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abort&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt; &lt;span class="c1"&gt;// client disconnected&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;streamText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;abortSignal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






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

&lt;p&gt;All three are one defect: &lt;strong&gt;an allocation with no ceiling.&lt;/strong&gt; Tokens, wall-clock, lifetime.&lt;/p&gt;

&lt;p&gt;I don't trust an allocation whose ceiling I can't see — the same instinct that keeps me out of a position whose downside I can't draw on one page.&lt;/p&gt;

&lt;p&gt;Classic resource-exhaustion review asks whether an attacker can make something loop forever. For an LLM call, the answer is worse — you don't need an attacker. A verbose model and one retry will do it, and the meter runs the entire time.&lt;/p&gt;

&lt;p&gt;This is OWASP LLM10, Unbounded Consumption — &lt;a href="https://ofriperetz.dev/go/100-owasp-llm-top-10-coverage-for-vercel-ai-sdk?utm_source=devto&amp;amp;from=agent-resource-bounds" rel="noopener noreferrer"&gt;the full top-10 mapping for this SDK&lt;/a&gt; covers the other nine. That list is a separate taxonomy from &lt;a href="https://ofriperetz.dev/go/owasp-top-10-explained?utm_source=devto&amp;amp;from=agent-resource-bounds" rel="noopener noreferrer"&gt;the web Top 10&lt;/a&gt; — LLM10 has no A-number. For governance rather than linting, see &lt;a href="https://ofriperetz.dev/go/r/hgb7extkuq?utm_source=devto&amp;amp;from=agent-resource-bounds" rel="noopener noreferrer"&gt;NIST AI RMF&lt;/a&gt;. That is why these are security rules, not style rules. It is the move &lt;a href="https://ofriperetz.dev/go/injection-beyond-sql?utm_source=devto&amp;amp;from=agent-resource-bounds" rel="noopener noreferrer"&gt;injection across nine interpreters&lt;/a&gt; makes too: CWE-770, 400 and 404 are the same sentence in three grammars.&lt;/p&gt;




&lt;h2&gt;
  
  
  The config
&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; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; eslint-plugin-vercel-ai-security
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node 18+. The peer range is ESLint 8 ∥ 9 ∥ 10, so both config formats work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// eslint.config.mjs — ESLint 9 · 10&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;vercelAi&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-vercel-ai-security&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="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;vercel-ai-security&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;vercelAi&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;vercel-ai-security/require-max-tokens&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;vercel-ai-security/require-request-timeout&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;vercel-ai-security/require-abort-signal&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 json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;.eslintrc.json&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ESLint&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&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;span class="nl"&gt;"plugins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"vercel-ai-security"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rules"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"vercel-ai-security/require-max-tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"vercel-ai-security/require-request-timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"warn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"vercel-ai-security/require-abort-signal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"warn"&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;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;On oxlint, add &lt;code&gt;"jsPlugins": ["eslint-plugin-vercel-ai-security/oxlint"]&lt;/code&gt; — alpha, not under semver.&lt;/p&gt;

&lt;p&gt;Rule docs: &lt;a href="https://ofriperetz.dev/go/r/12y5mkhgudu?utm_source=devto&amp;amp;from=agent-resource-bounds" rel="noopener noreferrer"&gt;require-max-tokens&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/10tceywee5m?utm_source=devto&amp;amp;from=agent-resource-bounds" rel="noopener noreferrer"&gt;require-request-timeout&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/19kzghbou99?utm_source=devto&amp;amp;from=agent-resource-bounds" rel="noopener noreferrer"&gt;require-abort-signal&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=agent-resource-bounds" rel="noopener noreferrer"&gt;follow on dev.to&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Has an LLM call ever run longer in production than you expected — and what told you first, the logs or the bill?&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://ofriperetz.dev/go/securing-ai-agents-in-the-vercel-ai-sdk?utm_source=devto&amp;amp;from=agent-resource-bounds" rel="noopener noreferrer"&gt;Securing AI Agents in the Vercel AI SDK&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ofriperetz.dev/go/getting-started-eslint-plugin-vercel-ai-security?utm_source=devto&amp;amp;from=agent-resource-bounds" rel="noopener noreferrer"&gt;Getting Started with eslint-plugin-vercel-ai-security&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ofriperetz.dev/go/cwe-taxonomy-explained?utm_source=devto&amp;amp;from=agent-resource-bounds" rel="noopener noreferrer"&gt;The CWE Taxonomy, Explained&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://ofriperetz.dev/go/npm/eslint-plugin-vercel-ai-security?utm_source=devto&amp;amp;from=agent-resource-bounds" rel="noopener noreferrer"&gt;npm&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint?utm_source=devto&amp;amp;from=agent-resource-bounds" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>security</category>
      <category>eslint</category>
    </item>
    <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>
  </channel>
</rss>
