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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;&lt;strong&gt;The worker&lt;/strong&gt; holds the linter and never talks to a server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Linter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eslint/universal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eslint-plugin-jwt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PLUGINS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// enumerated, never dynamic&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;linter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Linter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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




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

&lt;p&gt;Their tests use ESLint's &lt;code&gt;RuleTester&lt;/code&gt;. Rather than parse samples out of the source, intercept the runner and collect them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;RuleTester&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eslint&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;captured&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="nx"&gt;RuleTester&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tests&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;tests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invalid&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="nx"&gt;captured&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node_modules/eslint-plugin-security/test/rules&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`.../test/rules/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;p&gt;It was removed in Node 10. On Node 24:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;alloc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readUInt8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// extra argument ignored&lt;/span&gt;
&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readUInt8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// still throws ERR_OUT_OF_RANGE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;p&gt;Point it at mine: &lt;a href="https://ofriperetz.dev/go/npm/eslint-plugin-node-security?utm_source=devto&amp;amp;from=eslint-security-corpus-parity" rel="noopener noreferrer"&gt;&lt;code&gt;eslint-plugin-node-security&lt;/code&gt;&lt;/a&gt; ships its tests too, and I would rather read your numbers than my own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; eslint-plugin-node-security
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






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

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

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

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

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




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

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

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

&lt;/div&gt;



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

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

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

&lt;p&gt;The fix wasn't a longer regex. Guessing every phrasing of "nothing is wrong" is unbounded. The prompts already state the contract — fully in scope means &lt;code&gt;BLOCKERS: None&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;SCORE: 10&lt;/code&gt; — so a 10 that also reports a blocker is self-contradictory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;blockers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;improvements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;blockers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// demote, never drop&lt;/span&gt;
  &lt;span class="nx"&gt;blockers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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




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

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

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

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

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




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

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

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

&lt;/div&gt;





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

&lt;/div&gt;



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

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

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

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



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

&lt;/div&gt;



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

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

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

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

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

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



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

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;**/*.tsx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-features&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;reactFeatures&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-features/no-raw-color-literal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-features/no-arbitrary-token-class&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-features/require-data-slot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;warn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-features/no-inline-style&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;warn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





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

&lt;/div&gt;



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

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

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




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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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




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

</description>
      <category>webdev</category>
      <category>security</category>
      <category>javascript</category>
      <category>node</category>
    </item>
    <item>
      <title>innerHTML Has Five Doors. Most Reviews Only Watch One.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sat, 08 Aug 2026 08:19:02 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/innerhtml-has-five-doors-most-reviews-only-watch-one-587c</link>
      <guid>https://dev.to/ofri-peretz/innerhtml-has-five-doors-most-reviews-only-watch-one-587c</guid>
      <description>&lt;p&gt;I have reviewed the same line of code, written five different ways, and caught it once.&lt;/p&gt;

&lt;p&gt;The line is &lt;code&gt;element.innerHTML = something&lt;/code&gt;. I catch it when &lt;code&gt;something&lt;/code&gt; is obviously a&lt;br&gt;
URL parameter. I miss it when &lt;code&gt;something&lt;/code&gt; arrived four frames ago through a &lt;code&gt;message&lt;/code&gt;&lt;br&gt;
event, got stored, and is written to the DOM by a function in a different file.&lt;/p&gt;

&lt;p&gt;The sink was identical every time. My attention was not.&lt;/p&gt;


&lt;h2&gt;
  
  
  Sinks are few. Sources are many.
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;sink&lt;/strong&gt; is where data becomes execution: &lt;code&gt;innerHTML&lt;/code&gt;, &lt;code&gt;outerHTML&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;insertAdjacentHTML&lt;/code&gt;, &lt;code&gt;document.write&lt;/code&gt;, &lt;code&gt;eval&lt;/code&gt;. You can list them on one hand, which is&lt;br&gt;
why they feel solved. Grep for &lt;code&gt;innerHTML&lt;/code&gt;. Ban it in review. Done.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;source&lt;/strong&gt; is where untrusted data enters. Sources are not listable — they are a&lt;br&gt;
property of your architecture, and they multiply with every feature.&lt;/p&gt;

&lt;p&gt;That asymmetry is the whole problem. The sink is a syntactic fact. The source is a&lt;br&gt;
&lt;em&gt;trust&lt;/em&gt; fact, and trust facts do not survive being passed as a function argument.&lt;/p&gt;
&lt;h2&gt;
  
  
  The five doors
&lt;/h2&gt;

&lt;p&gt;The same sink, reached five ways. Every one is code I would call normal.&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;// 1. Direct — the one everybody catches&lt;/span&gt;
&lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&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;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;q&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 2. postMessage — another frame speaks, event.data is a string like any other&lt;/span&gt;
&lt;span class="nb"&gt;window&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;message&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;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// 3. FileReader — the user picked the file, which feels like consent&lt;/span&gt;
&lt;span class="nx"&gt;reader&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;load&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// 4. WebSocket — the connection is authenticated, so the payload feels authenticated&lt;/span&gt;
&lt;span class="nx"&gt;socket&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;message&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;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertAdjacentHTML&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;beforeend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// 5. Worker — it is our own worker, running our own code&lt;/span&gt;
&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;message&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;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;html&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;Five doors, one room. On the first, a reviewer says "that's user input." On the other&lt;br&gt;
four, they say some version of &lt;em&gt;"but that's ours."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The word "ours" is doing all the damage
&lt;/h2&gt;

&lt;p&gt;Each of those four defences is a claim about &lt;strong&gt;provenance&lt;/strong&gt;, and each is wrong the same way.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;message&lt;/code&gt; event fires for any frame with a handle on your window unless you check&lt;br&gt;
&lt;code&gt;event.origin&lt;/code&gt; — the listener does not filter by default. An authenticated WebSocket says&lt;br&gt;
the &lt;em&gt;connection&lt;/em&gt; is trusted, not what the other end sent, which may have been stored by a&lt;br&gt;
different user hours earlier. A worker is your code, but the data it posts back is&lt;br&gt;
whatever it was given. And a file the user chose is untrusted precisely &lt;em&gt;because&lt;/em&gt; they&lt;br&gt;
chose it.&lt;/p&gt;

&lt;p&gt;The failure is not that developers think &lt;code&gt;innerHTML&lt;/code&gt; is safe. Everyone knows. The failure&lt;br&gt;
is that danger gets assessed at the &lt;em&gt;sink&lt;/em&gt;, where the only visible fact is a string — and&lt;br&gt;
the string looks the same whoever wrote it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why generated code lands here
&lt;/h2&gt;

&lt;p&gt;Ask a model to render a preview and it reaches for &lt;code&gt;innerHTML&lt;/code&gt;, because that is the&lt;br&gt;
shortest correct-looking answer to "put this markup on the page" and the training corpus&lt;br&gt;
is fifteen years of tutorials using it.&lt;/p&gt;

&lt;p&gt;More to the point: a model completing a &lt;code&gt;message&lt;/code&gt; handler has the reviewer's problem,&lt;br&gt;
worse. It sees a local scope. It cannot know whether that listener lives in an app that&lt;br&gt;
checks &lt;code&gt;event.origin&lt;/code&gt; elsewhere. The sink is local; the trust decision is not.&lt;/p&gt;

&lt;p&gt;Which is the interesting part — the information needed to get it right is &lt;em&gt;not present at&lt;br&gt;
the point of writing&lt;/em&gt;. A property of the problem, not of the author.&lt;/p&gt;

&lt;h2&gt;
  
  
  The taxonomy is the useful artifact
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Trust claim that fails&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;URL / query params&lt;/td&gt;
&lt;td&gt;none — everyone catches this&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;postMessage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"another frame of ours"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebSocket message&lt;/td&gt;
&lt;td&gt;"the connection is authenticated"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;FileReader&lt;/code&gt; result&lt;/td&gt;
&lt;td&gt;"the user chose the file"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker message&lt;/td&gt;
&lt;td&gt;"it is our own worker"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the right column. That is not five vulnerabilities. It is one — &lt;em&gt;provenance assumed&lt;br&gt;
from proximity&lt;/em&gt; — wearing five costumes. Sinks tell you where to look; pairs tell you what&lt;br&gt;
to &lt;strong&gt;ask&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it means for detection
&lt;/h2&gt;

&lt;p&gt;Grepping a sink is a heuristic. Following a source to a sink is taint tracking —&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/taint-vs-heuristic-detection?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;the thing that decides your false-positive rate&lt;/a&gt;.&lt;br&gt;
Full taint tracking across frames and sockets is not something a linter does; those&lt;br&gt;
boundaries are&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/static-analysis-vs-sast-vs-linting?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;the line between linting and SAST&lt;/a&gt;.&lt;br&gt;
What a linter &lt;em&gt;can&lt;/em&gt; do is match source and sink inside one handler — which covers all four&lt;br&gt;
cases, because the risky pattern is nearly always written in a single function.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two branches, not one chain
&lt;/h3&gt;

&lt;p&gt;The XSS &lt;em&gt;outcome&lt;/em&gt; is &lt;a href="https://ofriperetz.dev/go/r/1g3gv4lpdto?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;CWE-79&lt;/a&gt;, under&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/1qpejqok4p?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;CWE-74&lt;/a&gt; Injection. The &lt;em&gt;trust failure&lt;/em&gt; is&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/l9p4nl18o8?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;CWE-345&lt;/a&gt; under&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/o7crbwo4ui?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;CWE-693&lt;/a&gt; — with its child&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/1uu8tu6zd69?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;CWE-346&lt;/a&gt; applying to &lt;code&gt;postMessage&lt;/code&gt;&lt;br&gt;
alone, the only case with a page-side origin check. A WebSocket's &lt;code&gt;Origin&lt;/code&gt; is validated&lt;br&gt;
server-side at the handshake; FileReader and workers have no origin at all.&lt;/p&gt;

&lt;p&gt;Different branches, not one chain. Filing all of it as "XSS" is how the actual defect goes&lt;br&gt;
unrecorded. &lt;a href="https://ofriperetz.dev/go/cwe-taxonomy-explained?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;The taxonomy piece&lt;/a&gt;&lt;br&gt;
has the map.&lt;/p&gt;

&lt;p&gt;Worth reading in full:&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/16norlpyu9f?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;OWASP's DOM-based XSS Prevention Cheat Sheet&lt;/a&gt;&lt;br&gt;
and &lt;a href="https://ofriperetz.dev/go/r/61fr9k50xh?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;PortSwigger on sources and sinks&lt;/a&gt;.&lt;br&gt;
To stop it being a review question at all,&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/17ux0fezqqz?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;Trusted Types&lt;/a&gt; moves&lt;br&gt;
it to the browser — the only place that sees every assignment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is boring, which is the point
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;window&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;message&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;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://trusted.example&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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;Check the origin. Use &lt;code&gt;textContent&lt;/code&gt; for text. Sanitize when you genuinely need markup.&lt;br&gt;
None of that is the hard part.&lt;/p&gt;

&lt;p&gt;The hard part is noticing you are in one of the five doorways — because four of them do&lt;br&gt;
not look like doorways. They look like your own code talking to itself.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Which door caught you? I am most interested in &lt;code&gt;postMessage&lt;/code&gt; — the origin check is one&lt;br&gt;
line, documented everywhere, and I still find handlers without it in code I wrote myself.&lt;/em&gt;&lt;/p&gt;




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

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>security</category>
      <category>eslint</category>
    </item>
    <item>
      <title>Nobody Writes Bad Crypto. They Write Correct Crypto at Four Layers.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sat, 01 Aug 2026 09:00:32 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/nobody-writes-bad-crypto-they-write-correct-crypto-at-four-layers-2noa</link>
      <guid>https://dev.to/ofri-peretz/nobody-writes-bad-crypto-they-write-correct-crypto-at-four-layers-2noa</guid>
      <description>&lt;p&gt;The code review comment I have written most often, and regretted most often, is&lt;br&gt;
"use a stronger algorithm."&lt;/p&gt;

&lt;p&gt;It is regrettable because it is almost never the actual defect. It is the defect I can see&lt;br&gt;
from where I am standing. The team swaps SHA-1 for SHA-256, everyone agrees the review was&lt;br&gt;
useful, and the thing that would have mattered stays where it was.&lt;/p&gt;




&lt;h2&gt;
  
  
  Four layers, each hiding the next
&lt;/h2&gt;

&lt;p&gt;A crypto call has to be right four separate times, and being correct at one layer makes&lt;br&gt;
the next one invisible. AES looks like a decision you already made. It is four.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1 — the primitive.&lt;/strong&gt; Right kind of thing? A hash where you needed a&lt;br&gt;
KDF. A cipher where you needed a MAC. &lt;code&gt;Math.random()&lt;/code&gt; where you needed a CSPRNG. Everyone&lt;br&gt;
reviews this layer, because it is visible in the function name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2 — the mode.&lt;/strong&gt; Primitive right, mode wrong. AES in ECB encrypts&lt;br&gt;
identical blocks to identical ciphertext — which is why the famous penguin is still&lt;br&gt;
legible after "encryption." Nothing about &lt;code&gt;aes-256-ecb&lt;/code&gt; reads as wrong at a glance; it&lt;br&gt;
contains &lt;code&gt;aes&lt;/code&gt; and &lt;code&gt;256&lt;/code&gt;, and both are reassuring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3 — the parameters.&lt;/strong&gt; Right primitive, right mode, wrong inputs. A&lt;br&gt;
static IV. An iteration count set in 2015 and never revisited. RSA where someone passed&lt;br&gt;
&lt;code&gt;RSA_PKCS1_PADDING&lt;/code&gt; explicitly — Node defaults to OAEP, so this one takes a deliberate&lt;br&gt;
argument to get wrong, which is exactly why it survives review. Each is a single value,&lt;br&gt;
usually a constant, and constants do not attract attention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 4 — the usage.&lt;/strong&gt; Everything above correct, and the surrounding code&lt;br&gt;
gives it away. Comparing an HMAC with &lt;code&gt;===&lt;/code&gt; and leaking the answer through timing.&lt;br&gt;
Deriving a key correctly, then logging it. This layer is not crypto code at all, which is&lt;br&gt;
why crypto review misses it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why layers beat a rule list
&lt;/h2&gt;

&lt;p&gt;A rule list tells you what to grep for. The layer tells you where your team is, and&lt;br&gt;
therefore what you are &lt;em&gt;not&lt;/em&gt; going to catch.&lt;/p&gt;

&lt;p&gt;My working hypothesis — and it is a hypothesis, not something I have measured — is that&lt;br&gt;
failures cluster by how much crypto attention a team has already had. Never thought about&lt;br&gt;
it: layer 1. Just audited: layer 2, because the audit said "upgrade the primitives" and it&lt;br&gt;
did exactly that. Careful for years: layers 3 and 4.&lt;/p&gt;

&lt;p&gt;If that holds, the uncomfortable corollary is that layer 3 and 4 defects live longest&lt;br&gt;
&lt;em&gt;because&lt;/em&gt; the code passes every review asking "are we using strong algorithms." The&lt;br&gt;
maturity that fixes the first two layers is the same maturity that stops looking further&lt;br&gt;
down. That is the measurement I most want from this taxonomy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detection: the easy layers are the trap
&lt;/h2&gt;

&lt;p&gt;Layers 1 and 2 live in a single call expression. &lt;code&gt;createHash("sha1")&lt;/code&gt; and &lt;code&gt;aes-256-ecb&lt;/code&gt;&lt;br&gt;
are string literals; matching them is syntactic and any linter does it well.&lt;/p&gt;

&lt;p&gt;Layers 3 and 4 are not. Whether an IV is static depends on where it came from — a&lt;br&gt;
data-flow question, the difference between&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/taint-vs-heuristic-detection?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;matching a pattern and following a value&lt;/a&gt;.&lt;br&gt;
Whether a comparison is timing-unsafe depends on whether the value is a secret, which no&lt;br&gt;
analyzer knows from the expression alone.&lt;/p&gt;

&lt;p&gt;So tool coverage is inversely correlated with how long a defect survives. The layers a&lt;br&gt;
tool covers cleanly are the ones your team already fixes. Read any claim that a tool&lt;br&gt;
"covers crypto" as "covers layers 1 and 2" until shown otherwise — and checking that needs&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/ground-truth-in-security-testing?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;a corpus with known answers&lt;/a&gt;,&lt;br&gt;
not a rule count.&lt;/p&gt;

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

&lt;p&gt;The layers map to distinct CWEs, which is the useful part —&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/cwe-taxonomy-explained?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;the taxonomy&lt;/a&gt; already encodes the&lt;br&gt;
distinction most reviews collapse:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;CWE&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 — primitive&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://ofriperetz.dev/go/r/1y07lslclcs?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;CWE-327&lt;/a&gt; risky algorithm; &lt;a href="https://ofriperetz.dev/go/r/p2oxz36hto?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;CWE-338&lt;/a&gt; weak PRNG&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 — mode&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://ofriperetz.dev/go/r/1y07lslclcs?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;CWE-327&lt;/a&gt; — ECB has no dedicated CWE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 — parameters&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://ofriperetz.dev/go/r/j214mfy26i?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;CWE-1204&lt;/a&gt; weak IV &lt;em&gt;generation&lt;/em&gt;; &lt;a href="https://ofriperetz.dev/go/r/13stfzus4ba?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;CWE-323&lt;/a&gt; &lt;em&gt;reusing&lt;/em&gt; a nonce or IV; &lt;a href="https://ofriperetz.dev/go/r/2g579rv1srr?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;CWE-916&lt;/a&gt; insufficient computational effort&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 — usage&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://ofriperetz.dev/go/r/1ppwov1rupo?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;CWE-208&lt;/a&gt; observable timing discrepancy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Filing all four as "weak crypto" is how the layer 3 and 4 instances disappear from your own&lt;br&gt;
bug data, and then from your priorities.&lt;/p&gt;

&lt;p&gt;Prior art worth reading: Egele et al., &lt;em&gt;An Empirical Study of Cryptographic Misuse in&lt;br&gt;
Android Applications&lt;/em&gt; (CCS 2013), which found the overwhelming majority of apps using&lt;br&gt;
crypto APIs got at least one layer wrong. A decade old, different platform, and the&lt;br&gt;
layering has not aged at all.&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/239k7h7sy66?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;NIST SP 800-175B&lt;/a&gt; is the reference for&lt;br&gt;
what each primitive is for, and Bernstein's cache-timing work on AES is the clearest&lt;br&gt;
statement of why layer 4 is not paranoia.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why generated code lands on layers 2 and 3
&lt;/h2&gt;

&lt;p&gt;A model asked for "AES encryption in Node" produces something that runs. Running is the&lt;br&gt;
constraint it optimizes, and every one of these layers is invisible to that constraint.&lt;br&gt;
ECB runs. A static IV runs. An iteration count of 1000 runs. The code is correct at the&lt;br&gt;
layer a reader checks, because it was trained on a corpus where that is what correct&lt;br&gt;
looked like.&lt;/p&gt;

&lt;p&gt;Same reason the human review misses it. The model read the same tutorials you did.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Which layer does your codebase fail at? The answer tends to say more about when the code&lt;br&gt;
was written than who wrote it — layer 2 usually means "audited once, years ago."&lt;/em&gt;&lt;/p&gt;




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

</description>
      <category>ai</category>
      <category>security</category>
      <category>node</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I Audited 203 of Our Own ESLint Security Rules. 16% Mislabel Their Own CVSS Score.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Tue, 28 Jul 2026 04:59:46 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score-3hgo</link>
      <guid>https://dev.to/ofri-peretz/i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score-3hgo</guid>
      <description>&lt;p&gt;Run &lt;code&gt;eslint-plugin-node-security&lt;/code&gt; against a Server-Side Request Forgery&lt;br&gt;
pattern and the terminal prints this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/fetch-avatar.ts
  4:7  warning  🔒 CWE-918 CVSS:9.1 | HTTP call whose URL argument name
              suggests user input. | LOW
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CVSS 9.1. Read the number on the calculator and it's nine tenths of a point&lt;br&gt;
from the maximum possible score a vulnerability can get. Read the word next&lt;br&gt;
to it and it says &lt;strong&gt;LOW&lt;/strong&gt;. Those two facts are printed four characters apart&lt;br&gt;
on the same line, by the same rule, and they disagree with each other.&lt;/p&gt;

&lt;p&gt;I write the CWE/CVSS/OWASP metadata that ships in every one of our lint&lt;br&gt;
messages — it's meant to be the thing that tells you, at a glance, how&lt;br&gt;
worried to be. So I went and checked whether it actually does that. I wrote&lt;br&gt;
a 25-line script that reads every rule in the Interlace ESLint ecosystem,&lt;br&gt;
pulls its CVSS score and its shipped severity label, and checks whether the&lt;br&gt;
label matches the official band the score falls in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;33 of 203 rules — 16% — don't.&lt;/strong&gt; Some of those are defensible once you&lt;br&gt;
know why. Some aren't. All of them are a good excuse to explain what CWE,&lt;br&gt;
CVSS, and OWASP actually measure, because the gap between them is exactly&lt;br&gt;
where lint output stops being trustworthy and starts being decoration.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the three labels actually measure
&lt;/h2&gt;

&lt;p&gt;A single line of our lint output packs in three unrelated standards. Quick&lt;br&gt;
definitions — there's a full reference for each if you need it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://ofriperetz.dev/go/cwe-taxonomy-explained?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;CWE&lt;/a&gt;&lt;/strong&gt; names &lt;em&gt;what kind&lt;/em&gt; of bug it is.
&lt;code&gt;CWE-918&lt;/code&gt; is SSRF; &lt;code&gt;CWE-287&lt;/code&gt; is Improper Authentication. A category, not a
verdict.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://ofriperetz.dev/go/cvss-scores-explained?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;CVSS&lt;/a&gt;&lt;/strong&gt; is a
&lt;em&gt;computed&lt;/em&gt; 0.0–10.0 score. Its four bands are the ruler this audit uses:
0.1–3.9 Low, 4.0–6.9 Medium, 7.0–8.9 High, 9.0–10.0 Critical. The number is
the primary source; the band name is derived from it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://ofriperetz.dev/go/owasp-top-10-explained?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;OWASP Top 10&lt;/a&gt;&lt;/strong&gt; is a &lt;em&gt;category&lt;/em&gt; bucket
(A01–A10) — an address, not a severity measurement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only CVSS is actually a severity measurement. So when a lint rule prints a&lt;br&gt;
severity word, that word has exactly one legitimate source: the CVSS score&lt;br&gt;
next to it. If they disagree, the word is wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  Case 1: a 9.1 that prints LOW — and the honest reason it might be fine
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;no-ssrf&lt;/code&gt; flags HTTP calls where the URL argument's &lt;em&gt;name&lt;/em&gt; looks&lt;br&gt;
user-supplied (&lt;code&gt;userUrl&lt;/code&gt;, &lt;code&gt;req.query.endpoint&lt;/code&gt;, &lt;code&gt;targetUri&lt;/code&gt;) — a classic&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/69k202sr3e?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;Server-Side Request Forgery&lt;/a&gt;&lt;br&gt;
setup, the bug class behind more than one well-known cloud metadata-endpoint&lt;br&gt;
breach:&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;// flagged — the *name* suggests user input, not a data-flow trace&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchAvatar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userUrl&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="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule's own metadata: &lt;code&gt;cwe: 'CWE-918'&lt;/code&gt;, &lt;code&gt;cvss: 9.1&lt;/code&gt; — squarely in the&lt;br&gt;
Critical band. The message it prints: &lt;code&gt;severity: 'LOW'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's the part that's genuinely defensible: the rule's own doc comment&lt;br&gt;
calls itself out as &lt;strong&gt;"a naming heuristic, not data-flow analysis."&lt;/strong&gt; It&lt;br&gt;
matches on identifier names, not on whether the value actually reaches an&lt;br&gt;
attacker-influenced source. That's a real limitation — plenty of&lt;br&gt;
&lt;code&gt;userUrl&lt;/code&gt;-named parameters are perfectly safe, admin-configured constants.&lt;br&gt;
A low-confidence match that could be a&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/confusion-matrix-tp-fp-fn-tn?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;false positive&lt;/a&gt;&lt;br&gt;
&lt;em&gt;does&lt;/em&gt; deserve a lower-urgency label than a rule that traced the actual&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/taint-vs-heuristic-detection?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;taint&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The part that isn't defensible: &lt;strong&gt;confidence and impact are two different&lt;br&gt;
axes, and &lt;code&gt;severity&lt;/code&gt; is only supposed to encode one of them.&lt;/strong&gt; "How sure are&lt;br&gt;
we this is real" and "how bad is it if it's real" are independent&lt;br&gt;
questions — a low-confidence match on a 9.1-impact bug is still worth&lt;br&gt;
&lt;em&gt;more&lt;/em&gt; attention than a high-confidence match on a 3.0. Collapsing both into&lt;br&gt;
one &lt;code&gt;LOW&lt;/code&gt;/&lt;code&gt;MEDIUM&lt;/code&gt;/&lt;code&gt;HIGH&lt;/code&gt;/&lt;code&gt;CRITICAL&lt;/code&gt; string means the reader can't tell&lt;br&gt;
which axis they're looking at, and a &lt;code&gt;grep&lt;/code&gt;-based triage pass (or an&lt;br&gt;
LLM agent doing the same) will deprioritize a Critical-impact finding on the&lt;br&gt;
strength of a label that was only ever describing detection confidence.&lt;/p&gt;
&lt;h2&gt;
  
  
  Case 2: a 9.8 with no heuristic excuse at all
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;require-audience-validation&lt;/code&gt; and &lt;code&gt;require-issuer-validation&lt;/code&gt; aren't naming&lt;br&gt;
heuristics — they check one deterministic fact: does a &lt;code&gt;jwt.verify()&lt;/code&gt; call&lt;br&gt;
pass an &lt;code&gt;audience&lt;/code&gt; (or &lt;code&gt;issuer&lt;/code&gt;) option? Yes or no, no ambiguity:&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;// flagged — no audience claim checked&lt;/span&gt;
&lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// not flagged&lt;/span&gt;
&lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skip audience validation and a JWT minted for &lt;em&gt;any&lt;/em&gt; service that trusts the&lt;br&gt;
same signing key gets accepted by &lt;em&gt;this&lt;/em&gt; service too — a textbook confused&lt;br&gt;
deputy, &lt;a href="https://ofriperetz.dev/go/r/j2k6t47hqz?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;CWE-287&lt;/a&gt;,&lt;br&gt;
&lt;code&gt;cvss: 9.8&lt;/code&gt;. Same story on the issuer side. Both rules print &lt;code&gt;severity:&lt;br&gt;
'MEDIUM'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There's no false-positive story to hide behind here — the check is exact.&lt;br&gt;
9.8 is a hair under the maximum a CVSS score can reach. &lt;code&gt;MEDIUM&lt;/code&gt; sits two&lt;br&gt;
whole bands below where that number lives. If a reader — or a CI gate that&lt;br&gt;
filters on severity string instead of CVSS number — treats &lt;code&gt;MEDIUM&lt;/code&gt; findings&lt;br&gt;
as "fix this sprint," a near-maximum-severity auth bypass just got queued&lt;br&gt;
behind a color contrast warning.&lt;/p&gt;
&lt;h2&gt;
  
  
  Case 3: the one that runs the other way
&lt;/h2&gt;

&lt;p&gt;Not every mismatch under-states. &lt;a href="https://ofriperetz.dev/go/searchpath-hijacking-postgresql-attack?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;&lt;code&gt;no-unsafe-search-path&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
— the PostgreSQL &lt;code&gt;search_path&lt;/code&gt; hijacking rule I wrote up in full elsewhere —&lt;br&gt;
ships &lt;code&gt;cvss: 7.5&lt;/code&gt; (High band) but prints &lt;code&gt;severity: 'CRITICAL'&lt;/code&gt;, one band&lt;br&gt;
&lt;em&gt;above&lt;/em&gt; its own score. I won't re-run the attack walkthrough here — the&lt;br&gt;
short version, if a label disagreeing with a number bothers you as much as&lt;br&gt;
it bothers me: read that piece for the exploit, come back here for why the&lt;br&gt;
label drifted in the first place.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this happens: &lt;code&gt;severity&lt;/code&gt; is the one field nobody enriches
&lt;/h2&gt;

&lt;p&gt;Every rule funnels its message through one formatter,&lt;br&gt;
&lt;code&gt;formatLLMMessage()&lt;/code&gt;, and every formatter call goes through the same&lt;br&gt;
enrichment step first:&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;// packages/eslint-devkit/src/messaging/formatters.ts&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;enrichFromCWE&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="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="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cwe&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;options&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;cweData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;CWE_MAPPING&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="nx"&gt;cwe&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="nx"&gt;cweData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="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="na"&gt;owasp&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="nx"&gt;owasp&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;cweData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;owasp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;cvss&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="nx"&gt;cvss&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;cweData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cvss&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;compliance&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="nx"&gt;compliance&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;CWE_COMPLIANCE_MAPPING&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="nx"&gt;cwe&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;There's a canonical lookup table, &lt;code&gt;CWE_MAPPING&lt;/code&gt;, and it's &lt;em&gt;correct&lt;/em&gt; — its&lt;br&gt;
entry for &lt;code&gt;CWE-918&lt;/code&gt; is &lt;code&gt;{ cvss: 9.1, severity: 'CRITICAL' }&lt;/code&gt;, internally&lt;br&gt;
consistent, right band. &lt;code&gt;CWE-287&lt;/code&gt; maps to &lt;code&gt;{ cvss: 9.8, severity:&lt;br&gt;
'CRITICAL' }&lt;/code&gt;. If every rule simply deferred to this table, none of the 33&lt;br&gt;
mismatches would exist.&lt;/p&gt;

&lt;p&gt;But look at what &lt;code&gt;enrichFromCWE&lt;/code&gt; actually forwards: &lt;code&gt;owasp&lt;/code&gt;, &lt;code&gt;cvss&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;compliance&lt;/code&gt; — and that's the whole list. &lt;strong&gt;&lt;code&gt;severity&lt;/code&gt; isn't in it.&lt;/strong&gt; Every&lt;br&gt;
&lt;code&gt;cvss&lt;/code&gt; number in our output either comes from this vetted table or from an&lt;br&gt;
explicit override that a human cross-checked against it. Every &lt;code&gt;severity&lt;/code&gt;&lt;br&gt;
string is whatever word the rule's author typed into that one call site,&lt;br&gt;
with nothing checking it against the table, the CVSS number, or any other&lt;br&gt;
rule's choice for the same CWE. Two rules can share a CWE, share a CVSS&lt;br&gt;
score, and print two different severity words, and nothing in the pipeline&lt;br&gt;
would ever notice.&lt;/p&gt;

&lt;p&gt;That's the actual bug: not "someone mislabeled 33 rules," but "the schema&lt;br&gt;
has a field with no source of truth." Free-text fields drift; that's what&lt;br&gt;
they do when nothing enriches them. The fix is the same one-line pattern&lt;br&gt;
already applied to &lt;code&gt;owasp&lt;/code&gt; and &lt;code&gt;cvss&lt;/code&gt; — default &lt;code&gt;severity&lt;/code&gt; from&lt;br&gt;
&lt;code&gt;CWE_MAPPING[cwe].severity&lt;/code&gt; unless a rule explicitly overrides it, the same&lt;br&gt;
&lt;code&gt;options.severity ?? cweData.severity&lt;/code&gt; fallback the other three fields&lt;br&gt;
already get. I've filed it; it hasn't shipped as of this writing, and I'd&lt;br&gt;
rather say that than quietly imply it's fixed.&lt;/p&gt;
&lt;h2&gt;
  
  
  The audit, if you want to run it yourself
&lt;/h2&gt;

&lt;p&gt;Point this at any rule set that ships CVSS numbers and severity strings —&lt;br&gt;
ours included — and it'll tell you exactly where they disagree:&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;// audit-severity-drift.ts — flags rules where severity doesn't match its own CVSS band&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="nx"&gt;globSync&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BANDS&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="mf"&gt;9.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;10.01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CRITICAL&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="mf"&gt;7.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;9.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HIGH&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="mf"&gt;4.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;7.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;MEDIUM&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="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;4.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LOW&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&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;bandFor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cvss&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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;BANDS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hi&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;cvss&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;cvss&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;globSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;packages/eslint-plugin-*/src/rules/*/index.ts&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&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;utf-8&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;cvss&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/cvss:&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;([\d&lt;/span&gt;&lt;span class="sr"&gt;.&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;severity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/severity:&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*'&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;A-Z&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;'/&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;cvss&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bandFor&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;cvss&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;expected&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;severity&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="s2"&gt;`&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;: CVSS &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cvss&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;expected&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, shipped &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;severity&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;203 rule files scanned, 33 flagged — an 84% clean rate I'd rather publish&lt;br&gt;
honestly than round up. And that rate is itself a snapshot: the rule sources&lt;br&gt;
as they stood when this published (2026-07-06), spread across the ecosystem's&lt;br&gt;
twenty-odd independently-versioned plugins, not one pinned release. The&lt;br&gt;
article's own thesis applies to its own numbers — a printed audit is a cache&lt;br&gt;
too. Re-run the script against today's checkout and trust that count over&lt;br&gt;
mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading a severity label like a security engineer, not a triage bot
&lt;/h2&gt;

&lt;p&gt;None of this is specific to our plugins — it generalizes to any tool that&lt;br&gt;
prints a severity word next to a CVSS number, including the ones you didn't&lt;br&gt;
write:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The number is the primary source. The word is a cache of it, and
caches go stale.&lt;/strong&gt; If a &lt;code&gt;CRITICAL&lt;/code&gt;/&lt;code&gt;HIGH&lt;/code&gt;/&lt;code&gt;MEDIUM&lt;/code&gt;/&lt;code&gt;LOW&lt;/code&gt; string and a
CVSS score disagree, trust the score — it's the one with a formula
behind it, not a human's word choice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CWE tells you the shape of the bug, not how loudly to worry.&lt;/strong&gt; "This is
a &lt;code&gt;CWE-918&lt;/code&gt; (SSRF)" is a fact about the code. It says nothing about
&lt;em&gt;this instance's&lt;/em&gt; severity until a CVSS vector is computed for it
specifically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A low-confidence detector and a low-impact bug produce the same label
and mean opposite things.&lt;/strong&gt; If a tool's docs mention "heuristic" anywhere
near a &lt;code&gt;LOW&lt;/code&gt;, ask whether &lt;code&gt;LOW&lt;/code&gt; is describing the bug or the detector's
certainty — they're rarely the same axis, and only one of them tells you
whether to actually go read the code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't build a CI gate on the adjective.&lt;/strong&gt; &lt;code&gt;severity !== 'CRITICAL'&lt;/code&gt;
as a merge-blocking filter inherits every mislabel in the tool it's
reading from. Gate on the CVSS number, or on the CWE list you've decided
matters, not on a string a human typed by hand.&lt;/li&gt;
&lt;/ol&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-node-security eslint-plugin-jwt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;nodeSecurity&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-node-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;import&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eslint-plugin-jwt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="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="nx"&gt;nodeSecurity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;configs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recommended&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;configs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recommended&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both plugins print the CWE/CVSS/OWASP line on every finding — that's what&lt;br&gt;
made this audit possible to run against our own output in the first place.&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/getting-started-eslint-plugin-node-security?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;Getting started with eslint-plugin-node-security&lt;/a&gt;&lt;br&gt;
and &lt;a href="https://ofriperetz.dev/go/getting-started-eslint-plugin-jwt?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;getting started with eslint-plugin-jwt&lt;/a&gt;&lt;br&gt;
cover the rest of what each ships.&lt;/p&gt;




&lt;p&gt;I'm not writing this to talk our own numbers down — 84% of 203 rules&lt;br&gt;
printing a severity word that matches their own CVSS band is a real bar,&lt;br&gt;
and I'd take it over a tool that doesn't print a CVSS number at all and so&lt;br&gt;
never gets caught disagreeing with itself. I'm writing it because "check&lt;br&gt;
the number, not the adjective" is a rule I only started following rigorously&lt;br&gt;
&lt;em&gt;after&lt;/em&gt; writing the script that caught my own tool getting it wrong three&lt;br&gt;
different ways in three different rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your turn:&lt;/strong&gt; open whatever security linter you already run, pick one&lt;br&gt;
finding, and look up its CVSS vector by hand. If the vector notation is new,&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/cvss-scores-explained?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;what a CVSS score actually measures&lt;/a&gt;&lt;br&gt;
walks the AV/AC/PR/UI fields — and why severity, exploit probability, and&lt;br&gt;
confirmed exploitation are three separate measurements with three separate&lt;br&gt;
owners. Does the severity word next to it still hold up once you've done the&lt;br&gt;
math yourself?&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=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;⭐ Star on GitHub if you've ever trusted a severity label more than the score behind it.&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;I'm &lt;strong&gt;Ofri Peretz&lt;/strong&gt;, a security engineering leader and the author of the&lt;br&gt;
Interlace ESLint ecosystem — domain-specific static analysis for security,&lt;br&gt;
reliability, and performance on the Node.js stack.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://ofriperetz.dev/go/r/jp3wgc57f7?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/1dhos3yly5w?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/lhohj885s7?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>node</category>
      <category>devsecops</category>
      <category>eslint</category>
    </item>
    <item>
      <title>142,076 Weekly Downloads. Zero Releases Since 2021. Is the Niche Defended?</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Mon, 20 Jul 2026 04:45:01 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/142076-weekly-downloads-zero-releases-since-2021-is-the-niche-defended-4hko</link>
      <guid>https://dev.to/ofri-peretz/142076-weekly-downloads-zero-releases-since-2021-is-the-niche-defended-4hko</guid>
      <description>&lt;p&gt;Two reads from the npm registry, same package, same morning (2026-07-19):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;downloads, last week:  142,076
last publish:          2021-05-21  (v3.2.0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;serverless-iam-roles-per-function&lt;/code&gt; is one of the Serverless Framework ecosystem's most-installed plugins, and it hasn't shipped a release since May 2021. Eleven weeks earlier: 118,761 weekly downloads (measured 2026-05-04). Zero releases in between; 23,315 weekly installs gained.&lt;/p&gt;

&lt;p&gt;The package isn't surviving abandonment — its installed base is growing straight through it.&lt;/p&gt;

&lt;p&gt;My first read was the common one: "142k a week, the niche is taken, I'm five years late." That's the misconception this article exists to correct. &lt;strong&gt;High downloads don't mean a niche is taken. Downloads measure the installed base; maintenance decay measures whether the niche is defended.&lt;/strong&gt; The gap between those signals is where I decide what to build next, using what I call the &lt;strong&gt;incumbent map&lt;/strong&gt;. Fair warning: I ran the arithmetic in May, shipped against its #1 row on 2026-05-07, and only ran the filter that kills that row while fact-checking this article in July. The method works; I ran half of it. Both halves are below, in the order I should have run them.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you build an incumbent map?
&lt;/h2&gt;

&lt;p&gt;Three signals per package, all public, all re-runnable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Weekly downloads — demand.&lt;/strong&gt; The installed base a maintained replacement would serve. Downloads are a &lt;a href="https://ofriperetz.dev/go/proxy-metrics?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;proxy metric&lt;/a&gt; — npm's own account of download counts includes build servers, mirrors, and robots, so the number tracks lockfile momentum, not humans choosing a package. As a &lt;em&gt;relative&lt;/em&gt; ranking of where demand pools inside one ecosystem, though, it's the best public signal there is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Years since last publish — supply-side decay.&lt;/strong&gt; Read the registry at &lt;code&gt;time[&amp;lt;latest-version&amp;gt;]&lt;/code&gt;. Not &lt;code&gt;time.modified&lt;/code&gt; — that field moves when npm touches registry metadata: &lt;code&gt;serverless-iam-roles-per-function&lt;/code&gt; shows &lt;code&gt;modified: 2022-05-17&lt;/code&gt;, a full year after its last release. Trust the version's own timestamp, nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open-issue decay — unmet demand.&lt;/strong&gt; No clean number here, so make the procedure explicit: sort open issues by reactions, list the recurring asks, date the last maintainer reply. For the plugin above: managed-policy attachment and custom role naming — asked for years across multiple issues, no maintainer commitment (the 2018 managed-policy ask drew "open for PRs"; nothing shipped since). And read the replies, not just titles; mine matter later.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first two signals, as commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://api.npmjs.org/downloads/point/last-week/&amp;lt;pkg&amp;gt;               &lt;span class="c"&gt;# demand&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://registry.npmjs.org/&amp;lt;pkg&amp;gt; | jq &lt;span class="s1"&gt;'.time[."dist-tags".latest]'&lt;/span&gt;  &lt;span class="c"&gt;# decay&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multiply, roughly: demand × decay × unanswered asks, ranked descending. Deliberately not a precise formula — a shortlist generator whose output you still have to judge.&lt;/p&gt;

&lt;p&gt;The closest prior art is &lt;a href="https://ofriperetz.dev/go/r/13873q2ynqh?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;OpenSSF Scorecard&lt;/a&gt;: its &lt;code&gt;Maintained&lt;/code&gt; check formalizes the same "is anyone home?" signal for the dependencies you &lt;em&gt;consume&lt;/em&gt; — a risk lens. The incumbent map points it the other way: not "should I trust this package?" but "should I compete with it?" I haven't seen that build-side version written down anywhere — a strong claim, and an open invitation to correct me.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the serverless incumbent map actually show?
&lt;/h2&gt;

&lt;p&gt;The worked example that produced my roadmap. Two zones, all numbers measured 2026-07-19:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Defended — active maintainers, do not compete on maintenance:&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;Package&lt;/th&gt;
&lt;th&gt;Downloads/wk&lt;/th&gt;
&lt;th&gt;Last publish&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverless-offline&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;627,578&lt;/td&gt;
&lt;td&gt;2026-06-23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverless-esbuild&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;388,619&lt;/td&gt;
&lt;td&gt;2026-05-21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverless-domain-manager&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;304,882&lt;/td&gt;
&lt;td&gt;2026-07-17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverless-step-functions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;208,865&lt;/td&gt;
&lt;td&gt;2026-07-13&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Abandoned — demand present, nobody home:&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;Package&lt;/th&gt;
&lt;th&gt;Downloads/wk&lt;/th&gt;
&lt;th&gt;Last publish&lt;/th&gt;
&lt;th&gt;Silence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverless-iam-roles-per-function&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;142,076&lt;/td&gt;
&lt;td&gt;2021-05-21&lt;/td&gt;
&lt;td&gt;5.2 yrs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverless-plugin-include-dependencies&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;52,500&lt;/td&gt;
&lt;td&gt;2024-07-22&lt;/td&gt;
&lt;td&gt;2.0 yrs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverless-plugin-common-excludes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;51,415&lt;/td&gt;
&lt;td&gt;2021-07-06&lt;/td&gt;
&lt;td&gt;5.0 yrs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverless-api-gateway-throttling&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;36,528&lt;/td&gt;
&lt;td&gt;2023-03-19&lt;/td&gt;
&lt;td&gt;3.3 yrs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverless-plugin-tracing&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;35,709&lt;/td&gt;
&lt;td&gt;2017-10-13&lt;/td&gt;
&lt;td&gt;8.8 yrs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverless-plugin-canary-deployments&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;31,562&lt;/td&gt;
&lt;td&gt;2022-04-11&lt;/td&gt;
&lt;td&gt;4.3 yrs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverless-associate-waf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;28,787&lt;/td&gt;
&lt;td&gt;2020-04-17&lt;/td&gt;
&lt;td&gt;6.3 yrs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The top zone shipped within two months. The bottom zone's median silence: five years — on packages still installed tens of thousands of times a week.&lt;/p&gt;

&lt;p&gt;The map moves, in both directions. &lt;code&gt;serverless-associate-waf&lt;/code&gt; shows demand finally decaying — down roughly a quarter from my May reading — so abandonment does reach the download number, on a lag of years, not months. And re-pulling before publishing deleted a row: &lt;code&gt;serverless-openapi-documenter&lt;/code&gt;, filed in my May notes as a stale documentation-plugin niche, shipped a release on 2026-06-24. Someone re-defended it while I drafted. Row gone. An incumbent map is a snapshot, not a subscription — re-run it the week you commit, not the quarter you planned.&lt;/p&gt;

&lt;h2&gt;
  
  
  When is a quiet package finished instead of dead?
&lt;/h2&gt;

&lt;p&gt;The honest counter-case, because the map's arithmetic will lie to you twice.&lt;/p&gt;

&lt;p&gt;First: &lt;strong&gt;stable is not dead.&lt;/strong&gt; &lt;code&gt;serverless-prune-plugin&lt;/code&gt; sits at 414,154 downloads a week (measured 2026-07-19), last publish 2024-10-16. Twenty-one quiet months — and it may simply be done: its job is narrow, its surface barely moves. The separating question: &lt;strong&gt;does the surface underneath the package move?&lt;/strong&gt; IAM — the ground &lt;code&gt;serverless-iam-roles-per-function&lt;/code&gt; stands on — moves constantly: new AWS services, new actions, new policy patterns. Five silent years on a moving surface is decay; on a frozen surface, it can be completion. Same silence, opposite meanings — a classic &lt;a href="https://ofriperetz.dev/go/bias-in-measurement?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;measurement-bias&lt;/a&gt; trap.&lt;/p&gt;

&lt;p&gt;Second: &lt;strong&gt;platform absorption.&lt;/strong&gt; &lt;code&gt;serverless-plugin-typescript&lt;/code&gt;: 151,586 downloads a week (measured 2026-07-19), dormant since 2023-06-05 — by raw arithmetic the juiciest target on the board. It's not on my list: Serverless Framework v4 builds TypeScript natively and won't run build plugins like it unless you explicitly opt out of the native build. That niche isn't undefended; it's dissolving. A replacement would be competing with the platform itself.&lt;/p&gt;

&lt;p&gt;An installed base looks like a moat. The publish log says whether anyone still defends it; the platform roadmap, whether the castle is even staying put.&lt;/p&gt;

&lt;h2&gt;
  
  
  How were the replacement targets actually picked?
&lt;/h2&gt;

&lt;p&gt;The full pipeline, as actually run for the serverless line:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Rank&lt;/strong&gt; every community plugin by demand × decay (the tables above are the output).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter: is the niche still real?&lt;/strong&gt; — &lt;code&gt;serverless-plugin-typescript&lt;/code&gt; fell here first. Then the same filter, run against the current v4 docs instead of my stale notes, reached the top row of my map: v4 ships per-function IAM roles natively — provider-statement inheritance on by default, log/VPC/event-source permissions auto-added, the plugin's own config fields accepted for migration. The &lt;a href="https://ofriperetz.dev/go/r/123glrpu8vs?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;official IAM guide&lt;/a&gt; credits the plugin for pioneering the feature and says you can now safely remove it. My #1 target wasn't an undefended niche; it was one the platform had absorbed — with honors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter: is the incumbent truly undefended?&lt;/strong&gt; — re-pull the registry the week you commit; that check removed &lt;code&gt;serverless-openapi-documenter&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter: is the unmet demand documented in public — and still unmet?&lt;/strong&gt; The newest open issue asks "Serverless Framework V4 Support?" — and the thread answers itself: a collaborator's "it works," then a comment pointing at the v4 docs above. The two features its tracker spent years asking for — managed policies, custom role names — shipped in v4's native implementation, not the plugin's. The counter-evidence to my top pick sat in the tracker I was reading for demand. Read the replies.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The worked example deleted two of its own rows — one while I drafted, one while I fact-checked. Here is what the second deletion cost, stated plainly: my May map scored &lt;code&gt;serverless-iam-roles-per-function&lt;/code&gt; 23/25 — "the biggest single-plugin opportunity in the portfolio" — and I shipped &lt;code&gt;@interlace/serverless-iam-roles-per-function&lt;/code&gt; 1.0.0 off that row on 2026-05-07 (the npm publish date; check it). The filter you just read ran two months later. The package stays published and does what it says; what it is not, anymore, is the line's growth bet — v4 owns that niche now. The ranked ROI analysis lives in the repo, unchanged, dates included — deliberately, as the before-artifact: you can see exactly the map I built from, and exactly what running filter 2 late overturned. Arithmetic generates the shortlist; filters, run against primary sources — &lt;em&gt;before you build&lt;/em&gt; — make the decision.&lt;/p&gt;

&lt;p&gt;Notice the conflict of interest: the person selling replacements is grading the incumbents — the same conflict as &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=abandoned-incumbent-map" rel="noopener noreferrer"&gt;building what I benchmark&lt;/a&gt;, with the same mitigation: every number here is a public registry read, stamped with its measurement date, re-runnable in one &lt;code&gt;curl&lt;/code&gt; without me in the loop.&lt;/p&gt;

&lt;p&gt;The map is step one of entering an ecosystem. Step two keeps the building honest — write down claims and evidence &lt;em&gt;before&lt;/em&gt; the marketing exists: &lt;a href="https://ofriperetz.dev/go/claims-registry-evidence-framework?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;the claims registry&lt;/a&gt;. Different &lt;a href="https://ofriperetz.dev/go/different-metrics-for-different-package-types?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;package types need different metrics&lt;/a&gt; once you measure what you built — but the map is how you decide to build at all.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What's the most-downloaded abandoned package in your ecosystem — React components? Terraform providers? Gradle plugins? Two registry reads will tell you. I'm collecting incumbent maps, and I'd genuinely like to see yours.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;What it measures&lt;/th&gt;
&lt;th&gt;Where to read it&lt;/th&gt;
&lt;th&gt;Failure mode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Weekly downloads&lt;/td&gt;
&lt;td&gt;Installed base (demand) — not adoption, not health&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api.npmjs.org/downloads/point/last-week/&amp;lt;pkg&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Mistaking base for defense; CI/mirror inflation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Years since last publish&lt;/td&gt;
&lt;td&gt;Supply-side decay&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;registry.npmjs.org/&amp;lt;pkg&amp;gt;&lt;/code&gt; → &lt;code&gt;time[&amp;lt;latest-version&amp;gt;]&lt;/code&gt; — &lt;strong&gt;not&lt;/strong&gt; &lt;code&gt;time.modified&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Finished ≠ dead on a frozen surface&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open-issue decay&lt;/td&gt;
&lt;td&gt;Unmet demand, stated in public&lt;/td&gt;
&lt;td&gt;Issue tracker: sort by reactions, date the last maintainer reply&lt;/td&gt;
&lt;td&gt;Reading titles, not replies — the answer may be "the platform shipped it"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deprecation/archive status + platform roadmap&lt;/td&gt;
&lt;td&gt;Whether the niche still exists&lt;/td&gt;
&lt;td&gt;Registry &lt;code&gt;deprecated&lt;/code&gt; field, repo header, framework changelog + docs&lt;/td&gt;
&lt;td&gt;High downloads on a dissolving niche&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decision rule&lt;/td&gt;
&lt;td&gt;demand × decay × unanswered asks → shortlist → 3 filters&lt;/td&gt;
&lt;td&gt;This article&lt;/td&gt;
&lt;td&gt;It's a snapshot — re-run the week you commit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/r/8e2ficgx9r?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;"numeric precision matters: how npm download counts work"&lt;/a&gt; (npm blog, 2014) — npm's own account of what a download includes (build servers, mirrors, robots); the primary source for why downloads can only ever be a demand proxy.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/r/2c14snsbept?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;npm registry download-counts documentation&lt;/a&gt; — the API the map reads: the endpoints and the data pipeline behind the counts.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/r/123glrpu8vs?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;Serverless Framework IAM guide&lt;/a&gt; — the platform-absorption primary source: v4's native per-function roles, statement inheritance, auto-added permissions, and the credit to the community plugin it replaces.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/r/13873q2ynqh?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;OpenSSF Scorecard&lt;/a&gt; — the closest prior art: its &lt;code&gt;Maintained&lt;/code&gt; check operationalizes "is anyone home?" for consumption-side dependency risk; the incumbent map reuses that signal for build-side decisions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related deep dives
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/proxy-metrics?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;Proxy Metrics: When the Number Isn't the Thing&lt;/a&gt; — why downloads, stars, and every other convenient number measure something adjacent to what you care about&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/bias-in-measurement?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;Bias in Measurement&lt;/a&gt; — how a real number still produces a wrong inference, including the too-clean-zero forensic&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/claims-registry-evidence-framework?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;The Claims Registry&lt;/a&gt; — step two of the playbook: the evidence discipline that comes before any claim&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/different-metrics-for-different-package-types?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;Different Metrics for Different Package Types&lt;/a&gt; — once you build, how the measuring has to change per package type&lt;/li&gt;
&lt;li&gt;
&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=abandoned-incumbent-map" rel="noopener noreferrer"&gt;I Built What I Benchmark. Here's How I Try Not to Cheat.&lt;/a&gt; — the conflict-of-interest process this article's mitigation borrows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://github.com/ofri-peretz/serverless" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Star the serverless repo — the map above is its roadmap&lt;/a&gt;
&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the &lt;a href="https://ofriperetz.dev/go/r/8v7joo8tnv?utm_source=devto&amp;amp;from=abandoned-incumbent-map" 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=abandoned-incumbent-map" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · npm: &lt;a href="https://ofriperetz.dev/go/r/29m8v07vw3v?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;@interlace&lt;/a&gt; · Follow: &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;Dev.to/ofri-peretz&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/jp3wgc57f7?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>serverless</category>
      <category>aws</category>
      <category>opensource</category>
      <category>node</category>
    </item>
    <item>
      <title>We Were Wrong About sls remove</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Mon, 20 Jul 2026 04:44:23 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/we-were-wrong-about-sls-remove-keo</link>
      <guid>https://dev.to/ofri-peretz/we-were-wrong-about-sls-remove-keo</guid>
      <description>&lt;p&gt;The loudest claim in my serverless line was wrong. Early copy for &lt;code&gt;@interlace/serverless-api-gateway-caching&lt;/code&gt; implied that the community caching plugin leaves an API Gateway cache cluster running — and billing — after &lt;code&gt;sls remove&lt;/code&gt;, and that our cleanup hook fixes it. On 2026-05-04, our own E2E harness ran the community plugin through &lt;code&gt;sls remove&lt;/code&gt; on live AWS: 31 seconds, exit 0, no orphans. CloudFormation deleted the stage and took the cache cluster with it. There is no &lt;code&gt;sls remove&lt;/code&gt; ghost-billing bug.&lt;/p&gt;

&lt;p&gt;The trap itself is real — I had reproduced it on live AWS the day before, 2026-05-03, in about ten minutes for roughly $0.005 of cache-cluster time. It just lives on a different path than the one we were loudest about. This article is the full telling of that re-scoping: the original claim, the two runs that killed and rebuilt it, and why the narrower claim that replaced it does more work than the broad one ever did.&lt;/p&gt;




&lt;h2&gt;
  
  
  What was the original claim?
&lt;/h2&gt;

&lt;p&gt;The community &lt;code&gt;serverless-api-gateway-caching&lt;/code&gt; (v1.11.0) manages the API Gateway cache cluster imperatively — it calls the AWS &lt;code&gt;UpdateStage&lt;/code&gt; API during deploy instead of declaring the cluster in the CloudFormation template. CloudFormation never learns the cluster exists. From there, the inference writes itself: if CloudFormation doesn't know about the cluster, teardown won't remove it, so &lt;code&gt;sls remove&lt;/code&gt; must leave it behind, billing forever. Our plugin ships a &lt;code&gt;before:remove:remove&lt;/code&gt; hook that disables the cluster before teardown — so early copy implied, in effect: &lt;em&gt;the community plugin has a ghost-billing bug on &lt;code&gt;sls remove&lt;/code&gt;, and we fix it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Notice what that claim actually was: an inference from architecture, shipped as if it were a measurement. And it was consistent — repeated the same way across the README, the docs, the comparison table. Consistency is reliability; whether the claim describes the failure that actually happens is &lt;a href="https://ofriperetz.dev/go/valid-vs-reliable-metrics?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;validity&lt;/a&gt; — and nobody had checked.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did the measurement actually show?
&lt;/h2&gt;

&lt;p&gt;Two runs on live AWS, one day apart, both against the pinned community release &lt;code&gt;1.11.0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2026-05-03 — the trap, reproduced.&lt;/strong&gt; Deploy with the community plugin (a live stack in 49s), wait for the cache cluster to reach &lt;code&gt;AVAILABLE&lt;/code&gt; (4–7 minutes), then do what a real team does when it stops wanting a plugin: delete it from the &lt;code&gt;plugins&lt;/code&gt; array and redeploy the still-running service. The redeploy succeeds in ~28 seconds and &lt;strong&gt;no plugin code runs&lt;/strong&gt; — the plugin is gone from the config, so it can't. The cluster afterward: &lt;code&gt;enabled: true, status: AVAILABLE, size: 0.5&lt;/code&gt;. Still running, still billing. No CloudFormation event, no log line, no warning. And no exit through the plugin either: &lt;code&gt;sls caching disable&lt;/code&gt; and &lt;code&gt;sls caching status&lt;/code&gt; both return "command not found" — the community plugin registers no custom commands, so the only escape is dropping to the AWS CLI and patching &lt;code&gt;cacheClusterEnabled&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt; by hand. Total cost of watching all of this happen: ~10 minutes, ~$0.005 — the recipe is public in &lt;a href="https://ofriperetz.dev/go/r/11jasy64k1j?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;&lt;code&gt;docs/ghost-billing-reproduction.md&lt;/code&gt;&lt;/a&gt;, cheap enough that &lt;a href="https://ofriperetz.dev/go/reproducibility-vs-replicability?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;"it reproduced on my machine"&lt;/a&gt; can be your finding rather than my assurance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2026-05-04 — the loud path, refuted.&lt;/strong&gt; The community E2E then ran the exact path our copy warned about: &lt;code&gt;sls remove&lt;/code&gt; with the plugin still installed. Clean. 31 seconds, exit 0, no orphans — CloudFormation deleted the stage, and the cluster went with it.&lt;/p&gt;

&lt;p&gt;The mechanism fits in one sentence: the cache cluster rides on the stage. Delete the stage — which is what &lt;code&gt;sls remove&lt;/code&gt; does — and the cluster dies with the stack, no matter who created it. Keep the service deployed and remove only the plugin, and the stage stays up while the one tool that remembered the cluster leaves your toolchain.&lt;/p&gt;

&lt;p&gt;I ran that second E2E to collect the evidence &lt;em&gt;for&lt;/em&gt; the claim. It came back with the evidence against it — exit 0, no orphans, and a rewrite of my best line of copy. The same discipline that makes you distrust a &lt;a href="https://ofriperetz.dev/go/bias-in-measurement?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;result that flatters you&lt;/a&gt; is the one that makes you run the test that can defeat you, before someone else does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the re-scoped claim?
&lt;/h2&gt;

&lt;p&gt;The old copy didn't get quietly patched; it got re-scoped, on the record. Our &lt;a href="https://ofriperetz.dev/go/claims-registry-evidence-framework?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;claims registry&lt;/a&gt; keeps a dedicated table for exactly this case — original copy, what we measured, what's true — and the row for this incident reads, condensed: earlier copy implying "we fix &lt;code&gt;sls remove&lt;/code&gt;'s ghost-billing bug" was wrong, because there is no &lt;code&gt;sls remove&lt;/code&gt; ghost-billing bug. The orphaned-cluster trap lives on the &lt;strong&gt;uninstall-while-keeping-service&lt;/strong&gt; path, where &lt;code&gt;sls caching disable&lt;/code&gt; — an explicit offboarding command the community plugin doesn't have — is the actual fix. The &lt;code&gt;before:remove:remove&lt;/code&gt; hook stays, verified live (stack deleted in 28s, zero residuals, measured 2026-05-03), but it's defense-in-depth now, not the headline.&lt;/p&gt;

&lt;p&gt;That's the whole lifecycle in one incident: claim → reproduction attempt → refutation → re-scope → versioned evidence file. The refuting run is committed as a dated JSON next to the claim row; the trap ships as a recipe anyone can replay.&lt;/p&gt;

&lt;p&gt;The stakes are why the claim deserved this much care. The cluster we reproduced was the smallest tier — 0.5 GB at $0.020/hour, $175.20/year. AWS's published cache pricing runs up to 237 GB at $33,288/year, and the charge is per stage, per region, with no flag on orphaned clusters. A number that size deserves a claim pointed at the path where the money actually leaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is a narrower claim stronger?
&lt;/h2&gt;

&lt;p&gt;Value investors call it margin of safety: you don't pay the price your own estimate says is fair — you pay less, so that being somewhat wrong doesn't ruin you. A claim scoped to exactly what the run files show carries the same margin. The broad claim had none: it rode on an untested inference, and a single ten-minute E2E from anyone could falsify it. Ours did, mercifully before anyone else's.&lt;/p&gt;

&lt;p&gt;The narrow claim is also more useful to you. "Run &lt;code&gt;sls caching disable&lt;/code&gt; before you remove the plugin from config" is an action. "Beware &lt;code&gt;sls remove&lt;/code&gt;" would have taught you to distrust the one command that actually cleans up after itself — misallocating your caution away from the path that costs money and onto the path that doesn't.&lt;/p&gt;

&lt;p&gt;Which is the misconception worth naming: &lt;strong&gt;"walking back a claim costs credibility."&lt;/strong&gt; It's backwards. Unmeasured claims cost credibility — on a schedule you don't control. A published re-scope is the cheapest credibility purchase available: it costs one uncomfortable table row and buys back the right to be believed about everything the run files do support. The genre has old prior art — Buffett's shareholder letters have aired his own mistakes for decades, and it's the same model behind &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=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;how I try not to cheat in my own benchmarks&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I'll admit the first instinct, reading the clean run, was the other genre: quietly delete the sentence and move on. That instinct — fix silently, stay loud — is exactly what a claims registry exists to make impossible, and the reason this article exists at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Answer (measured 2026-05-03/04, community plugin v1.11.0)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Does &lt;code&gt;sls remove&lt;/code&gt; orphan the community plugin's cache cluster?&lt;/td&gt;
&lt;td&gt;No — CloudFormation deletes the stage and the cluster with it (31s, exit 0, no orphans)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where is the actual trap?&lt;/td&gt;
&lt;td&gt;Remove the plugin from &lt;code&gt;plugins&lt;/code&gt; and redeploy the still-running service — no plugin code runs; cluster persists &lt;code&gt;enabled: true, AVAILABLE&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is there a plugin-mediated escape (community v1.11.0)?&lt;/td&gt;
&lt;td&gt;No — &lt;code&gt;sls caching disable&lt;/code&gt; / &lt;code&gt;sls caching status&lt;/code&gt; return "command not found"; manual &lt;code&gt;aws apigateway update-stage&lt;/code&gt; required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What does an orphaned cluster cost?&lt;/td&gt;
&lt;td&gt;$175.20/yr at 0.5 GB up to $33,288/yr at 237 GB — per stage, per region, unflagged by AWS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The safe offboarding path (&lt;code&gt;@interlace/serverless-api-gateway-caching&lt;/code&gt;)?&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sls caching disable&lt;/code&gt; before uninstalling; &lt;code&gt;before:remove:remove&lt;/code&gt; covers &lt;code&gt;sls remove&lt;/code&gt; (verified live: stack deleted in 28s, zero residuals)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reproduce it yourself&lt;/td&gt;
&lt;td&gt;~10 minutes, ~$0.005 — &lt;a href="https://ofriperetz.dev/go/r/11jasy64k1j?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;ghost-billing-reproduction.md&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;The claim that survived is smaller than the one we started with, and it's the only one of the two I'd defend in public — because defending it requires no rhetoric, just the run files. If a claim in our docs matters to you, check whether it has a row in &lt;a href="https://ofriperetz.dev/go/r/zwh1e23qps?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;CLAIMS.md&lt;/a&gt;; if it doesn't, it isn't allowed to ship. That policy — and the re-scope you just read, kept on the record instead of buried — is the honest answer to "why should I trust a plugin author's comparison table."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ofri-peretz/serverless" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;⭐ Star the serverless repo — where claims ship with run files, or don't ship&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;Next in this arc: one step earlier in the story. Before you can re-scope a claim about an incumbent plugin, you have to decide which incumbent is worth challenging at all — &lt;a href="https://ofriperetz.dev/go/abandoned-incumbent-map?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;the abandoned-incumbent map&lt;/a&gt; is how the serverless line made that call.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/r/11ld6bhww47?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;Amazon API Gateway pricing — cache pricing&lt;/a&gt; — the per-size price table behind every $/hour and $/year figure in this article.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/r/9yycsabb48?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;Cache settings for REST APIs in API Gateway — Amazon API Gateway Developer Guide&lt;/a&gt; — the stage-level cache settings (&lt;code&gt;cacheClusterEnabled&lt;/code&gt;, cluster size) that make the cluster live and die with its stage.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/bias-in-measurement?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;Bias in Measurement&lt;/a&gt; — why a result that flatters you is the one to re-run&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/reproducibility-vs-replicability?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;Reproducibility vs Replicability&lt;/a&gt; — the bar the $0.005 recipe is built to clear&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/valid-vs-reliable-metrics?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;Valid vs Reliable Metrics&lt;/a&gt; — how a claim can be perfectly consistent and still measure nothing&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/claims-registry-evidence-framework?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;The Claims Registry&lt;/a&gt; — the table this article's re-scoped row lives in&lt;/li&gt;
&lt;li&gt;
&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=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;I Built What I Benchmark. Here's How I Try Not to Cheat.&lt;/a&gt; — the same discipline applied to the ESLint benchmarks&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Part of the &lt;a href="https://ofriperetz.dev/go/r/8v7joo8tnv?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" 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=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · npm: &lt;a href="https://ofriperetz.dev/go/r/29m8v07vw3v?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;@interlace&lt;/a&gt; · Follow: &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;Dev.to/ofri-peretz&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/jp3wgc57f7?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>serverless</category>
      <category>aws</category>
      <category>devops</category>
      <category>node</category>
    </item>
    <item>
      <title>25% of My Benchmark Verdict Is an Opinion. Here's the Anatomy.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Mon, 20 Jul 2026 04:43:35 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/25-of-my-benchmark-verdict-is-an-opinion-heres-the-anatomy-294d</link>
      <guid>https://dev.to/ofri-peretz/25-of-my-benchmark-verdict-is-an-opinion-heres-the-anatomy-294d</guid>
      <description>&lt;p&gt;On 2026-05-04, my benchmark harness printed its verdict on two Serverless Framework caching plugins: mine scored &lt;strong&gt;0.88&lt;/strong&gt;; the community incumbent, &lt;strong&gt;0.3025&lt;/strong&gt;. Every digit of that gap is arithmetic — seven dimensions, each one measured, normalized, weighted, summed. The raw JSON actually stores my side as &lt;code&gt;0.8800000000000001&lt;/code&gt;, kept as-is: IEEE 754's reminder that a number can be very precise about being an opinion. Because the most consequential input in that file was never measured by any instrument: &lt;strong&gt;Lifecycle Correctness is worth 25% of the score because I said so.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A composite score is opinions wearing a number costume — the general argument, and the honesty requirements any composite owes its readers, live in &lt;a href="https://ofriperetz.dev/go/composite-scores-and-weighting?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;Composite Scores and Weighting&lt;/a&gt;, and I won't re-derive them here. What I owe you instead is the costume's pattern, dimension by dimension — including whether the verdict survives when you attack the weights.&lt;/p&gt;




&lt;h2&gt;
  
  
  What are the seven dimensions — and where does each number come from?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Weight&lt;/th&gt;
&lt;th&gt;Interlace&lt;/th&gt;
&lt;th&gt;Community&lt;/th&gt;
&lt;th&gt;Measured from&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lifecycle Correctness&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;td&gt;local source + live AWS deploy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLI Surface&lt;/td&gt;
&lt;td&gt;15%&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;0.0&lt;/td&gt;
&lt;td&gt;local source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript Coverage&lt;/td&gt;
&lt;td&gt;15%&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;0.0&lt;/td&gt;
&lt;td&gt;npm registry*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintenance Signal&lt;/td&gt;
&lt;td&gt;15%&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;0.0&lt;/td&gt;
&lt;td&gt;npm registry*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bundle Weight&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;npm registry*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hook Coverage&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;0.375&lt;/td&gt;
&lt;td&gt;local source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Documentation Quality&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;0.8&lt;/td&gt;
&lt;td&gt;0.4&lt;/td&gt;
&lt;td&gt;README scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Composite&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Σ=1.0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.88&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.3025&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;* Community side only. The Interlace values in these rows came from local source (&lt;code&gt;"source": "local"&lt;/code&gt; in the result file) — version 0.0.0 has never been published.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Run stamped 2026-05-04, Node v24.12.0 on darwin/arm64. Two disclosures before anything else. First: my &lt;code&gt;@interlace/serverless-api-gateway-caching&lt;/code&gt; was measured from local source at version 0.0.0, while the community's &lt;code&gt;serverless-api-gateway-caching&lt;/code&gt; is the published 1.11.0 from the registry. That asymmetry makes one score in the table an artifact, not a measurement: my Maintenance Signal 1.0 rests on &lt;code&gt;daysSincePublish: 0, totalVersions: null, weeklyDownloads: 0&lt;/code&gt; — an unpublished plugin scores perfect maintenance while the community's real 379-day publish gap scores 0. Read that row as &lt;em&gt;unmeasured&lt;/em&gt;, not &lt;em&gt;excellent&lt;/em&gt;. Second: the lifecycle numbers are not simulations. Both plugins went through a live-AWS end-to-end lifecycle run, and the two E2E result files sit on disk stamped 43 minutes apart from the same night.&lt;/p&gt;

&lt;p&gt;The "measured from" column matters as much as the scores. Registry-sourced dimensions are &lt;a href="https://ofriperetz.dev/go/proxy-metrics?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;proxy metrics&lt;/a&gt;: publish cadence stands in for maintenance, type definitions stand in for TypeScript support. And a README scan is &lt;a href="https://ofriperetz.dev/go/valid-vs-reliable-metrics?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;reliable without being automatically valid&lt;/a&gt; — it returns the same 0.8 every run; whether 0.8 captures what "good documentation" means to you, the harness cannot answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do kilobytes, hook counts, and pass rates become one scale?
&lt;/h2&gt;

&lt;p&gt;The seven dimensions arrive in incompatible units: bundle size in kilobytes, hook coverage as a count of lifecycle hooks handled, lifecycle correctness as pass/fail steps against a live deploy, documentation as a scan score. A weighted sum over raw units lets the biggest-range unit silently win — the classic &lt;a href="https://ofriperetz.dev/go/composite-scores-and-weighting?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;mixing-scales trap&lt;/a&gt;. So every dimension is normalized to 0–1 before any weight touches it.&lt;/p&gt;

&lt;p&gt;The community plugin's Hook Coverage of 0.375 is not a grade someone typed in — it's 3 hooks counted against a ceiling of 8, and that ceiling is my own plugin's full hook-and-command surface, which guarantees my side a 1.0. That denominator is a normalization policy that happens to flatter its author, and it deserves the same suspicion as the bundle curve coming next. On Bundle Weight, my 78 KB unpacked against the community's 47 KB came out 0 against 1 — which tells you the normalization curve is itself a policy decision. A gentler curve would have given my 78 KB partial credit; this one didn't, and I left it that way. Normalization choices change scores without changing a single measurement, so they belong in the published methodology, not in a footnote you have to diff the source to find.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when a dimension can't be measured?
&lt;/h2&gt;

&lt;p&gt;The harness has a standing rule for dimensions it cannot measure — historically the AWS-dependent ones, when there's no live deploy to run against: score them &lt;strong&gt;null&lt;/strong&gt; and never impute a value. An imputed dimension is an invented measurement wearing the same costume as the real ones. But the null path deserves the least flattering disclosure in this article: when nulls are present, &lt;code&gt;compositeScore&lt;/code&gt; renormalizes over the measured subset — null dimensions drop out of both numerator and denominator — so a partial run still prints one clean-looking composite. Renormalizing isn't imputation, but it quietly moves weight onto whatever happened to be measurable. The day before this run did exactly that: three of seven dimensions measured, and the harness stored &lt;code&gt;0.7499999999999999&lt;/code&gt; vs 0.25 — IEEE 754 again, equally precise about a composite built from 40% of the total weight, with nothing in the numbers admitting four dimensions were missing. The &lt;em&gt;n&lt;/em&gt;-of-7 label that makes a partial composite honest currently lives one layer up, in &lt;a href="https://ofriperetz.dev/go/claims-registry-evidence-framework?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;the claims registry&lt;/a&gt; rather than the harness output — a real gap: "3-of-7, here's what's missing" should be printed by the code, not patched on at the claims layer. In this run the null path went unused — &lt;code&gt;dimensionScores&lt;/code&gt; holds numeric values for all seven keys, on both plugins (don't cite the &lt;code&gt;skipped&lt;/code&gt; array for this; it was empty in the partial run too) — and I'm telling you anyway, because a policy you only hear about when it's exercised is a policy you can't audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is Lifecycle Correctness worth 25% — and who decided?
&lt;/h2&gt;

&lt;p&gt;I decided. Here's the reasoning, in tiers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;25% — the damage tier.&lt;/strong&gt; Lifecycle Correctness is the only dimension where failure means the plugin actively harms your deployment instead of merely annoying you. A caching plugin that mishandles the deploy lifecycle doesn't degrade politely — it leaves your API Gateway stage in a state you didn't ask for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;15% × 3 — the daily-use tier.&lt;/strong&gt; CLI Surface, TypeScript Coverage, Maintenance Signal: the things you touch or depend on every working day. Individually smaller than correctness, collectively 45% of the score.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10% × 3 — the comfort tier.&lt;/strong&gt; Bundle Weight, Hook Coverage, Documentation Quality: real costs you can see coming and route around.&lt;/p&gt;

&lt;p&gt;Reasonable people could defend a different split — which is the point. The common misconception is that &lt;strong&gt;a benchmark score computed from real measurements is objective.&lt;/strong&gt; The measurements are objective; the weights they run through are editorial, and computed is not neutral. Nor is admitting that unusual: Lighthouse versions its scoring weights and OpenSSF Scorecard publishes its full methodology — the composite-scores canonical walks both. Same page, new Lighthouse version, different score — which means every performance score you've ever pasted into a report was a committee vote you didn't attend. Every index has a committee. The only real question is whether the committee publishes its votes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does the winner survive a ±50% attack on the weights?
&lt;/h2&gt;

&lt;p&gt;If a ranking flips when the weights shift, the composite is telling you about the committee, not the plugins. So attack mine as hard as ±50% allows. Halve every weight where my plugin leads: Lifecycle to 12.5%, CLI, TypeScript, and Maintenance to 7.5% each, Hooks and Docs to 5% each. Raise the one dimension where my plugin &lt;em&gt;loses&lt;/em&gt; — Bundle Weight — by half, to 15%. Renormalize so the weights sum to 1.0 again.&lt;/p&gt;

&lt;p&gt;The composite becomes roughly &lt;strong&gt;0.73 vs 0.42&lt;/strong&gt;. The order holds, and not by luck: the community plugin leads exactly one dimension out of seven, so no ±50% reweighting exists in which one 10%-class dimension outvotes six others. Push past ±50% and, holding the other six weights in proportion, Bundle Weight needs roughly &lt;strong&gt;43% of the whole composite&lt;/strong&gt; — against its actual 10% — to flip the winner. The gap is score-broad, not weight-fragile.&lt;/p&gt;

&lt;p&gt;That surviving loss deserves its own sentence. My plugin scores &lt;strong&gt;0&lt;/strong&gt; on Bundle Weight — 78 KB unpacked against the community's 47 KB — and it's printed in the table above at full weight, in a benchmark I wrote, scoring a plugin I built, under weights I chose. That conflict of interest is real, and the two controls you can audit against it are the published loss and the sensitivity math you can redo on a napkin — the full self-benchmark process is in &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=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;I Built What I Benchmark&lt;/a&gt;. A composite that survives its own worst dimension in public is worth more than one that never shows a loss.&lt;/p&gt;

&lt;p&gt;If you'd have weighted it differently, don't argue in the abstract — the whole run is one command: &lt;code&gt;npm run bench:caching&lt;/code&gt; from &lt;code&gt;serverless/benchmarks&lt;/code&gt;, and this run's output sits at &lt;code&gt;benchmarks/benchmark-results/api-gateway-caching/2026-05-04_v1.0/result.json&lt;/code&gt; in the repo. Change the weights, re-run it; if your reweighting flips the order, that's a finding and I want it in an issue. The strongest evidence that this harness follows the data even when it disagrees with me is the next article in this series: the time our loudest serverless claim died under its own E2E run — &lt;a href="https://ofriperetz.dev/go/we-were-wrong-about-sls-remove?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;We Were Wrong About sls remove&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What is the composite?&lt;/td&gt;
&lt;td&gt;Weighted sum over 7 dimensions; weights sum to 1.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Result (2026-05-04 run)&lt;/td&gt;
&lt;td&gt;Interlace &lt;strong&gt;0.88&lt;/strong&gt; vs community &lt;strong&gt;0.3025&lt;/strong&gt;; all 7 dimensions measured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who sets the weights?&lt;/td&gt;
&lt;td&gt;The author — editorial, published, versioned with the results&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weight tiers&lt;/td&gt;
&lt;td&gt;Damage 25% · daily-use 15%×3 · comfort 10%×3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mixed units?&lt;/td&gt;
&lt;td&gt;Every dimension normalized to 0–1 before weighting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unmeasurable dimension?&lt;/td&gt;
&lt;td&gt;Null, never imputed; harness renormalizes over measured dims — &lt;em&gt;n&lt;/em&gt;-of-7 label lives at the claims layer (this run: 7-of-7)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;±50% adversarial reweighting&lt;/td&gt;
&lt;td&gt;~0.73 vs ~0.42 — the order holds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The honest loss&lt;/td&gt;
&lt;td&gt;Bundle Weight: 78 KB vs 47 KB → scored 0 vs 1, published at full weight&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Versions measured&lt;/td&gt;
&lt;td&gt;Interlace 0.0.0 (local source, unpublished) vs community 1.11.0 (npm)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reproduce&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;npm run bench:caching&lt;/code&gt; in &lt;code&gt;serverless/benchmarks&lt;/code&gt; → &lt;code&gt;benchmark-results/api-gateway-caching/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  External references
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/r/1jh3gr5k5v4?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;OECD/JRC — Handbook on Constructing Composite Indicators: Methodology and User Guide (2008)&lt;/a&gt; — the authoritative methodology reference for composite indicators; the normalization and sensitivity sections above are the single-benchmark version of its checklist.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/r/2gj60g4jn6m?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;Lighthouse performance scoring documentation&lt;/a&gt; — Google's published, versioned scoring weights: the working precedent for treating a weight change as a verdict change.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;What's the largest weight you'd assign differently in that table — and which dimension would you demote to pay for it?&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Related deep dives
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/composite-scores-and-weighting?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;Composite Scores and Weighting&lt;/a&gt; — the tier-0 canonical this benchmark instantiates: why composites exist, the honesty requirements, and the Lighthouse / OpenSSF Scorecard case studies&lt;/li&gt;
&lt;li&gt;
&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=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;I Built What I Benchmark. Here's How I Try Not to Cheat.&lt;/a&gt; — the conflict-of-interest process behind every benchmark on this site&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/claims-registry-evidence-framework?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;The Claims Registry&lt;/a&gt; — the claim→evidence-file contract that this run's result file feeds&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/proxy-metrics?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;Proxy Metrics&lt;/a&gt; — why registry-sourced dimensions measure stand-ins, not the thing itself&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/valid-vs-reliable-metrics?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;Valid vs Reliable Metrics&lt;/a&gt; — the difference a README scan can't close on its own&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://github.com/ofri-peretz/serverless" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Star the serverless repo — you've just read exactly how its scores are built&lt;/a&gt;
&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the &lt;a href="https://ofriperetz.dev/go/r/8v7joo8tnv?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" 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=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · npm: &lt;a href="https://ofriperetz.dev/go/r/29m8v07vw3v?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;@interlace&lt;/a&gt; · Follow: &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;Dev.to/ofri-peretz&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/jp3wgc57f7?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>serverless</category>
      <category>devops</category>
      <category>node</category>
      <category>aws</category>
    </item>
    <item>
      <title>Marketing Pages Rot Silently. Mine Print an Expiry Date on Every Claim.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Mon, 20 Jul 2026 04:43:26 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/marketing-pages-rot-silently-mine-print-an-expiry-date-on-every-claim-1km4</link>
      <guid>https://dev.to/ofri-peretz/marketing-pages-rot-silently-mine-print-an-expiry-date-on-every-claim-1km4</guid>
      <description>&lt;p&gt;Around 2026-08-01, a "verification pending" banner goes up on eleven claims in my serverless docs. Nothing will have broken. No competitor will have shipped. The banner goes up because every verified row in that repo's &lt;a href="https://ofriperetz.dev/go/r/zwh1e23qps?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;&lt;code&gt;CLAIMS.md&lt;/code&gt;&lt;/a&gt; is stamped 2026-05-03 or 2026-05-04, the file gives evidence a 90-day warranty, and date arithmetic doesn't care that I wrote the rule.&lt;/p&gt;

&lt;p&gt;Line 5 of that file:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If a claim doesn't have a row, it can't ship in the docs. If "Last verified" is older than 90 days, the claim is &lt;strong&gt;stale&lt;/strong&gt; and gets a "verification pending" banner in docs until refreshed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That line exists because marketing pages rot silently: a claim gets written on the day it's true, nobody dates it, and the sentence sits there aging in public. The fix is one file per product line — &lt;strong&gt;the claims registry&lt;/strong&gt;: every public claim holds a row, every row points at a versioned result file, and every date is an expiry date.&lt;/p&gt;

&lt;p&gt;This is the methodology piece — the contract, the lifecycle, the staleness rule — and none of it is JavaScript-specific. If your product page makes claims, it transfers in an afternoon.&lt;/p&gt;




&lt;h2&gt;
  
  
  What does a claim owe you before it ships?
&lt;/h2&gt;

&lt;p&gt;The contract is deliberately boring. A claim backed by static evidence — a benchmark you can run on a laptop — carries four fields: the claim text &lt;strong&gt;exactly as it appears in docs or marketing&lt;/strong&gt;, the suite that measures it, a link to the latest result file, and the date it was last verified. Verbatim matters: paraphrase is where scope creep hides — if the row is politer than the landing page, the landing page is the claim nobody verified.&lt;/p&gt;

&lt;p&gt;One worked row from each product line:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Serverless.&lt;/strong&gt; The claim "Composite score 88% vs community 30% (7 of 7 dimensions)" points at the &lt;code&gt;api-gateway-caching&lt;/code&gt; suite and its result file &lt;a href="https://ofriperetz.dev/go/r/1cywkt4os1j?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;&lt;code&gt;latest.json&lt;/code&gt;&lt;/a&gt;, last verified 2026-05-04. Open the file: composite 0.88 for my plugin (measured from local source at v0.0.0, not a published release) against 0.3025 for the community &lt;code&gt;serverless-api-gateway-caching@1.11.0&lt;/code&gt;, all seven weighted dimensions scored, including the one I lose outright: bundle weight, 78 KB unpacked against their 47 KB. The row carries the loss because the file does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ESLint.&lt;/strong&gt; The &lt;a href="https://ofriperetz.dev/go/r/1ea7m4rs921?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;sister registry's&lt;/a&gt; row reads "Top of leaderboard on the ILB-Arena 40-vuln / 38-safe corpus (1st of 18 plugins tested, 17 security-relevant)" and points at the ilb-arena suite's dated result file, where &lt;code&gt;summary.leaderboard[0].rank == 1&lt;/code&gt;. That row survived an audit its neighbor didn't: the registry had been asserting "97.6% precision, 100% recall, 98.8% F1" — figures that appear nowhere in the cited JSON, which reports 100% across the board. The 2026-05-13 audit withdrew the claim rather than substitute the perfect score — 100% on a 40-fixture self-authored corpus is the textbook "regression test, not benchmark" failure mode. The ordinal claim kept its row; the flattering one kept only its receipt, in a "Withdrawn claims" section. The suite column enforces &lt;a href="https://ofriperetz.dev/go/valid-vs-reliable-metrics?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;validity&lt;/a&gt; — measure what the copy asserts; the result file makes the claim &lt;a href="https://ofriperetz.dev/go/reproducibility-vs-replicability?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;reproducible&lt;/a&gt; — regenerate the number without trusting me.&lt;/p&gt;

&lt;p&gt;Claims only live infrastructure can verify get a second row flavor: claim text, the E2E step that asserts it, the last release verified. "Cache MISS on first request, HIT within TTL" maps to steps 4–5 of a deploy-and-assert run against real AWS, last verified 2026-05-03.&lt;/p&gt;

&lt;p&gt;The contract also kills a third category — the adjective. "Battle-tested," "blazing fast," "production-grade": &lt;a href="https://ofriperetz.dev/go/proxy-metrics?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;proxies&lt;/a&gt; for measurements nobody made. No suite outputs "battle-tested," so no row, so no ship. The registry doesn't argue with weak claims; it has nowhere to put them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does a claim live and die?
&lt;/h2&gt;

&lt;p&gt;Draft → evidenced → stale → re-verified or retired — every transition a visible edit to one file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Draft.&lt;/strong&gt; The add-a-claim procedure opens with its most important sentence: &lt;em&gt;don't write the marketing copy first.&lt;/em&gt; Build or extend the benchmark until it produces a measurable result for your product and at least one competitor, add the row with today's date, and only then write the copy. The rule is that blunt because my instinct runs the other way — when I draft a README, the adjective arrives before the evidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pending.&lt;/strong&gt; Unbacked copy isn't deleted — it's routed into a "Pending claims" section next to the suite that would back it. As of today my serverless registry holds 11 verified rows (4 static-evidence, 7 live-evidence) and 2 pending, both "Not started." Pending is the honest parking lot: marketing wishes become a benchmark backlog, priced in engineering hours instead of adjectives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Re-scoped.&lt;/strong&gt; The state nobody plans for: the measurement comes back and narrows the claim. Early copy in my serverless line implied we fix a ghost-billing bug in &lt;code&gt;sls remove&lt;/code&gt;. Then the community-plugin E2E ran on 2026-05-04: &lt;code&gt;sls remove&lt;/code&gt; came back clean — 31 seconds, exit 0, no orphans. The real trap is uninstalling the plugin while keeping the service. The original copy went into a re-scoped row — original text, what we measured, what's true — instead of being quietly rewritten (&lt;a href="https://ofriperetz.dev/go/we-were-wrong-about-sls-remove?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;told in full here&lt;/a&gt;). The most useful rows in my registry are the ones that say I was wrong, in writing, next to the run file that proved it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retired&lt;/strong&gt; is a re-scope with nothing left: no narrower version survives the measurement. The row stays; the docs copy goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  When does a claim go stale — and who decides?
&lt;/h2&gt;

&lt;p&gt;The misconception to kill: &lt;strong&gt;"claims go stale when the product changes."&lt;/strong&gt; They don't. A claim goes stale when its &lt;em&gt;evidence&lt;/em&gt; ages past its warranty — the product changing is just one way that happens. Your code can be byte-for-byte identical while the competitor you compared against ships three releases and the runtime you benchmarked goes end-of-life. The sentence didn't move; everything it pointed at did.&lt;/p&gt;

&lt;p&gt;That's why staleness is &lt;em&gt;declared, not discovered&lt;/em&gt;. Discovered staleness means a reader catches the drift, and the correction arrives after the damage. Declared staleness is date arithmetic: "Last verified" older than 90 days, banner up, mechanically, before anyone is misled. Ninety isn't sacred; it's a warranty one maintainer can honor — pick yours and enforce it the same way.&lt;/p&gt;

&lt;p&gt;What keeps this from becoming a refresh treadmill is the version stamp. "eslint-plugin-security has 27.5% recall" rots — a standing claim about the present. "eslint-plugin-security v2.1.1 scored 27.5% recall on our 40-fixture corpus, on ESLint 8.57.0" is a dated measurement no future release can falsify — true forever. Financial statements solved this long ago: a balance sheet is always "as of" a date. Stamped measurements carry zero refresh obligation; only &lt;em&gt;standing&lt;/em&gt; claims — "fastest," "most complete," anything in the present tense — sit under the 90-day clock.&lt;/p&gt;

&lt;p&gt;The opening countdown is this rule running on its author: the banner lands around 2026-08-01 whether or not I get the re-runs done first. The ESLint registry is already there — one row, a synthetic-corpus speedup last verified 2026-01-02, carries the "re-verify recommended" flag today. That's the system working on me, twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you adopt this in an afternoon?
&lt;/h2&gt;

&lt;p&gt;Nothing above depends on ESLint, serverless, or even software — the suite can be a load test, an accessibility audit, or a lab assay. The afternoon version:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create &lt;code&gt;CLAIMS.md&lt;/code&gt; at the repo root. The whole starting file:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;   # Claims Registry
&lt;span class="gt"&gt;
   &amp;gt; No row, no ship. "Last verified" older than 90 days →&lt;/span&gt;
&lt;span class="gt"&gt;   &amp;gt; "verification pending" banner in docs until refreshed.&lt;/span&gt;

   | Claim (verbatim from docs/marketing)   | Suite               | Latest result              | Last verified |
   | -------------------------------------- | ------------------- | -------------------------- | ------------- |
   | "Composite score 88% vs community 30%" | api-gateway-caching | benchmarks/.../latest.json | 2026-05-04    |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Inventory every public claim — README, landing page, package description — verbatim, no paraphrase.&lt;/li&gt;
&lt;li&gt;For each, name the evidence (suite + latest result + date) or move it to Pending.&lt;/li&gt;
&lt;li&gt;For everything new: evidence first, row second, copy last.&lt;/li&gt;
&lt;li&gt;On refresh, commit the new result as a dated file (&lt;code&gt;&amp;lt;suite&amp;gt;/&amp;lt;YYYY-MM-DD&amp;gt;_v&amp;lt;version&amp;gt;/&lt;/code&gt;) and bump the date — what you claimed, and when it was last true, becomes repo history.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This isn't an island. The closest neighbor is the &lt;a href="https://ofriperetz.dev/go/r/1qqfaz74bul?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;OpenSSF Scorecard&lt;/a&gt;, which treats a repo's security posture as automated, publicly specified checks pointed at repositories from the outside. The claims registry is the manual, product-side sibling, pointed inward at your own marketing copy (&lt;a href="https://ofriperetz.dev/go/how-to-design-a-ground-truth-corpus?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;corpus design&lt;/a&gt; covers the datasheets-for-datasets lineage). And it pairs with the conflict-of-interest discipline in &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=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;I Built What I Benchmark&lt;/a&gt;: I built the products &lt;em&gt;and&lt;/em&gt; the registry that polices them — a suspicious arrangement, which is why every row names the command that would catch me.&lt;/p&gt;

&lt;p&gt;Next in this arc: &lt;a href="https://ofriperetz.dev/go/we-were-wrong-about-sls-remove?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;We Were Wrong About sls remove&lt;/a&gt; — the re-scope told in full, because the lifecycle is easiest to believe when you watch it delete a claim I liked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What gets a row&lt;/td&gt;
&lt;td&gt;Every claim in docs/marketing, verbatim — no paraphrase&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static-evidence row&lt;/td&gt;
&lt;td&gt;Claim text · suite · latest result file · last-verified date&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live-evidence row&lt;/td&gt;
&lt;td&gt;Claim text · E2E step · last release verified&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ship rule&lt;/td&gt;
&lt;td&gt;No row → the claim cannot ship in docs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Staleness rule&lt;/td&gt;
&lt;td&gt;"Last verified" &amp;gt; 90 days → "verification pending" banner until refreshed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lifecycle&lt;/td&gt;
&lt;td&gt;Draft → evidenced → stale → re-verified or retired; re-scoped when measurement narrows it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New-claim order&lt;/td&gt;
&lt;td&gt;Evidence first, row second, marketing copy last&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unbacked copy&lt;/td&gt;
&lt;td&gt;Routed to Pending with the suite that would back it — never into docs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refresh mechanics&lt;/td&gt;
&lt;td&gt;Re-run suite → commit dated result file (&lt;code&gt;&amp;lt;suite&amp;gt;/&amp;lt;YYYY-MM-DD&amp;gt;_v&amp;lt;version&amp;gt;/&lt;/code&gt;) → bump date&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The immortal form&lt;/td&gt;
&lt;td&gt;Version-stamped: "vX scored Y (measured DATE)" — a dated measurement, true forever&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The suite that generates the ESLint result files is public — go check:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ofri-peretz/eslint-benchmark-suite" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;⭐ Star the benchmark suite — the result files this registry points at&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;Before the clock catches yours: what's the oldest undated claim on your product page right now — and would it survive a re-run today?&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/r/1qqfaz74bul?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;OpenSSF Scorecard&lt;/a&gt; — the Open Source Security Foundation's automated checks on open-source repos; cited as the closest prior art for treating claims as named, publicly specified checks rather than prose.&lt;/li&gt;
&lt;li&gt;Gebru et al., &lt;a href="https://ofriperetz.dev/go/r/2epvqpcbvmd?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;Datasheets for Datasets&lt;/a&gt; (arXiv:1803.09010, later published in CACM) — cited for the documentation-contract idea the registry applies to marketing claims instead of datasets: standardized fields, stated provenance, explicit scope.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Part of the &lt;a href="https://ofriperetz.dev/go/r/8v7joo8tnv?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" 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=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · npm: &lt;a href="https://ofriperetz.dev/go/r/29m8v07vw3v?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;@interlace&lt;/a&gt; · Follow: &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;Dev.to/ofri-peretz&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/jp3wgc57f7?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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