<?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>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>
    <item>
      <title>Fixtures First, Rules Second: How to Design a Ground-Truth Corpus</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Mon, 20 Jul 2026 04:43:02 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/fixtures-first-rules-second-how-to-design-a-ground-truth-corpus-1g05</link>
      <guid>https://dev.to/ofri-peretz/fixtures-first-rules-second-how-to-design-a-ground-truth-corpus-1g05</guid>
      <description>&lt;p&gt;&lt;code&gt;'SELECT * FROM users WHERE id = ' + userId&lt;/code&gt; is a vulnerable fixture — no debate. Wrap the input in &lt;code&gt;parseInt(userId)&lt;/code&gt; — vulnerable or safe? The label depends entirely on your threat model, and whichever way the author calls it, &lt;a href="https://ofriperetz.dev/go/ground-truth-in-security-testing?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus#hard-decisions" rel="noopener noreferrer"&gt;that call &lt;em&gt;is&lt;/em&gt; the ground truth&lt;/a&gt;. The corpus behind the Interlace security benchmarks took 40 calls like that one; &lt;code&gt;parseInt&lt;/code&gt; is the call I've gone back and forth on longest — it's still not in the corpus, which is itself a judgment call. And the cost of getting the discipline wrong isn't hypothetical: this corpus scores its own author's tool at a perfect 100% — a number that reads as evidence only because the fixtures demonstrably came first, and as a mirror otherwise. That discipline — categories, borderline labels, and the sequencing rule — is the rest of this article.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why do fixtures have to come before rules?
&lt;/h2&gt;

&lt;p&gt;Write the corpus &lt;em&gt;after&lt;/em&gt; the tool, with one eye on what it already detects, and the fixtures inherit its blind spots — the benchmark stops measuring and starts reflecting. That's &lt;a href="https://ofriperetz.dev/go/goodharts-law-explained?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;Goodhart's Law&lt;/a&gt; operating at design time.&lt;/p&gt;

&lt;p&gt;The defense is sequencing: this corpus was designed against published OWASP categories and CWE mappings before any Interlace rule existed to cover it, and its 38 safe patterns represent realistic validated code, not Interlace's allow-listing logic. Sequencing bounds the &lt;a href="https://ofriperetz.dev/go/bias-in-measurement?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;bias&lt;/a&gt;, not removes it — the same person still wrote both sides of the test.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you choose categories — and how many of each?
&lt;/h2&gt;

&lt;p&gt;Anchor categories to a public taxonomy, not instinct. The vulnerable corpus is 40 exported fixture functions across 14 categories, each mapped to a &lt;a href="https://ofriperetz.dev/go/cwe-taxonomy-explained?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;CWE ID&lt;/a&gt; — from four SQL-injection fixtures (CWE-89) down to a single open redirect (CWE-601), 17 distinct CWE IDs in total. Paired with those are 38 safe fixtures — 78 in all ("the 40-fixture corpus" names the vulnerable half; &lt;a href="https://ofriperetz.dev/go/precision-recall-f1-for-static-analysis?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;precision&lt;/a&gt; is computed against the safe half).&lt;/p&gt;

&lt;p&gt;Prior art: NIST's SARD is the canonical labeled vulnerable-code corpus family — the Juliet suites cover C/C++ and Java, with PHP and C# suites alongside; OWASP's Benchmark Project is the closest scored SAST analog; and SecBench.js (ICSE 2023) labels ~600 real-world server-side JavaScript vulnerabilities with executable exploits. What none of them offered Node.js-native is an OWASP-Benchmark-style scored SAST leaderboard — this corpus is that analog, smaller.&lt;/p&gt;

&lt;p&gt;One distortion to own out loud: the corpus is roughly balanced across categories and production code is not — real vulnerabilities are rare, so a balanced corpus says nothing about the false-alarm rate at &lt;a href="https://ofriperetz.dev/go/base-rate-problem-explained?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;production base rates&lt;/a&gt;. It measures detection per pattern; prevalence needs different evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you label the edge cases?
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;parseInt&lt;/code&gt; label is an author judgment inside the CWE-89 definition, and it earns an annotation carrying its reasoning: &lt;em&gt;borderline; would be vulnerable if the query used string interpolation instead of the typed value&lt;/em&gt;. The generalizable rule: record the reasoning, not just the verdict. Six months later, a label without reasoning is an opinion with tenure.&lt;/p&gt;

&lt;p&gt;This corpus has one rater, and &lt;a href="https://ofriperetz.dev/go/inter-rater-agreement-cohens-kappa?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;inter-rater agreement&lt;/a&gt; needs at least two — there is no kappa to report, only me agreeing with myself. That's the weakest joint in the design, and why the reasoning must be public: so a second rater can show up later and disagree precisely.&lt;/p&gt;

&lt;p&gt;Labels live &lt;em&gt;with&lt;/em&gt; the code: a manifest at the fixture file's bottom (&lt;code&gt;EXPECTED_DETECTIONS&lt;/code&gt;: function → &lt;code&gt;{cwe, severity}&lt;/code&gt;; &lt;code&gt;EXPECTED_NO_DETECTIONS&lt;/code&gt;: the safe list) is what the runner imports to score &lt;a href="https://ofriperetz.dev/go/confusion-matrix-tp-fp-fn-tn?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;TP/FP/FN/TN&lt;/a&gt; — no spreadsheet drifting out of sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you make a corpus result reproducible?
&lt;/h2&gt;

&lt;p&gt;A corpus result is a claim about specific tool versions — the &lt;a href="https://ofriperetz.dev/go/bias-in-measurement?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus#the-unicorn-incident" rel="noopener noreferrer"&gt;unicorn false-zero&lt;/a&gt; in my own benchmark came from a stale Node version in the shell, not from the plugin. &lt;a href="https://ofriperetz.dev/go/reproducibility-vs-replicability?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;Reproducibility&lt;/a&gt; rests on three mechanisms, none of them "trust the manifest": a lockfile pins the install; the runner resolves each plugin's actually-installed version at run time and writes it into the results JSON; and the environment ships with the numbers — per run, inside each results file, because runs happen on different days on different Nodes. The Interlace 3.0.2 run behind the 40 TP / 0 FP / 0 FN line records Node v24.12.0, ESLint 9.39.2, measured 2026-05-30; the sonarjs 3.0.6 run (14/40) records the same environment a day earlier, 2026-05-29. One plugin (&lt;code&gt;eslint-plugin-security&lt;/code&gt; 2.1.1) crashes on ESLint 9 and runs in a separate compat package — it declares &lt;code&gt;eslint ^8.57.0&lt;/code&gt; and lets its lockfile hold the exact version — recorded, not smoothed over.&lt;/p&gt;

&lt;p&gt;The other half is publishing: corpus public, divergence channel public, and reproduction that needs zero help from me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/ofri-peretz/eslint-benchmark-suite.git
&lt;span class="nb"&gt;cd &lt;/span&gt;eslint-benchmark-suite &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install
&lt;/span&gt;npm run benchmark:fn-fp   &lt;span class="c"&gt;# → node benchmarks/fn-fp-comparison/run.js&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it, get a different number, open an issue — your results JSON carries its own environment block, so the disagreement starts from evidence, not recollection. A corpus nobody can re-run is a claim, not a benchmark.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can 40 fixtures actually prove?
&lt;/h2&gt;

&lt;p&gt;The 78 fixtures are a constructed set, not a random draw, so results are descriptions, not inferences — no p-value is meaningful at any corpus size, because &lt;a href="https://ofriperetz.dev/go/sample-size-and-statistical-power?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;statistical power&lt;/a&gt; assumes a sampling process this design doesn't have. What n=40 is good at is category-level findings: a plugin catching 14 of 40 while missing whole categories is a real, checkable statement about that plugin at that version. Don't decorate results with statistics the design can't carry.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the corpus lifecycle?
&lt;/h2&gt;

&lt;p&gt;The lifecycle: design → publish → saturate → refresh.&lt;/p&gt;

&lt;p&gt;Saturation is where mine is now: Interlace v3.0.2 scores 40 TP, 0 FP, 0 FN — 100/100/100 precision/recall/F1. The tempting read is "the tool is finished." The correct read: the &lt;em&gt;corpus&lt;/em&gt; is finished, for that tool — a saturated corpus can't measure the improvement of the tool that saturates it. Publishing that line felt less like winning and more like watching the instrument go quiet. The corpus still measures competitors (next best: 14 of 40, eslint-plugin-sonarjs 3.0.6), but for Interlace it's now a regression test, not a benchmark.&lt;/p&gt;

&lt;p&gt;Then contamination: public fixtures can be tuned against — by any tool, mine included. Public corpora decay the way public trading signals do: once everyone sees the signal, trading on it erases the information it carried. The answer is versioning, not secrecy (secrecy kills reproducibility): treat the published corpus as v1 and plan a harder v2 held to the same fixtures-before-rules discipline — anchored to the taxonomy, not to any tool's known behavior, Interlace's misses included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which findings survive a robustness check?
&lt;/h2&gt;

&lt;p&gt;Before publishing, run leave-one-category-out: re-score the leaderboard 14 times, once per removed CWE category, and check whether the ordering survives every drop. A 40/40-versus-14/40 gap survives any single removal by construction; two mid-table plugins a fixture apart may swap the moment a category disappears — that's the ranking to report as fragile, or not as a ranking at all. Cheap, mechanical, and almost nobody does it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are Fixture Cards?
&lt;/h2&gt;

&lt;p&gt;The last discipline is documentation, and it deserves a name: &lt;strong&gt;Fixture Cards&lt;/strong&gt; — per-fixture documentation modeled on Datasheets for Datasets (Gebru et al.) and Model Cards (Mitchell et al.), scaled down to the single labeled example. Six fields:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;The &lt;code&gt;parseInt&lt;/code&gt; fixture's card would read&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CWE&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;CWE-89&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Label&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;threat-model-conditional: vulnerable under "any non-parameterized query is unsafe"; safe under "direct injection vector only"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Reasoning&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;borderline; would be vulnerable if the query used string interpolation instead of the typed value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Author&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ofri Peretz&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Date&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the day the call is made, pinned to the corpus version it enters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Disputed?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;open — threat-model-dependent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four of the six fields already exist in the suite: CWE and label live in the manifest the runner imports, author and date in the fixture file's git history. The Card names that standard and adds the two fields no runner ever checks — reasoning and dispute status. Packaging all six as a JSON schema is the remaining step, and it doubles as the contribution unit: to submit a fixture is to submit its Card. Labeling debt stops accumulating silently, and the single-rater problem gets its structural fix — every &lt;code&gt;disputed?&lt;/code&gt; field is an open seat for a second rater.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;parseInt&lt;/code&gt; Card above is the first open seat, and it's yours if you want it: comment with your label — vulnerable or safe — and the threat model that produces it. That's the second rating this corpus doesn't yet have.&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;Design decision&lt;/th&gt;
&lt;th&gt;The rule&lt;/th&gt;
&lt;th&gt;If you skip it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sequencing&lt;/td&gt;
&lt;td&gt;Fixtures before rules&lt;/td&gt;
&lt;td&gt;The benchmark becomes a mirror&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Category selection&lt;/td&gt;
&lt;td&gt;Anchor to CWE/OWASP (here: 14 categories, 17 CWE IDs)&lt;/td&gt;
&lt;td&gt;Blind spots inherited from instinct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Balance&lt;/td&gt;
&lt;td&gt;Balanced corpus — and say what it can't estimate&lt;/td&gt;
&lt;td&gt;Base-rate overclaims about production FP rates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge-case labels&lt;/td&gt;
&lt;td&gt;Record reasoning with the label; mark borderline calls&lt;/td&gt;
&lt;td&gt;Labels become opinions with tenure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Machine-readable labels&lt;/td&gt;
&lt;td&gt;Manifest lives in the fixture file; runner imports it&lt;/td&gt;
&lt;td&gt;Spreadsheet drift&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Versions&lt;/td&gt;
&lt;td&gt;Lockfile + per-run resolved versions in the results&lt;/td&gt;
&lt;td&gt;Irreproducible numbers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Publishing&lt;/td&gt;
&lt;td&gt;Public corpus + a divergence channel&lt;/td&gt;
&lt;td&gt;A claim, not a benchmark&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sample claims&lt;/td&gt;
&lt;td&gt;n=40 describes; it does not infer&lt;/td&gt;
&lt;td&gt;Fake statistical rigor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lifecycle&lt;/td&gt;
&lt;td&gt;Version the corpus; plan v2 at saturation&lt;/td&gt;
&lt;td&gt;100% scores misread as "done"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Robustness&lt;/td&gt;
&lt;td&gt;Leave-one-category-out before publishing&lt;/td&gt;
&lt;td&gt;Fragile rankings published as real&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixture Cards&lt;/td&gt;
&lt;td&gt;CWE · label · reasoning · author · date · disputed?&lt;/td&gt;
&lt;td&gt;Labeling debt accumulates silently&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;Corpus design is the unglamorous half of benchmarking — nobody stars a repo for a well-reasoned borderline label. But every downstream precision and recall number is only as honest as these decisions. Next in this arc the corpus does its job: the &lt;a href="https://ofriperetz.dev/go/eslint-security-fn-fp-benchmark?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;false-positive/false-negative benchmark&lt;/a&gt; scores six plugins against these 78 fixtures, wins and losses included.&lt;/p&gt;

&lt;p&gt;If this is the discipline you'd want behind a benchmark you trust, the corpus — fixtures, manifests, runner, results — is public:&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 — every fixture and label in this article is in it&lt;/a&gt;
&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/r/amji6fw5zd?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;NIST SARD — Software Assurance Reference Dataset&lt;/a&gt; (incl. the Juliet Test Suite) — the canonical labeled vulnerable-code corpora for C/C++/Java; this corpus is the Node.js-native analog, at a fraction of the scale.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/r/22pc6fhgqgm?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;OWASP Benchmark Project&lt;/a&gt; — the closest prior art overall: a public, scored SAST benchmark on the same TP/FP mechanics.&lt;/li&gt;
&lt;li&gt;Gebru et al., &lt;a href="https://ofriperetz.dev/go/r/2epvqpcbvmd?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;"Datasheets for Datasets"&lt;/a&gt; (CACM 2021) — the dataset-documentation standard Fixture Cards are modeled on.&lt;/li&gt;
&lt;li&gt;Mitchell et al., &lt;a href="https://ofriperetz.dev/go/r/1gqzr4mayj3?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;"Model Cards for Model Reporting"&lt;/a&gt; (FAT* 2019) — the same documentation move at model level; Fixture Cards scale it down to the single labeled example.&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/ground-truth-in-security-testing?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;Ground Truth in Security Testing: Who Decides What's Vulnerable?&lt;/a&gt; — the T1 vocabulary this article builds on, including the full &lt;code&gt;parseInt&lt;/code&gt; labeling story&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=how-to-design-a-ground-truth-corpus" 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 corpus discipline lives inside&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/eslint-security-fn-fp-benchmark?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;1.5M Weekly Downloads, 1 False Alarm per Real Bug&lt;/a&gt; — the evidence article produced by this corpus&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=how-to-design-a-ground-truth-corpus" 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=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · npm: &lt;a href="https://ofriperetz.dev/go/r/29m8v07vw3v?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;@interlace&lt;/a&gt; · Follow: &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" 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=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>devsecops</category>
      <category>eslint</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Valid vs. Reliable Metrics: Consistent Numbers Can Still Be Wrong</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sun, 19 Jul 2026 23:47:32 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/valid-vs-reliable-metrics-consistent-numbers-can-still-be-wrong-1a2m</link>
      <guid>https://dev.to/ofri-peretz/valid-vs-reliable-metrics-consistent-numbers-can-still-be-wrong-1a2m</guid>
      <description>&lt;p&gt;A clock that runs ten minutes fast is the friendly kind of broken: it shows the same answer every time you look, so it feels dependable — right up until it makes you miss the train. The clock was never inconsistent. It was consistently wrong, which is the harder problem, because consistency is the part that looks like quality. That gap between &lt;em&gt;steady&lt;/em&gt; and &lt;em&gt;correct&lt;/em&gt; is the oldest distinction in measurement theory, and it's the one most dashboards quietly skip: a number can be perfectly &lt;strong&gt;reliable&lt;/strong&gt; — the same value on every read — and still be &lt;strong&gt;invalid&lt;/strong&gt; — pointed at the wrong quantity. Precision of the wrong thing is still the wrong thing.&lt;/p&gt;

&lt;p&gt;Reliability versus validity applies identically whether you weigh yourself, price a stock, score a diagnostic test, grade a diver, or benchmark software. Reliability is a property of the instrument; validity is a property of the claim you attach to its number. They are independent — you can have either without the other — and telling them apart is the whole job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Makes a Metric Reliable?
&lt;/h2&gt;

&lt;p&gt;A metric is &lt;strong&gt;reliable&lt;/strong&gt; when repeated measurement under the same conditions returns the same number. A scale that reads 82.0 kg five times in a row is reliable. A lab assay that returns 5.4 mmol/L on the same blood sample, run three times, is reliable — same sample, same number. Reliability never asks whether 82.0 or 5.4 is &lt;em&gt;correct&lt;/em&gt;; only whether the instrument will say it again.&lt;/p&gt;

&lt;p&gt;Reliability is a property of the &lt;em&gt;instrument&lt;/em&gt;, not of the truth. That makes it the cheap property to check: you don't need to know anything about the world — take the measurement twice and compare. It's also the property people naturally police, because unreliability is loud. A blood-pressure cuff that gives a different reading every squeeze, a poll that swings ten points between identical samples, a stopwatch that disagrees with itself — a wobbling number announces itself, gets questioned, gets fixed.&lt;/p&gt;

&lt;p&gt;That loudness is exactly what makes reliability feel like quality. It isn't. It's half of quality, and it's the easy half.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Makes a Metric Valid?
&lt;/h2&gt;

&lt;p&gt;A metric is &lt;strong&gt;valid&lt;/strong&gt; when it measures the concept you claim it measures. Psychometricians call this &lt;em&gt;construct validity&lt;/em&gt;: the "construct" is the real thing you care about — health, aptitude, risk, security — and validity is how faithfully your number tracks it.&lt;/p&gt;

&lt;p&gt;Three general examples, in increasing order of how much they cost when they're wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A scale that is 2 kg off&lt;/strong&gt; reads 82.0 kg every time while you actually weigh 80. Perfectly reliable, invalid for "my actual weight." A calibration error doesn't reduce consistency at all — which is precisely why it survives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Standardized tests&lt;/strong&gt; produce famously reliable scores: retake next week, get nearly the same number. The century-old argument is entirely about validity — whether the score measures aptitude or measures practice at taking that kind of test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A call center reports 95% of calls answered within 20 seconds&lt;/strong&gt; — cleanly measured, stable month to month. But the customers who gave up before connecting were never in the denominator. The metric is valid for "answered calls, handled fast" and invalid for "customers who could reach us," and the gap between those two is where the complaints live.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This framing isn't mine. Messick's 1989 treatment made construct validity the organizing idea of measurement in the social sciences, and Jacobs &amp;amp; Wallach ported the framework to computational systems in 2021. The practitioner translation: every number you publish is implicitly a validity claim, whether you meant to make one or not.&lt;/p&gt;

&lt;p&gt;The hard part is that you cannot check validity by re-running. Reliability is verified from inside the measurement; validity needs an independent route to the construct — a second instrument you trust, a real-world outcome, a ground truth established some other way. You can be certain a number is stable and have no idea whether it is true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reliable, Valid, Both, or Neither?
&lt;/h2&gt;

&lt;p&gt;The standard picture is a dartboard. Where the darts land is your metric; the bullseye is the construct.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tight cluster on the bullseye&lt;/strong&gt; — reliable and valid. The goal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tight cluster in the wrong corner&lt;/strong&gt; — reliable, invalid. &lt;strong&gt;The dangerous quadrant&lt;/strong&gt;, because consistency masquerades as correctness. Nobody audits a number that never wobbles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scattered around the bullseye&lt;/strong&gt; — valid on average, unreliable. Annoying, but self-announcing: the wobble is visible, so it gets fixed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scattered and off-target&lt;/strong&gt; — neither. Usually caught fast, because nothing about it looks trustworthy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is the misconception this article exists to delete: &lt;strong&gt;"a metric that gives consistent numbers is a good metric."&lt;/strong&gt; Consistency is reliability; whether the number measures the right thing is validity — and they are independent. You can have either without the other, and quadrant 2 is where the expensive failures concentrate.&lt;/p&gt;

&lt;p&gt;My clearest picture of quadrant 2 is a silent zero. A fraud filter flags 0 of roughly 50,000 transactions every night for a week — the same 0 on every run, perfectly reproducible — because a data feed upstream had gone null and the model was scoring empty rows. The real fraud rate that week was about 0.8%. Nothing wobbled, so nothing got investigated; the stability &lt;em&gt;was&lt;/em&gt; the disguise. A number that never moves is not the same as a number that is right.&lt;/p&gt;

&lt;p&gt;One more force pushes metrics toward quadrant 2: optimization. When a number becomes a target, people and processes make it &lt;em&gt;more&lt;/em&gt; stable while draining its meaning — the number stays crisp as its connection to the construct erodes. That mechanism has &lt;a href="https://ofriperetz.dev/go/goodharts-law-explained?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;its own article on Goodhart's law&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is One Valid Metric Enough? Construct Coverage
&lt;/h2&gt;

&lt;p&gt;Validity is a property of a metric. &lt;strong&gt;Construct coverage&lt;/strong&gt; is the property of your metric &lt;em&gt;system&lt;/em&gt;: it is valid only if every failure mode that matters has at least one metric watching it.&lt;/p&gt;

&lt;p&gt;The expensive failures are usually not a bad metric — they're a missing one. A company can report revenue, gross margin, and burn rate — each audited, each reliable, each valid for its own construct — while no line anywhere tracks customer concentration. Then the single client that was 40% of revenue walks, and every number on the healthy dashboard was true right up to the moment it stopped mattering. A coverage hole is invisible from inside the dashboard, because every number you &lt;em&gt;do&lt;/em&gt; have looks fine. The metrics were excellent at answering questions nobody was about to ask.&lt;/p&gt;

&lt;p&gt;The audit generalizes to anything you ship or promise:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;List the claims.&lt;/strong&gt; Everything a spec sheet, a contract, a label, or a marketing page asserts — "safe under load," "accurate to ±1%," "no side effects," "holds up outside the sample it was built on."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For each claim, name the check that would falsify it.&lt;/strong&gt; Not the check that sits near it — the one that fails if the claim is false. A "survives a crash" promise needs a test that actually triggers the crash; a "works after the upgrade" promise needs a run on the upgraded system, not the one you happened to have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The diff is your exposure.&lt;/strong&gt; Every claim without a falsifying check is a promise you are making on vibes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the constructive move this whole distinction buys you: reliability tells you your instrument is steady; validity tells you it's pointed at something true; coverage tells you nothing important sits unwatched. Tom DeMarco needed a public recantation in 2009 to walk back decades of "you can't control what you can't measure" — measurement humility from one of the people who taught the industry to measure. The practical version of that humility is small: before you trust a stable number, ask what question it actually answers. Then ask which questions have no number 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;Property&lt;/th&gt;
&lt;th&gt;Question it answers&lt;/th&gt;
&lt;th&gt;How to check&lt;/th&gt;
&lt;th&gt;Failure smell&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Reliability&lt;/td&gt;
&lt;td&gt;Does re-measuring give the same number?&lt;/td&gt;
&lt;td&gt;Take it twice under identical conditions&lt;/td&gt;
&lt;td&gt;Wobbling numbers — loud, self-announcing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validity&lt;/td&gt;
&lt;td&gt;Does the number measure the concept claimed?&lt;/td&gt;
&lt;td&gt;Independent route to the construct (second instrument, real outcome, ground truth)&lt;/td&gt;
&lt;td&gt;Stable dashboard, surprised stakeholders&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reliable + invalid&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;The dangerous quadrant: consistency masquerading as correctness&lt;/td&gt;
&lt;td&gt;A number nobody audits because it never wobbles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Construct coverage&lt;/td&gt;
&lt;td&gt;Does every claim have a metric watching it?&lt;/td&gt;
&lt;td&gt;List claims → name the falsifying check for each → diff&lt;/td&gt;
&lt;td&gt;A stated guarantee with no check pointed at it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/goodharts-law-explained?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;Goodhart's Law, Explained&lt;/a&gt; — the mechanism that drives metrics into the reliable-but-invalid quadrant.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/proxy-metrics?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;Proxy Metrics&lt;/a&gt; — every proxy has a validity gap; that gap is this article's subject, measured.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/composite-scores-and-weighting?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;Composite Scores and Weighting&lt;/a&gt; — what happens to validity when you collapse many metrics into one.&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=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;Different Metrics for Different Package Types&lt;/a&gt; — the software worked example this idea was drawn from: a real construct-coverage hole, and the metric system built to close it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If this is the kind of reference you'll want at your next metrics review, &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;follow me on Dev.to&lt;/a&gt; and bookmark it — it's built to be cited, not skimmed once.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Messick, S. (1989). "Validity." In R. L. Linn (Ed.), &lt;em&gt;Educational Measurement&lt;/em&gt; (3rd ed., pp. 13–103). American Council on Education / Macmillan. The authoritative construct-validity treatment; the source of the modern view that validity is about the &lt;em&gt;interpretation&lt;/em&gt; of a score, not the score itself.&lt;/li&gt;
&lt;li&gt;Jacobs, A. Z., &amp;amp; Wallach, H. (2021). "Measurement and Fairness." &lt;em&gt;Proceedings of the ACM Conference on Fairness, Accountability, and Transparency (FAccT '21)&lt;/em&gt;. &lt;a href="https://ofriperetz.dev/go/r/ak24ceu5rq?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;arxiv.org/abs/1912.05511&lt;/a&gt;. Measurement theory applied to computational systems — the bridge between psychometrics and the numbers you actually ship.&lt;/li&gt;
&lt;li&gt;Trochim, W. M. K. &lt;em&gt;Research Methods Knowledge Base&lt;/em&gt;. &lt;a href="https://ofriperetz.dev/go/r/1syg5136tmw?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;conjointly.com/kb&lt;/a&gt;. Free online reference; the fastest concept-check for reliability/validity vocabulary, including the dartboard figure.&lt;/li&gt;
&lt;li&gt;DeMarco, T. (2009). "&lt;a href="https://ofriperetz.dev/go/r/rf2cdhh4bl?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;Software Engineering: An Idea Whose Time Has Come and Gone?&lt;/a&gt;" &lt;em&gt;IEEE Software&lt;/em&gt;, 26(4). The recantation of "you can't control what you can't measure" — metrics humility from one of the field's original measurement advocates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Foundations series: ← &lt;a href="https://ofriperetz.dev/go/inter-rater-agreement-cohens-kappa?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;Inter-Rater Agreement &amp;amp; Cohen's κ&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/16wihjeozse?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;hub&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/proxy-metrics?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;Proxy Metrics&lt;/a&gt; →&lt;/em&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=valid-vs-reliable-metrics" 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=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · npm: &lt;a href="https://ofriperetz.dev/go/r/29m8v07vw3v?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;@interlace&lt;/a&gt; · Follow: &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" 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=valid-vs-reliable-metrics" 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>
    <item>
      <title>Static Analysis vs. SAST vs. Linting: The Taxonomy That Matters for Security Teams</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sun, 19 Jul 2026 23:47:10 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/static-analysis-vs-sast-vs-linting-the-taxonomy-that-matters-for-security-teams-g5o</link>
      <guid>https://dev.to/ofri-peretz/static-analysis-vs-sast-vs-linting-the-taxonomy-that-matters-for-security-teams-g5o</guid>
      <description>&lt;p&gt;When a security dashboard files a single-file linter under "SAST tools," it's using "SAST" as a marketing umbrella for anything that inspects source code without running it — technically defensible, practically misleading.&lt;/p&gt;

&lt;p&gt;A lint rule fires in your editor in under a second. CodeQL runs on a pull request in minutes and can trace a tainted value through a dozen function calls across as many files. SonarQube keeps a persistent model of your codebase and tracks how an issue count moves across releases. Different scopes, different failure modes, different jobs in the pipeline — squeezed into one word that tells you none of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The taxonomy
&lt;/h2&gt;

&lt;p&gt;Three levels, broadest to narrowest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Static analysis&lt;/strong&gt; is any examination of source code without executing it — abstract interpretation, control-flow analysis, taint analysis, and everything below all qualify. Every tool in this piece is static analysis; the word alone tells you nothing about what a given tool actually catches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SAST&lt;/strong&gt; (Static Application Security Testing) narrows that to security vulnerabilities specifically, and adds three defining traits: it runs in CI/CD rather than at edit time, it traces values across function and file boundaries (interprocedural analysis), and it reports a finding with severity, &lt;a href="https://ofriperetz.dev/go/cwe-taxonomy-explained?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;CWE classification&lt;/a&gt;, and the data-flow path that proves it. CodeQL, Semgrep, and SonarQube Security are the common names.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Linting&lt;/strong&gt; narrows further, to syntactic and pattern-level checks meant to run while you're still writing the code. A lint rule works on a single file's &lt;a href="https://ofriperetz.dev/go/r/5valei5tw4?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;AST&lt;/a&gt; — the pluggable, per-file architecture Nicholas C. Zakas built ESLint around in 2013 — with no cross-file data flow, and it has to finish in milliseconds to be worth having in the editor. It reports patterns: style violations, correctness bugs, and the security mistakes that are visible from inside one file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security linting&lt;/strong&gt; — the category a dedicated security linter belongs to — is linting aimed at security patterns specifically: same edit-time speed, same single-file scope, but pattern-matching for constructs like string concatenation at a SQL call site, &lt;code&gt;Math.random()&lt;/code&gt; generating a token, or a missing JWT verify call. Security linting at edit time. Not a SAST replacement.&lt;/p&gt;




&lt;h2&gt;
  
  
  What each level detects
&lt;/h2&gt;

&lt;p&gt;The differences get concrete on one vulnerability, shown two ways.&lt;/p&gt;

&lt;h3&gt;
  
  
  The pattern a security linter catches
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vulnerable.js&lt;/span&gt;
&lt;span class="nx"&gt;app&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="s1"&gt;/users/:id&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&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;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM users WHERE id = &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&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="c1"&gt;//                                                 ^^^^^^^^^^^^^^^^^^&lt;/span&gt;
  &lt;span class="c1"&gt;//            ESLint sees: string + user-controlled value at a db.query call&lt;/span&gt;
  &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="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 linter sees the concatenation at the call site — source (&lt;code&gt;req.params.id&lt;/code&gt;) and sink (&lt;code&gt;pool.query&lt;/code&gt;) both visible in the same file, adjacent lines. A security rule fires on the string-built SQL and attaches CWE-89. The developer sees the warning before they commit.&lt;/p&gt;

&lt;h3&gt;
  
  
  The multi-hop path that needs taint analysis
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// routes/users.js&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;buildQuery&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="s1"&gt;../db/query-builder.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;app&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="s1"&gt;/users/:id&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&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;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&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="c1"&gt;// source: user-controlled&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sanitized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;              &lt;span class="c1"&gt;// looks sanitized — only strips whitespace&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sanitized&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// tainted value crosses file boundary&lt;/span&gt;
  &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// db/query-builder.js&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`SELECT * FROM &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; WHERE id = &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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// template injection&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A lint rule scanning &lt;code&gt;routes/users.js&lt;/code&gt; sees &lt;code&gt;userId.trim()&lt;/code&gt; and a function call — the concatenation itself sits one file away, inside &lt;code&gt;buildQuery()&lt;/code&gt;. The call site looks clean; the rule doesn't fire. (And it isn't safe: &lt;code&gt;.trim()&lt;/code&gt; removes whitespace, not SQL — &lt;code&gt;1; DROP TABLE users--&lt;/code&gt; passes through untouched and reaches the template intact. Had the code used &lt;code&gt;parseInt(userId, 10)&lt;/code&gt; the payload really would have collapsed to the integer &lt;code&gt;1&lt;/code&gt;; the trap is the sanitizer that only &lt;em&gt;looks&lt;/em&gt; like one.) CodeQL traces &lt;code&gt;req.params.id&lt;/code&gt; → &lt;code&gt;userId&lt;/code&gt; → &lt;code&gt;sanitized&lt;/code&gt; → &lt;code&gt;query&lt;/code&gt; inside &lt;code&gt;buildQuery&lt;/code&gt; → &lt;code&gt;pool.query&lt;/code&gt;, and files a CWE-89 finding with the full path as evidence.&lt;/p&gt;

&lt;p&gt;Same vulnerability, two different visibility ceilings — not a defect in either tool, just two levels of the taxonomy doing what they're built to do.&lt;/p&gt;




&lt;h2&gt;
  
  
  Time-in-pipeline analysis
&lt;/h2&gt;

&lt;p&gt;Capability isn't the only axis that matters — timing is. Security linting fires at edit time, under a second. A light SAST pass on a PR takes one to five minutes. A deep nightly scan runs thirty minutes to hours. That gap changes what a developer actually does with a finding: a three-minute CI wait means they've already pushed the branch and moved on to reviewing someone else's PR by the time the result lands. A half-second lint warning arrives while their hands are still on the keyboard, in the same context as the line that triggered it.&lt;/p&gt;

&lt;p&gt;Security linting and SAST aren't competing for the same job. They sit at different moments in the workflow, at different depth, for different cost — which is why a mature pipeline runs both instead of picking one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why a security linter is not a SAST tool
&lt;/h2&gt;

&lt;p&gt;A security linter carries the constraints of its category on purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Speed and scope.&lt;/strong&gt; Rules run in milliseconds, on one file's AST, because that's what edit-time feedback requires — any rule needing cross-file data flow would break the guarantee. A linter can't trace &lt;code&gt;userInput&lt;/code&gt; through five function calls across three files; it pattern-matches on argument names, import shapes, and option keys instead of building a program-semantics model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Domain depth over breadth.&lt;/strong&gt; Where a security linter earns its keep is depth inside specific library surfaces: JWT verification via &lt;code&gt;jsonwebtoken&lt;/code&gt;, parameterized queries via &lt;code&gt;pg&lt;/code&gt;, SSRF sinks in &lt;code&gt;node-fetch&lt;/code&gt; and &lt;code&gt;axios&lt;/code&gt;, unsafe deserialization in &lt;code&gt;serialize-javascript&lt;/code&gt;. The rule knows the library's dangerous shapes and pattern-matches them within a single call site — narrow, but precise where it looks. That's the honest claim: security linting at edit time, covering what linting can structurally detect — not a replacement for CodeQL, Semgrep, or SonarQube's interprocedural coverage.&lt;/p&gt;

&lt;p&gt;A vendor's "SAST" label isn't false — at maximum breadth "SAST" does mean "any source-code security analysis." I've stopped being annoyed that it's technically defensible: a tide pool and the open ocean are both "water," and that stays true right up until you have to decide where it's safe to swim. What the label discards is the one distinction that tells you which tool to reach for — a pattern match at edit time, or a proven data-flow path at CI time.&lt;/p&gt;

&lt;p&gt;The precise label is &lt;strong&gt;security linter&lt;/strong&gt;: lint speed, lint scope, right for edit-time and CI, not a substitute for taint analysis. Where that structural ceiling comes from, and how precision and recall diverge under it, is the subject of &lt;a href="https://ofriperetz.dev/go/taint-vs-heuristic-detection?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;Taint vs. Heuristic Detection&lt;/a&gt; and &lt;a href="https://ofriperetz.dev/go/precision-recall-f1-for-static-analysis?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;Precision, Recall, and F1 for Static Analysis&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The recommended stack
&lt;/h2&gt;

&lt;p&gt;A complete Node.js security pipeline uses four positions — not redundant, each catches a different category at a different cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Edit time — security linting (ESLint plus a security plugin).&lt;/strong&gt; Catches obvious single-file patterns at developer speed, zero CI wait.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Pre-commit / pre-push — CI linting.&lt;/strong&gt; Same rules, enforced as a hard gate — catches what a skipped editor integration let through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. PR check — SAST (CodeQL or Semgrep).&lt;/strong&gt; Taint analysis across the diff and its call graph; catches multi-hop injection that spans files and imports, before merge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Nightly / weekly — deep SAST or DAST&lt;/strong&gt; (SonarQube, Checkmarx, OWASP ZAP on staging). Full-codebase analysis, drift tracking across releases, and — for DAST — the runtime behaviors no source read can reach.&lt;/p&gt;

&lt;p&gt;Teams that skip positions 1–2 pay for 3–4 to catch what the editor could have blocked for free. Teams that skip 3–4 leave every multi-hop taint path undetected. A complete pipeline runs all four.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Named misconception:&lt;/strong&gt; "ESLint security plugins are SAST tools." They're security linters — the subset of static analysis that runs at lint speed and lint scope, without interprocedural data flow or taint tracking. The confusion comes from vendors using "SAST" as an umbrella term for any source-code security analysis, which is technically accurate and practically uninformative.&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;Category&lt;/th&gt;
&lt;th&gt;Example tools&lt;/th&gt;
&lt;th&gt;When runs&lt;/th&gt;
&lt;th&gt;Cross-file&lt;/th&gt;
&lt;th&gt;Taint tracking&lt;/th&gt;
&lt;th&gt;Speed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Linting&lt;/td&gt;
&lt;td&gt;ESLint, Pylint, RuboCop&lt;/td&gt;
&lt;td&gt;Edit time&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Under 1 sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security linting&lt;/td&gt;
&lt;td&gt;eslint-plugin-security, Bandit&lt;/td&gt;
&lt;td&gt;Edit time&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Under 1 sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SAST — light&lt;/td&gt;
&lt;td&gt;Semgrep, CodeQL default&lt;/td&gt;
&lt;td&gt;PR check&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;1–5 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SAST — deep&lt;/td&gt;
&lt;td&gt;Checkmarx, Veracode, Snyk Code&lt;/td&gt;
&lt;td&gt;Weekly&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;30 min+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DAST&lt;/td&gt;
&lt;td&gt;OWASP ZAP, Burp Suite&lt;/td&gt;
&lt;td&gt;QA / staging&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;Minutes–hours&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;DAST (Dynamic Application Security Testing) tests a running application, not source — out of scope here, but it catches categories, auth state, session management, server-side rendering injection, that no source-code read can reach.&lt;/em&gt;&lt;/p&gt;




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

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Chess, B., &amp;amp; West, J. (2007). &lt;em&gt;&lt;a href="https://ofriperetz.dev/go/r/1y0jz49es4u?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;Secure Programming with Static Analysis&lt;/a&gt;&lt;/em&gt;. Addison-Wesley. The foundational industrial treatment of static analysis for security — data-flow and taint techniques, rule design, and the false-positive economics that separate a linter from a SAST engine.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;NIST IR 8397: &lt;em&gt;&lt;a href="https://ofriperetz.dev/go/r/1ejx87mlrw5?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;Guidelines on Minimum Standards for Developer Verification of Software&lt;/a&gt;&lt;/em&gt;. National Institute of Standards and Technology, 2021. nvlpubs.nist.gov/nistpubs/ir/2021/NIST.IR.8397.pdf. Note that NIST itself uses "static analysis" and "SAST" interchangeably here — even listing ESLint among its example SAST tools — which is itself a data point on how loosely the industry applies the label.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://ofriperetz.dev/go/r/222q1ikavwm?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;OWASP Source Code Analysis Tools&lt;/a&gt;. owasp.org/www-community/Source_Code_Analysis_Tools. OWASP's community-maintained list of source-code (SAST) analysis tools. For the automated-analysis-vs-manual-review distinction specifically, see OWASP's &lt;a href="https://ofriperetz.dev/go/r/1n8jtd57ez1?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;Static Code Analysis&lt;/a&gt; control page.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Livshits, B., &amp;amp; Lam, M. S. (2005). &lt;a href="https://ofriperetz.dev/go/r/2gddhspwbrs?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;Finding security vulnerabilities in Java applications with static analysis&lt;/a&gt;. &lt;em&gt;USENIX Security Symposium&lt;/em&gt;, 14. The structural basis for what makes taint analysis different from pattern matching.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Zakas, N. C. (2013, July 16). Introducing ESLint. humanwhocodes.com. &lt;a href="https://humanwhocodes.com/blog/2013/07/16/introducing-eslint/" rel="noopener noreferrer"&gt;https://humanwhocodes.com/blog/2013/07/16/introducing-eslint/&lt;/a&gt;. The original announcement of ESLint's pluggable, per-file rule architecture — the design decision this whole taxonomy hangs on.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;This is the reference for the next time a scan result gets called "SAST" and it's actually a lint rule — bookmark it, and &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;follow me on Dev.to&lt;/a&gt; for the rest of the Foundations series.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Foundations series: ← &lt;a href="https://ofriperetz.dev/go/taint-vs-heuristic-detection?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;Taint vs. Heuristic Detection&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/16wihjeozse?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;hub&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/confusion-matrix-tp-fp-fn-tn?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;start over: The Confusion Matrix&lt;/a&gt; →&lt;/em&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=static-analysis-vs-sast-vs-linting" 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=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · npm: &lt;a href="https://ofriperetz.dev/go/r/29m8v07vw3v?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;@interlace&lt;/a&gt; · Follow: &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" 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=static-analysis-vs-sast-vs-linting" 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>
    <item>
      <title>Sample Size and Statistical Power: What a Small Sample Can and Cannot Tell You</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sun, 19 Jul 2026 23:46:47 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/sample-size-and-statistical-power-what-a-small-sample-can-and-cannot-tell-you-p5h</link>
      <guid>https://dev.to/ofri-peretz/sample-size-and-statistical-power-what-a-small-sample-can-and-cannot-tell-you-p5h</guid>
      <description>&lt;p&gt;A fraud filter that catches zero of the last 200 confirmed-fraudulent charges needs no p-value to convict it. A trading strategy that beat its benchmark by five points last quarter might be genuine skill — or the kind of luck that evaporates by spring. Same data, same afternoon of analysis, two completely different standards of proof.&lt;/p&gt;

&lt;p&gt;A test that returns zero detections across every known case of the thing it claims to catch doesn't need a significance test; that zero is a structural fact, and no amount of re-sampling changes it. But ask a subtler question — is a five-point edge real, or is it sampling noise — and the honest answer depends on numbers a summary table never shows: effect size, variance, statistical power. Knowing which of those two questions you're asking is the whole article.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Two Questions a Small Sample Can Answer
&lt;/h2&gt;

&lt;p&gt;The first question: does this detector catch the failure at all?&lt;/p&gt;

&lt;p&gt;A screening test that returns zero hits across every confirmed case of a disease doesn't need a significance test — you just need a sample large enough that one hit was structurally possible, and even a handful of cases clears that floor. Add forty more known-positive cases, still get zero, and the conclusion doesn't move; the same zero just grows more confident.&lt;/p&gt;

&lt;p&gt;The second question is different in kind: is the gap between two contenders large enough to be real? (A composite score like an F1 rests on the same true-positive / false-positive bookkeeping covered in the &lt;a href="https://ofriperetz.dev/go/confusion-matrix-tp-fp-fn-tn?utm_source=devto&amp;amp;from=sample-size-and-statistical-power" rel="noopener noreferrer"&gt;confusion matrix article&lt;/a&gt;.) A five-point difference on a small sample could be genuine — or a fluctuation that vanishes if you swap three cases or re-run on a slightly different set. You cannot tell which without knowing the effect size.&lt;/p&gt;

&lt;p&gt;The distinction underneath both: not every result carries sampling variability. Whether a deterministic test fires on a fixed, known input is a fact, not a draw from a distribution — no noise in that outcome to test. Statistical tests exist for questions where repeated sampling would produce a spread of answers. Run the same fixed case through the same check twice and you get the same output; a p-value on that has nothing left to measure.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Statistical Power Actually Means
&lt;/h2&gt;

&lt;p&gt;Statistical power is the probability that a test detects a real effect when one genuinely exists. If the true gap between two contenders is fifteen points and you test it on a sample of forty, how often does the test come back significant? That probability is the power — the field's standard target is 80%, meaning you will still miss one in five real effects. Fine for an exploratory look; too low for a decision you are about to ship.&lt;/p&gt;

&lt;p&gt;Three things move it: more observations sharpen the test's sensitivity to smaller effects; larger true effects are easier to detect than small ones; and a stricter significance threshold (α = 0.01 instead of 0.05) cuts false positives but demands a bigger sample to compensate.&lt;/p&gt;

&lt;p&gt;Cohen (1988) gives the standard effect-size conventions — small = 0.2, medium = 0.5, large = 0.8 (Cohen's d). At 80% power and α = 0.05, detecting a medium effect needs roughly 64 observations per group; a small effect needs roughly 400.&lt;/p&gt;

&lt;p&gt;I learned this at a poker table before I ever met it in a textbook: a player up after forty hands has proven nothing. Variance alone can float a mediocre player for an evening and sink a strong one. The edge — the thing you actually want to measure — only surfaces over thousands of hands. There, sample size isn't a technicality; it's the whole difference between reading skill and reading a good night. n is hands, and forty hands is a story, not a measurement.&lt;/p&gt;

&lt;p&gt;The same arithmetic decides which comparisons a report can honestly make. Two diagnostic tests scoring 95% and 45% differ by fifty points — so large any sample of forty detects it. Two tests eight points apart — 63% against 55% — sit close enough that forty cases struggle to separate them from noise without a power calculation first. This is why a careful report leads with the categorical claim ("misses the entire category") and refuses to rank near-neighbours by a handful of points.&lt;/p&gt;




&lt;h2&gt;
  
  
  When Variability Is Real: The Chi-Squared Case
&lt;/h2&gt;

&lt;p&gt;The opposite situation looks similar on the surface and needs the opposite treatment. Suppose you A/B-test three versions of a checkout page — call their conversion rates 68%, 71%, and 73% over a few hundred sessions each. The eye wants to crown the 73%. Whether the rate truly differs between versions needs a chi-squared test on the counts; run it and it comes back with a small statistic and p ≈ 0.4 — no significant difference, and the three-way ranking your eye drew is noise.&lt;/p&gt;

&lt;p&gt;That test earns its p-value precisely because each visitor is a genuine random draw — the sampling story a hand-built checklist never has. &lt;a href="https://ofriperetz.dev/go/statistical-significance-p-value?utm_source=devto&amp;amp;from=sample-size-and-statistical-power" rel="noopener noreferrer"&gt;Statistical Significance and p-Values&lt;/a&gt; owns the full contingency-table walkthrough, including the paired McNemar's-test version for comparing two tools on the same cases.&lt;/p&gt;




&lt;h2&gt;
  
  
  When Your Test Cases Aren't a Random Sample
&lt;/h2&gt;

&lt;p&gt;This is where most people trip: a confidence interval requires the test set to be a random sample from some population. A hand-built set of cases is not one.&lt;/p&gt;

&lt;p&gt;Think of a driving examiner's checklist of manoeuvres, a medical board's curated question bank, or a chess coach's hand-picked tactics puzzles. Each is deliberately constructed to cover chosen categories — which bounds a specific kind of validity (the set covers the categories you chose) without giving you a random draw from the space of situations that show up in the real world. Conflating the two manufactures false precision.&lt;/p&gt;

&lt;p&gt;A confidence interval on "score over these forty cases" is computable and practically meaningless: the formula assumes repeated draws from a distribution, and deliberate test cases aren't that. The same problem sinks a p-value on "is performance significantly above 50% here" — it tells you whether you beat a coin flip on these particular cases, not on the next real one you face, because the two don't share a distribution.&lt;/p&gt;

&lt;p&gt;What a constructed set is legitimately good for: exact, observational counts — did it catch this case, did it miss an entire category. What it cannot support: a confidence interval or significance claim about performance beyond the cases in it. The table below draws that line for every question type here.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Practical Rules
&lt;/h2&gt;

&lt;p&gt;Four rules for reading any benchmark, audit, or scorecard:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Category absence is evidence without statistics.&lt;/strong&gt; Zero out of N on a whole category needs no t-test — the tool has no coverage of that class of problem, full stop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Only run significance tests on a genuine sample.&lt;/strong&gt; A deliberately built set can't support a p-value about real-world generalization; outcomes drawn or generated at random, like the three-variant test above, can. Know which situation you're in before you reach for the test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. State your n before your conclusion.&lt;/strong&gt; "Forty cases, missed the entire category" is complete on its own. "Forty cases, 5% higher score" needs a power calculation before it means anything — the sample size is part of the finding, not a footnote to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Report null results.&lt;/strong&gt; "No significant difference" is an honest finding, not a result to bury. Publishing only the comparisons that reached significance is publication bias — the same distortion covered in &lt;a href="https://ofriperetz.dev/go/bias-in-measurement?utm_source=devto&amp;amp;from=sample-size-and-statistical-power" rel="noopener noreferrer"&gt;Bias in Measurement&lt;/a&gt;, operating at the analysis stage instead of during data collection.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Named misconception:&lt;/strong&gt; "A larger sample always gives more accurate results." A larger sample buys more power to detect small effects. It does not turn a deliberately constructed set into a random sample, and no sample size fixes that. Sample size and sampling method are different problems, and conflating them is what manufactures false precision.&lt;/p&gt;
&lt;/blockquote&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 type&lt;/th&gt;
&lt;th&gt;Needs significance test?&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Does the tool detect this failure at all?&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Presence/absence, not magnitude — zero is zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Which of two tools scores higher?&lt;/td&gt;
&lt;td&gt;Depends on effect size&lt;/td&gt;
&lt;td&gt;Run a power calculation before claiming the difference is real&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Do three groups have different rates?&lt;/td&gt;
&lt;td&gt;Yes (random sample)&lt;/td&gt;
&lt;td&gt;Random draws + categorical comparison — chi-squared appropriate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is our score significantly above 90% on a fixed test set?&lt;/td&gt;
&lt;td&gt;No (constructed set)&lt;/td&gt;
&lt;td&gt;Not a random sample; a CI would be false precision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Did it miss an entire category?&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Observational count — report the zeros directly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is a 5-point improvement real?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Small effect; small n has low power; significance test or power calc required&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;If this is the kind of page you'll want open mid-argument, bookmark it — and &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=sample-size-and-statistical-power" rel="noopener noreferrer"&gt;follow me on Dev.to&lt;/a&gt; to catch the next one in the series.&lt;/em&gt;&lt;/p&gt;




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

&lt;p&gt;Cohen, J. (1988). &lt;a href="https://ofriperetz.dev/go/r/1w0shr0zowc?utm_source=devto&amp;amp;from=sample-size-and-statistical-power" rel="noopener noreferrer"&gt;&lt;em&gt;Statistical Power Analysis for the Behavioral Sciences&lt;/em&gt;&lt;/a&gt; (2nd ed.). Lawrence Erlbaum Associates. The foundational book on power analysis — defines the small/medium/large effect size conventions (d = 0.2 / 0.5 / 0.8) used throughout statistics and cited in this article.&lt;/p&gt;

&lt;p&gt;Lakens, D. (2013). &lt;a href="https://ofriperetz.dev/go/r/ojmuy6ae2q?utm_source=devto&amp;amp;from=sample-size-and-statistical-power" rel="noopener noreferrer"&gt;Calculating and reporting effect sizes to facilitate cumulative science: a practical primer for t-tests and ANOVAs&lt;/a&gt;. &lt;em&gt;Frontiers in Psychology&lt;/em&gt;, 4, 863. Accessible practical guide to effect size and statistical power — freely available online and the clearest non-textbook treatment of the topic.&lt;/p&gt;

&lt;p&gt;Faul, F., Erdfelder, E., Lang, A.-G., &amp;amp; Buchner, A. (2007). &lt;a href="https://ofriperetz.dev/go/r/z2bywuku0q?utm_source=devto&amp;amp;from=sample-size-and-statistical-power" rel="noopener noreferrer"&gt;G*Power 3: A flexible statistical power analysis program for the social, behavioral, and biomedical sciences&lt;/a&gt;. &lt;em&gt;Behavior Research Methods&lt;/em&gt;, 39(2), 175–191. The paper behind the G*Power tool (freely available at gpower.hhu.de) — the standard software for the power calculations referenced in this article.&lt;/p&gt;

&lt;p&gt;Head, M. L., Holman, L., Lanfear, R., Kahn, A. T., &amp;amp; Jennions, M. D. (2015). &lt;a href="https://ofriperetz.dev/go/r/2atbo33llhe?utm_source=devto&amp;amp;from=sample-size-and-statistical-power" rel="noopener noreferrer"&gt;The extent and consequences of p-hacking in science&lt;/a&gt;. &lt;em&gt;PLOS Biology&lt;/em&gt;, 13(3). On what goes wrong when significance tests are applied carelessly — the publication bias and null-result-suppression problem that motivates rule four above.&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;Builds on&lt;/strong&gt; — &lt;a href="https://ofriperetz.dev/go/confusion-matrix-tp-fp-fn-tn?utm_source=devto&amp;amp;from=sample-size-and-statistical-power" rel="noopener noreferrer"&gt;The Confusion Matrix&lt;/a&gt;: the four counts every score in this article is ultimately built from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read next&lt;/strong&gt; — &lt;a href="https://ofriperetz.dev/go/statistical-significance-p-value?utm_source=devto&amp;amp;from=sample-size-and-statistical-power" rel="noopener noreferrer"&gt;Statistical Significance and p-Values&lt;/a&gt;: what a p-value means, the full chi-squared walkthrough, and the paired McNemar's test for comparing two tools on one set.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cited by&lt;/strong&gt; — &lt;a href="https://ofriperetz.dev/go/eslint-security-fn-fp-benchmark?utm_source=devto&amp;amp;from=sample-size-and-statistical-power" rel="noopener noreferrer"&gt;The FP-Tax Benchmark&lt;/a&gt; and &lt;a href="https://ofriperetz.dev/go/we-ranked-5-ai-models-by-security-the-leaderboard-is-wrong?utm_source=devto&amp;amp;from=sample-size-and-statistical-power" rel="noopener noreferrer"&gt;We Ranked 5 AI Models by Security&lt;/a&gt; — the evidence articles whose worked numbers (the leaderboard gaps, the null-result chi-squared) live where the product proof belongs. See also &lt;a href="https://ofriperetz.dev/go/bias-in-measurement?utm_source=devto&amp;amp;from=sample-size-and-statistical-power" rel="noopener noreferrer"&gt;Bias in Measurement&lt;/a&gt; for the publication-bias angle.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Foundations series: ← &lt;a href="https://ofriperetz.dev/go/reproducibility-vs-replicability?utm_source=devto&amp;amp;from=sample-size-and-statistical-power" rel="noopener noreferrer"&gt;Reproducibility vs replicability&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/16wihjeozse?utm_source=devto&amp;amp;from=sample-size-and-statistical-power" rel="noopener noreferrer"&gt;hub&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/statistical-significance-p-value?utm_source=devto&amp;amp;from=sample-size-and-statistical-power" rel="noopener noreferrer"&gt;p-values &amp;amp; significance&lt;/a&gt; →&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part of the Interlace ESLint ecosystem. &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint-benchmark-suite?utm_source=devto&amp;amp;from=sample-size-and-statistical-power" rel="noopener noreferrer"&gt;Source on GitHub&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/y5yxrpx88n?utm_source=devto&amp;amp;from=sample-size-and-statistical-power" rel="noopener noreferrer"&gt;npm&lt;/a&gt; · Follow: &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=sample-size-and-statistical-power" 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=sample-size-and-statistical-power" 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>
    <item>
      <title>Proxy Metrics: The Number You Optimize Is Not the Thing You Want</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sun, 19 Jul 2026 23:46:24 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/proxy-metrics-the-number-you-optimize-is-not-the-thing-you-want-kml</link>
      <guid>https://dev.to/ofri-peretz/proxy-metrics-the-number-you-optimize-is-not-the-thing-you-want-kml</guid>
      <description>&lt;p&gt;Every number on a dashboard is a proxy. GDP stands in for a nation's prosperity, yet it counts a car crash and the cleanup afterward as growth, while a parent raising three children for free adds nothing to it. A blood-pressure reading stands in for cardiovascular health, yet it climbs the instant a nervous patient sits down in the cuff. A credit score stands in for whether a borrower will repay, yet it cannot see the lifelong renter who simply never borrowed. Each of these is the best single number anyone has, and each is wrong in a way the number itself will never show you.&lt;/p&gt;

&lt;p&gt;I reach for numbers like these anyway. Refusing proxies is not discipline; it is refusing to measure, because almost nothing worth wanting can be measured directly. The discipline is knowing, precisely, what each number fails to measure.&lt;/p&gt;

&lt;p&gt;I learned the distinction from markets before I ever learned it from a dashboard. A stock's price is a proxy for a business's worth — precise to the cent, quoted all day, and routinely wrong. Mr. Market hands you a fresh number every morning; the discipline is remembering the number is his mood, not the company. I read every metric that way now. GDP is not prosperity, a download is not a decision, and the morning you forget the gap is the morning you start buying the map instead of the territory.&lt;/p&gt;




&lt;h2&gt;
  
  
  What a Proxy Metric Is
&lt;/h2&gt;

&lt;p&gt;A proxy metric is a measurable stand-in for something you cannot measure directly. The thing you actually want — measurement theory calls it the &lt;em&gt;construct&lt;/em&gt; — is almost always abstract: "health," "prosperity," "skill," "quality," "security." What you can observe is a correlate: blood pressure, GDP, a credit score, a customer-satisfaction survey, a scan for known-bad patterns. The correlate is the proxy. The construct is why you bothered.&lt;/p&gt;

&lt;p&gt;This is the normal condition of measurement, not a defect. Medicine runs on it: a drug trial may track a &lt;em&gt;surrogate endpoint&lt;/em&gt; — lower blood sugar, a shrinking tumor — because the real endpoint, living longer, takes years to observe. Sometimes the surrogate moves and the patient does not. A class of anti-arrhythmic drugs once corrected the irregular heartbeats they targeted while quietly raising deaths: the proxy improved and the construct got worse at the same time. The pattern is identical across domains; only the nouns change.&lt;/p&gt;

&lt;p&gt;Value investing has the cleanest statement of the idea. "Price is what you pay; value is what you get," as Warren Buffett put it — the market prints a precise number all day, and that number is a proxy for a value nobody can observe directly. Investors who forget the distinction buy prices. The rest of us optimize dashboards.&lt;/p&gt;

&lt;p&gt;A proxy is &lt;em&gt;good&lt;/em&gt; exactly to the degree that it tracks its construct — a question of &lt;a href="https://ofriperetz.dev/go/valid-vs-reliable-metrics?utm_source=devto&amp;amp;from=proxy-metrics" rel="noopener noreferrer"&gt;validity, not reliability&lt;/a&gt;. A metric can be perfectly reliable, returning the same number on every run, while measuring the wrong thing every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Proxy Gap
&lt;/h2&gt;

&lt;p&gt;The proxy gap is the distance between what a metric actually measures and what you read it as. Two properties make it dangerous.&lt;/p&gt;

&lt;p&gt;First, the gap is invisible in the number itself. A statistics office reports 3% GDP growth; nothing in the figure tells you whether it came from rising wages or from rebuilding after a flood. The gap lives entirely outside the data, in the difference between the measurement procedure and the construct — so no amount of staring at the metric reveals it.&lt;/p&gt;

&lt;p&gt;Second, the gap widens under optimization pressure. This is &lt;a href="https://ofriperetz.dev/go/goodharts-law-explained?utm_source=devto&amp;amp;from=proxy-metrics" rel="noopener noreferrer"&gt;Goodhart's Law&lt;/a&gt; doing its work: the moment you target a proxy, you improve it by the cheapest available route, and the cheapest route rarely runs through the construct. A school told to raise its average exam score can teach better — or it can quietly counsel its twenty weakest students to stay home on test day. The second route is cheaper.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Named misconception:&lt;/strong&gt; "If the metric goes up, the thing improved." The proxy moved. Whether the construct moved is a separate empirical question — and under optimization pressure the two actively diverge, because the cheapest way to move a proxy usually runs around the construct, not through it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where Proxies Famously Fail
&lt;/h2&gt;

&lt;p&gt;The failure pattern is old and well documented. Lines of code as a measure of programmer productivity is the ancestral software case: reward lines, receive lines — bloated code, duplicated logic, resistance to deletion. Tom DeMarco, who helped canonize measurement-driven software management, spent his 2009 &lt;em&gt;IEEE Software&lt;/em&gt; retrospective walking back how much of engineering should be governed by metrics at all.&lt;/p&gt;

&lt;p&gt;Jerry Muller's &lt;em&gt;The Tyranny of Metrics&lt;/em&gt; (2018) is a book-length catalog of the same pattern outside software. His best-known case: score surgeons on their patients' mortality rates, and some surgeons stop operating on the sickest patients. The proxy improves while the construct — care for the people who most need it — gets worse. Muller finds the identical shape in policing (arrests over safety), in universities (citation counts over scholarship), and across public administration.&lt;/p&gt;

&lt;p&gt;Software delivery produced its own famous proxy set with the DORA metrics (Forsgren, Humble &amp;amp; Kim, &lt;em&gt;Accelerate&lt;/em&gt;, 2018). Deployment frequency stands in for delivery health; teams game it by slicing one release into ten trivial deploys — which is exactly why DORA pairs its speed metrics against stability metrics like change-failure rate. Forsgren then co-authored the SPACE framework (2021), which builds "no single metric" in as a founding principle rather than a caveat — the closest prior art to the counter-metric rule below, applied one metric at a time instead of at framework scale.&lt;/p&gt;

&lt;p&gt;The audit generalizes to any dashboard: write down what a number &lt;em&gt;actually&lt;/em&gt; measures next to what you &lt;em&gt;wish&lt;/em&gt; it meant, and the gap becomes hard to un-see. Body-mass index measures weight over height squared, yet a muscular athlete and a sedentary desk worker can both post a BMI of 27 while sharing almost nothing about their health — a proxy built to describe populations, routinely misread as a verdict on an individual.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Choose a Proxy
&lt;/h2&gt;

&lt;p&gt;Two rules cover most of the discipline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 1: prefer the proxy closest to the construct that you can afford to measure.&lt;/strong&gt; Risk-adjusted mortality sits closer to &lt;em&gt;quality of care&lt;/em&gt; than raw mortality, because it stops rewarding case-selection. Body-fat percentage sits closer to &lt;em&gt;health&lt;/em&gt; than BMI. Closer proxies almost always cost more to collect — more instrumentation, more time, more follow-up — which is exactly why the cheap, distant ones dominate dashboards. Treat that as a cost decision you are making, not a fact of nature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 2: pair every proxy with a counter-metric that catches its gaming.&lt;/strong&gt; Deployment frequency pairs with change-failure rate. A sales team's &lt;em&gt;calls made&lt;/em&gt; pairs with &lt;em&gt;deals closed&lt;/em&gt;, so nobody wins by dialing numbers and hanging up. A model that looks flawless on the data it was tuned on pairs with a test on data it has never seen — because the &lt;a href="https://ofriperetz.dev/go/base-rate-problem-explained?utm_source=devto&amp;amp;from=proxy-metrics" rel="noopener noreferrer"&gt;base rate&lt;/a&gt; of what you are hunting is rarely the same in the wild as in the sample you built on. A proxy with no counter-metric is an unfalsifiable claim wearing a number.&lt;/p&gt;

&lt;p&gt;Proxies are not the enemy. They are the only measurements you will ever have, and the honest dashboard is not the one with the fewest numbers — it is the one whose owner can state each number's gap out loud. The next problem arrives immediately after: you hold five honest proxies and a decision that needs one number. Merging them means choosing weights, and weights are opinions — that is the subject of &lt;a href="https://ofriperetz.dev/go/composite-scores-and-weighting?utm_source=devto&amp;amp;from=proxy-metrics" rel="noopener noreferrer"&gt;composite scores and weighting&lt;/a&gt;, next in this series.&lt;/p&gt;

&lt;p&gt;If this page earns a bookmark, take it — and &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=proxy-metrics" rel="noopener noreferrer"&gt;follow me on Dev.to&lt;/a&gt; to catch the next canonical in the measurement series as it lands.&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;Proxy&lt;/th&gt;
&lt;th&gt;Construct it stands in for&lt;/th&gt;
&lt;th&gt;Known gap&lt;/th&gt;
&lt;th&gt;Counter-metric pairing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GDP&lt;/td&gt;
&lt;td&gt;National prosperity&lt;/td&gt;
&lt;td&gt;Counts disasters and cleanup as growth&lt;/td&gt;
&lt;td&gt;Median wage · wellbeing surveys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stock price&lt;/td&gt;
&lt;td&gt;A business's worth&lt;/td&gt;
&lt;td&gt;Sentiment, momentum, and mood&lt;/td&gt;
&lt;td&gt;Owner earnings · free cash flow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Body-mass index&lt;/td&gt;
&lt;td&gt;Health&lt;/td&gt;
&lt;td&gt;Muscle reads as fat; built for populations&lt;/td&gt;
&lt;td&gt;Body-fat % · metabolic panel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standardized test score&lt;/td&gt;
&lt;td&gt;Learning&lt;/td&gt;
&lt;td&gt;Teaching to the test; roster gaming&lt;/td&gt;
&lt;td&gt;Transfer tasks · later-life outcomes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy frequency&lt;/td&gt;
&lt;td&gt;Delivery health&lt;/td&gt;
&lt;td&gt;Trivial-deploy slicing&lt;/td&gt;
&lt;td&gt;Change-failure rate (DORA's own pairing)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lines of code&lt;/td&gt;
&lt;td&gt;Productivity&lt;/td&gt;
&lt;td&gt;Rewards verbosity, punishes deletion&lt;/td&gt;
&lt;td&gt;None — retire it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




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

&lt;p&gt;Goodhart, C. A. E. (1975). "Problems of Monetary Management: The UK Experience." &lt;em&gt;Papers in Monetary Economics&lt;/em&gt;, Reserve Bank of Australia. The original observation that statistical regularities collapse once used for control — the mechanism behind every widening proxy gap.&lt;/p&gt;

&lt;p&gt;Strathern, M. (1997). "&lt;a href="https://ofriperetz.dev/go/r/1bpfiaxlwjf?utm_source=devto&amp;amp;from=proxy-metrics" rel="noopener noreferrer"&gt;'Improving Ratings': Audit in the British University System&lt;/a&gt;." &lt;em&gt;European Review&lt;/em&gt;, 5(3), 305–321. The source of the familiar measure-becomes-target phrasing commonly misattributed to Goodhart; her university-audit case is itself a proxy-gap study.&lt;/p&gt;

&lt;p&gt;Muller, J. Z. (2018). &lt;em&gt;&lt;a href="https://ofriperetz.dev/go/r/1ndwuitu1t?utm_source=devto&amp;amp;from=proxy-metrics" rel="noopener noreferrer"&gt;The Tyranny of Metrics&lt;/a&gt;&lt;/em&gt;. Princeton University Press. The book-length catalog of proxy failures across medicine, education, policing, and business — the surgeon-scorecard case above is his.&lt;/p&gt;

&lt;p&gt;Forsgren, N., Humble, J., &amp;amp; Kim, G. (2018). &lt;em&gt;&lt;a href="https://ofriperetz.dev/go/r/1joskb8tpn0?utm_source=devto&amp;amp;from=proxy-metrics" rel="noopener noreferrer"&gt;Accelerate: The Science of Lean Software and DevOps&lt;/a&gt;&lt;/em&gt;. IT Revolution Press. Defines the DORA metrics and, notably, pairs speed metrics with stability metrics precisely to resist single-proxy gaming.&lt;/p&gt;

&lt;p&gt;Forsgren, N., Storey, M.-A., Maddila, C., Zimmermann, T., Houck, B., &amp;amp; Butler, J. (2021). "&lt;a href="https://ofriperetz.dev/go/r/10238za3f7c?utm_source=devto&amp;amp;from=proxy-metrics" rel="noopener noreferrer"&gt;The SPACE of Developer Productivity&lt;/a&gt;." &lt;em&gt;ACM Queue&lt;/em&gt;, 19(1). The framework that made "no single metric" a design principle rather than a disclaimer — prior art for per-metric counter-pairing.&lt;/p&gt;

&lt;p&gt;DeMarco, T. (2009). "&lt;a href="https://ofriperetz.dev/go/r/rf2cdhh4bl?utm_source=devto&amp;amp;from=proxy-metrics" rel="noopener noreferrer"&gt;Software Engineering: An Idea Whose Time Has Come and Gone?&lt;/a&gt;" &lt;em&gt;IEEE Software&lt;/em&gt;, 26(4). A founder of software-metrics culture revisiting how much control measurement should really carry — the field's most cited note of metrics humility.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Cited by / worked examples: &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=proxy-metrics" rel="noopener noreferrer"&gt;I built what I benchmark — how I try not to cheat&lt;/a&gt; runs this exact audit on a detection tool, where a perfect score on a corpus you wrote yourself is partly a statement about the corpus; &lt;a href="https://ofriperetz.dev/go/eslint-security-fn-fp-benchmark?utm_source=devto&amp;amp;from=proxy-metrics" rel="noopener noreferrer"&gt;the FP-tax benchmark&lt;/a&gt; is the leaderboard those numbers come from.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Foundations series: ← &lt;a href="https://ofriperetz.dev/go/valid-vs-reliable-metrics?utm_source=devto&amp;amp;from=proxy-metrics" rel="noopener noreferrer"&gt;Valid vs reliable metrics&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/16wihjeozse?utm_source=devto&amp;amp;from=proxy-metrics" rel="noopener noreferrer"&gt;hub&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/composite-scores-and-weighting?utm_source=devto&amp;amp;from=proxy-metrics" rel="noopener noreferrer"&gt;Composite scores &amp;amp; weighting&lt;/a&gt; →&lt;/em&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=proxy-metrics" 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=proxy-metrics" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · npm: &lt;a href="https://ofriperetz.dev/go/r/29m8v07vw3v?utm_source=devto&amp;amp;from=proxy-metrics" rel="noopener noreferrer"&gt;@interlace&lt;/a&gt; · Follow: &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=proxy-metrics" 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=proxy-metrics" 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>
    <item>
      <title>Precision, Recall, and F1 for Static Analysis: Same Score, Opposite Tools</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sun, 19 Jul 2026 23:45:00 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/precision-recall-and-f1-for-static-analysis-same-score-opposite-tools-53ib</link>
      <guid>https://dev.to/ofri-peretz/precision-recall-and-f1-for-static-analysis-same-score-opposite-tools-53ib</guid>
      <description>&lt;p&gt;F1 gives the scanner that screams at everything and the one that stays almost silent the same score — and it is the number every vendor table leads with.&lt;/p&gt;

&lt;p&gt;Tool A finds all 50 vulnerabilities in your codebase and buries them under 2,450 false alarms: 100% recall, 2% precision. Tool B raises exactly one flag, which is correct, and misses the other 49: 100% precision, 2% recall. F1 for both: &lt;strong&gt;3.9%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Same number, opposite products: one drowns every real finding in noise, the other stays silent through almost everything. A single score that maps both to 3.9% cannot tell you which failure mode you are buying. This page is the reference for what sits underneath it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Counts
&lt;/h2&gt;

&lt;p&gt;Every headline score in a static-analysis benchmark reduces to three counts against a labeled corpus:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TP (true positive)&lt;/strong&gt; — the tool flagged a real vulnerability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FP (false positive)&lt;/strong&gt; — the tool flagged safe code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FN (false negative)&lt;/strong&gt; — a real vulnerability the tool stayed silent on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fourth cell, the true negative — "every line the tool correctly ignored" — is unbounded, which is why accuracy flatters any tool that mostly stays quiet; the full grid lives in &lt;a href="https://ofriperetz.dev/go/confusion-matrix-tp-fp-fn-tn?utm_source=devto&amp;amp;from=precision-recall-f1-for-static-analysis" rel="noopener noreferrer"&gt;the confusion matrix article&lt;/a&gt;. And all three counts assume someone already decided what "a real vulnerability" means — a labeling step with failure modes of its own, covered in &lt;a href="https://ofriperetz.dev/go/ground-truth-in-security-testing?utm_source=devto&amp;amp;from=precision-recall-f1-for-static-analysis" rel="noopener noreferrer"&gt;ground truth in security testing&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Precision: When It Fires, Is It Right?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Precision = TP / (TP + FP).&lt;/strong&gt; Of everything the tool flagged, the fraction that was real.&lt;/p&gt;

&lt;p&gt;Concrete case: a SAST scanner raises 22 SQL-injection flags on a service; 11 are real, 11 are safe string concatenations it misread. That is 50.0% precision — one false alarm per real bug. Precision is the number your developers feel, because they are the ones who triage every flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recall: Of Everything There, How Much Did It Find?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Recall = TP / (TP + FN).&lt;/strong&gt; Of all the real vulnerabilities in the corpus, the fraction the tool caught.&lt;/p&gt;

&lt;p&gt;Concrete case: a conservative scanner that only fires on high-confidence patterns catches 4 of the 40 real vulnerabilities in a codebase, with 1 false alarm: 80.0% precision, 10.0% recall. When it fires, trust it — but it stayed silent on 36 of 40. Recall is the number your attackers feel, because false negatives are the findings that ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  F1: One Number, Two Hidden Failure Modes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;F1 = 2PR / (P + R)&lt;/strong&gt; — the harmonic mean of precision and recall. Unlike an arithmetic mean, it punishes imbalance: 100% on one side cannot rescue 2% on the other, which is how both tools in the opening land on 3.9%.&lt;/p&gt;

&lt;p&gt;That property is also its blind spot. F1 tells you the two ratios are &lt;em&gt;balanced or not&lt;/em&gt;; it never tells you &lt;em&gt;which one collapsed&lt;/em&gt;. The misconception worth deleting is the one the opening demonstrated: &lt;strong&gt;two tools with the same F1 behave the same.&lt;/strong&gt; They don't. The value-investing rule applies: price is what you pay, value is what you get. A headline score is the price tag; the three counts are the books. Read the books.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Worked Numbers
&lt;/h2&gt;

&lt;p&gt;An illustrative benchmark makes the point concrete. Take a corpus of 50 seeded vulnerabilities — SQL injection, SSRF, path traversal — plus safe counterparts, and run three scanners with different temperaments:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scanner&lt;/th&gt;
&lt;th&gt;TP&lt;/th&gt;
&lt;th&gt;FP&lt;/th&gt;
&lt;th&gt;FN&lt;/th&gt;
&lt;th&gt;Precision&lt;/th&gt;
&lt;th&gt;Recall&lt;/th&gt;
&lt;th&gt;F1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Aggressive matcher&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;50.0%&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;td&gt;66.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Balanced&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;79.5%&lt;/td&gt;
&lt;td&gt;70.0%&lt;/td&gt;
&lt;td&gt;74.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conservative&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;83.3%&lt;/td&gt;
&lt;td&gt;20.0%&lt;/td&gt;
&lt;td&gt;32.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three readings no single column supports:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Similar precision, different products.&lt;/strong&gt; Balanced (79.5%) and Conservative (83.3%) look interchangeable on precision. Balanced has 3.5× the recall (70.0% vs 20.0%), and F1 spreads them to 74.5 vs 32.3. Comparing the two on precision alone compares the wrong thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Averages conceal; per-check numbers reveal.&lt;/strong&gt; The aggressive matcher's 50.0% precision reads like a coin flip until you split it by check. One broad rule — a regex that flags any string concatenation feeding a database query — fired 8 of its false alarms against just 2 real SQL-injection hits: 20% precision hiding inside the 50% average. That 20% check is the one a team mutes first. Pull the per-check breakdown before you quote the tool-level number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A perfect row is a question, not an answer.&lt;/strong&gt; A scanner that posts 100/100/100 on a corpus is usually reporting that the corpus is exhausted, not that the tool is flawless. Read a perfect row as a reason to build harder fixtures — be more suspicious, not less.&lt;/p&gt;

&lt;p&gt;Cited by: for a real, version-stamped run of this arithmetic across the Node.js linting ecosystem — real tools, real counts, one row per plugin, down to a single rule that fired 8 of a plugin's 11 false positives — see the &lt;a href="https://ofriperetz.dev/go/eslint-security-fn-fp-benchmark?utm_source=devto&amp;amp;from=precision-recall-f1-for-static-analysis" rel="noopener noreferrer"&gt;FP-tax benchmark&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Security Asymmetry
&lt;/h2&gt;

&lt;p&gt;In most domains, precision and recall errors cost about the same. In security they don't. A false positive costs minutes: a developer reads the flag, recognizes safe code, moves on. A false negative ships a vulnerability. On raw cost per error, recall should dominate.&lt;/p&gt;

&lt;p&gt;But the second-order effect inverts the naive conclusion. Sustained low precision trains developers to distrust the tool — first the inline suppression, then the rule lands in the shared config's &lt;code&gt;off&lt;/code&gt; list. A muted rule has an effective recall of zero, whatever the benchmark said. This is the false-positive tax: precision failures convert into recall failures through human behavior — which is why optimizing any single number in isolation fails exactly as &lt;a href="https://ofriperetz.dev/go/goodharts-law-explained?utm_source=devto&amp;amp;from=precision-recall-f1-for-static-analysis" rel="noopener noreferrer"&gt;Goodhart's Law&lt;/a&gt; predicts.&lt;/p&gt;

&lt;p&gt;The framing that survives both effects: &lt;strong&gt;recall-first, precision-floor.&lt;/strong&gt; Choose tools by what they catch, subject to a hard minimum on precision — below the floor, the catch rate is theoretical because nobody is listening.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Assumption F1 Hides
&lt;/h2&gt;

&lt;p&gt;F1 weights precision and recall equally. That is a choice, not a law — and for security it is the wrong default. Van Rijsbergen's Fβ generalizes the metric: &lt;strong&gt;Fβ = (1+β²)PR / (β²P + R)&lt;/strong&gt;, where β is how many times more you value recall than precision. F2 (recall counts double) is the defensible default when a false negative ships a vulnerability and a false positive costs triage minutes.&lt;/p&gt;

&lt;p&gt;Recomputing the two similar-precision scanners above under F2:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scanner&lt;/th&gt;
&lt;th&gt;F1&lt;/th&gt;
&lt;th&gt;F2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Balanced (P 79.5% / R 70.0%)&lt;/td&gt;
&lt;td&gt;74.5%&lt;/td&gt;
&lt;td&gt;71.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conservative (P 83.3% / R 20.0%)&lt;/td&gt;
&lt;td&gt;32.3%&lt;/td&gt;
&lt;td&gt;23.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The order does not change. The gaps do: Conservative, the precision champion, drops from 32.3 to 23.6 as F2 charges it properly for 20% recall, and Balanced's lead over it stretches from 42 to 48 points. That is the lesson — metric choice moves magnitudes before it moves ranks, so check that a conclusion survives the switch before you trust it.&lt;/p&gt;

&lt;p&gt;The grown-up version is cost-sensitive evaluation: put a currency cost on each FP (triage time × flag volume) and each FN (expected incident cost), then minimize expected total cost instead of maximizing any F-score. Those costs hinge on how rare real vulnerabilities are in your codebase — &lt;a href="https://ofriperetz.dev/go/base-rate-problem-explained?utm_source=devto&amp;amp;from=precision-recall-f1-for-static-analysis" rel="noopener noreferrer"&gt;the base rate problem&lt;/a&gt;, which earns its own page.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Read a Vendor's Table
&lt;/h2&gt;

&lt;p&gt;Four questions, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Where are the raw counts?&lt;/strong&gt; TP/FP/FN recover every ratio; ratios without counts recover nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is precision per-rule or tool-wide?&lt;/strong&gt; A 50% average can hide a 20% rule, and the 20% rule is the one your team will mute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Are the numbers version-stamped and dated?&lt;/strong&gt; A tool's precision is a property of a specific version, on a specific corpus, on a specific date; last quarter's build, re-run after a rules update, can post a different number. An unstamped number is a claim about nothing in particular.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Points or curves?&lt;/strong&gt; ML classifiers sweep a threshold, so they report precision-recall curves (Davis &amp;amp; Goadrich is the standard treatment). Rule-based scanners and linters are binary — a rule is on or off, with no threshold to sweep — so an honest benchmark reports operating points. A PR curve on a rule-based benchmark is decoration.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For prior art: the closest public relative is the OWASP Benchmark Project, which scores SAST tools on a large Java corpus using the same TP/FP-derived arithmetic. The same discipline transfers to any ecosystem — pick a labeled corpus, report operating points, and publish the raw counts so every ratio stays recoverable.&lt;/p&gt;

&lt;p&gt;None of these metrics decides for you. Used together, they make it impossible for a single flattering number to decide &lt;em&gt;against&lt;/em&gt; you. Next in the Foundations arc: why a precision number earned on a balanced corpus quietly falls apart at production base rates — &lt;a href="https://ofriperetz.dev/go/base-rate-problem-explained?utm_source=devto&amp;amp;from=precision-recall-f1-for-static-analysis" rel="noopener noreferrer"&gt;The Base Rate Problem&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If this is the reference page you wished vendor tables came with, bookmark it and &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=precision-recall-f1-for-static-analysis" rel="noopener noreferrer"&gt;follow me on Dev.to&lt;/a&gt; — the rest of the Foundations series is written the same way.&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;Metric&lt;/th&gt;
&lt;th&gt;Formula&lt;/th&gt;
&lt;th&gt;Question it answers&lt;/th&gt;
&lt;th&gt;Blind spot&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Precision&lt;/td&gt;
&lt;td&gt;TP / (TP + FP)&lt;/td&gt;
&lt;td&gt;When it fires, is it right?&lt;/td&gt;
&lt;td&gt;Says nothing about what was missed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recall&lt;/td&gt;
&lt;td&gt;TP / (TP + FN)&lt;/td&gt;
&lt;td&gt;Of what's there, how much did it find?&lt;/td&gt;
&lt;td&gt;Says nothing about noise&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;F1&lt;/td&gt;
&lt;td&gt;2PR / (P + R)&lt;/td&gt;
&lt;td&gt;Are the two in balance?&lt;/td&gt;
&lt;td&gt;Symmetric — hides &lt;em&gt;which&lt;/em&gt; side failed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;F2&lt;/td&gt;
&lt;td&gt;5PR / (4P + R)&lt;/td&gt;
&lt;td&gt;Balance, valuing recall 2×&lt;/td&gt;
&lt;td&gt;Still one number; weights still a choice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accuracy&lt;/td&gt;
&lt;td&gt;(TP+TN) / all&lt;/td&gt;
&lt;td&gt;How often was it right overall?&lt;/td&gt;
&lt;td&gt;Needs TN — unbounded and near-meaningless for linters&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




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

&lt;p&gt;Sokolova, M., &amp;amp; Lapalme, G. (2009). &lt;a href="https://ofriperetz.dev/go/r/lbosahgj72?utm_source=devto&amp;amp;from=precision-recall-f1-for-static-analysis" rel="noopener noreferrer"&gt;A systematic analysis of performance measures for classification tasks&lt;/a&gt;. &lt;em&gt;Information Processing &amp;amp; Management&lt;/em&gt;, 45(4), 427–437. The standard taxonomy of classification metrics — which measures are invariant to which changes in the confusion matrix, and therefore which are safe to compare across corpora.&lt;/p&gt;

&lt;p&gt;Van Rijsbergen, C. J. (1979). &lt;em&gt;&lt;a href="https://ofriperetz.dev/go/r/oz56t233bv?utm_source=devto&amp;amp;from=precision-recall-f1-for-static-analysis" rel="noopener noreferrer"&gt;Information Retrieval&lt;/a&gt;&lt;/em&gt; (2nd ed.). Butterworths. The origin of the effectiveness measure that F1 descends from, including the β parameter — the primary source for the fact that F1's equal weighting was always meant to be adjustable.&lt;/p&gt;

&lt;p&gt;Davis, J., &amp;amp; Goadrich, M. (2006). &lt;a href="https://ofriperetz.dev/go/r/8sqf22y1sw?utm_source=devto&amp;amp;from=precision-recall-f1-for-static-analysis" rel="noopener noreferrer"&gt;The relationship between Precision-Recall and ROC curves&lt;/a&gt;. &lt;em&gt;Proceedings of ICML 2006&lt;/em&gt;. Why precision-recall space is the right lens under class imbalance, and the context for when curves (threshold tools) versus points (binary tools) are the honest report.&lt;/p&gt;

&lt;p&gt;OWASP Benchmark Project. &lt;a href="https://ofriperetz.dev/go/r/22pc6fhgqgm?utm_source=devto&amp;amp;from=precision-recall-f1-for-static-analysis" rel="noopener noreferrer"&gt;owasp.org/www-project-benchmark&lt;/a&gt;. A public, scored SAST benchmark built on TP/FP-derived metrics over a Java test suite — the closest existing prior art to a hand-labeled, operating-point leaderboard.&lt;/p&gt;

&lt;p&gt;Saito, T., &amp;amp; Rehmsmeier, M. (2015). &lt;a href="https://ofriperetz.dev/go/r/po1tn93tj4?utm_source=devto&amp;amp;from=precision-recall-f1-for-static-analysis" rel="noopener noreferrer"&gt;The precision-recall plot is more informative than the ROC plot when evaluating binary classifiers on imbalanced datasets&lt;/a&gt;. &lt;em&gt;PLOS ONE&lt;/em&gt;, 10(3), e0118432. The companion piece for why ROC-style reporting flatters tools when real positives are rare — which in security codebases they always are.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Foundations series: ← &lt;a href="https://ofriperetz.dev/go/confusion-matrix-tp-fp-fn-tn?utm_source=devto&amp;amp;from=precision-recall-f1-for-static-analysis" rel="noopener noreferrer"&gt;The Confusion Matrix&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/16wihjeozse?utm_source=devto&amp;amp;from=precision-recall-f1-for-static-analysis" rel="noopener noreferrer"&gt;hub&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/base-rate-problem-explained?utm_source=devto&amp;amp;from=precision-recall-f1-for-static-analysis" rel="noopener noreferrer"&gt;The Base Rate Problem&lt;/a&gt; →&lt;/em&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=precision-recall-f1-for-static-analysis" rel="noopener noreferrer"&gt;Interlace ESLint ecosystem&lt;/a&gt;. &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint-benchmark-suite?utm_source=devto&amp;amp;from=precision-recall-f1-for-static-analysis" rel="noopener noreferrer"&gt;Source on GitHub&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/y5yxrpx88n?utm_source=devto&amp;amp;from=precision-recall-f1-for-static-analysis" rel="noopener noreferrer"&gt;npm&lt;/a&gt; · Follow: &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=precision-recall-f1-for-static-analysis" 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=precision-recall-f1-for-static-analysis" 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>
    <item>
      <title>Taint vs. Heuristic Detection: The Difference Between a Proof and a Hunch</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sun, 19 Jul 2026 23:41:25 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/taint-vs-heuristic-detection-the-difference-between-a-proof-and-a-hunch-1b18</link>
      <guid>https://dev.to/ofri-peretz/taint-vs-heuristic-detection-the-difference-between-a-proof-and-a-hunch-1b18</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A linter flags this line in about a millisecond. Here is what it did &lt;em&gt;not&lt;/em&gt; do in that millisecond: it did not trace where &lt;code&gt;userUrl&lt;/code&gt; came from, and it did not prove that a request-forgery path exists. It flagged the line because a user-ish name reached a network sink. That is the entire analysis.&lt;/p&gt;

&lt;p&gt;Whether that is scandalous or sensible depends on a distinction most tool marketing blurs: &lt;strong&gt;impact-if-real&lt;/strong&gt; and &lt;strong&gt;confidence-it's-real&lt;/strong&gt; are different axes. The impact axis is brutal — server-side request forgery is CWE-918, and a serious vector scores CVSS 9.1, a tenth of a point past the line where the scale turns Critical (push every judgment to its worst and it tops out at a perfect 10.0). The confidence axis, for a single name-match flag, is modest: a variable name is circumstantial evidence. Print one axis in the other's vocabulary and you get &lt;code&gt;9.1&lt;/code&gt; and &lt;code&gt;LOW&lt;/code&gt; four characters apart — and that drift is not hypothetical: a &lt;a href="https://ofriperetz.dev/go/i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score?utm_source=devto&amp;amp;from=taint-vs-heuristic-detection" rel="noopener noreferrer"&gt;rule-metadata audit&lt;/a&gt; caught exactly that mislabel in the wild. &lt;a href="https://ofriperetz.dev/go/cvss-scores-explained?utm_source=devto&amp;amp;from=taint-vs-heuristic-detection" rel="noopener noreferrer"&gt;CVSS scores what a weakness could do&lt;/a&gt;, never how sure a detector is that it found one — so this page explains the two detection families that sit on opposite ends of the confidence axis.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is taint analysis?
&lt;/h2&gt;

&lt;p&gt;Taint analysis answers one question with evidence: &lt;strong&gt;can a value from an untrusted source reach a dangerous sink without passing through a sanitizer?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The model has three parts. &lt;strong&gt;Sources&lt;/strong&gt; produce attacker-influenced data: &lt;code&gt;req.query&lt;/code&gt;, &lt;code&gt;req.body&lt;/code&gt;, CLI arguments, file contents. &lt;strong&gt;Sinks&lt;/strong&gt; are operations where untrusted data causes damage: &lt;code&gt;fetch&lt;/code&gt;, &lt;code&gt;child_process.exec&lt;/code&gt;, a SQL driver call. &lt;strong&gt;Sanitizers&lt;/strong&gt; are the functions that clear the taint: parameterization, allowlist validation, encoding. The engine builds a data-flow graph of the whole program, propagates "tainted" labels from sources, and reports only when a complete source-to-sink path exists with no sanitizer on it. The lineage runs straight back to Dorothy Denning's 1976 lattice model of secure information flow; Livshits and Lam made it practical for real web vulnerabilities in 2005.&lt;/p&gt;

&lt;p&gt;What makes this expensive is that real paths are indirect. A two-hop flow:&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;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// source&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;   &lt;span class="c1"&gt;// taint flows into a property&lt;/span&gt;
&lt;span class="k"&gt;await&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;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// sink — three statements, one path&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reporting this requires following the value through an assignment, a property write, and a property read — and production paths cross function and file boundaries, which demands a call graph. Aliasing is worse:&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;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT&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;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                    &lt;span class="c1"&gt;// two names, one object&lt;/span&gt;
&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// tainting b.url taints a.url&lt;/span&gt;
&lt;span class="k"&gt;await&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;             &lt;span class="c1"&gt;// sink reached through the alias&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sound alias analysis is where most of the computational cost lives, and in JavaScript — &lt;code&gt;eval&lt;/code&gt;, dynamic property access, framework glue — the graph is never complete. The payoff for all that machinery: when a taint engine reports, it hands you a &lt;strong&gt;path&lt;/strong&gt; you can read as a demonstration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is heuristic detection?
&lt;/h2&gt;

&lt;p&gt;A heuristic rule answers a much cheaper question: &lt;strong&gt;does this code's shape resemble a known-vulnerable pattern?&lt;/strong&gt; A non-literal argument reaching &lt;code&gt;fetch&lt;/code&gt;. A template literal inside &lt;code&gt;exec()&lt;/code&gt;. A computed property write. No data-flow proof — a resemblance, computed from a single file's AST in milliseconds.&lt;/p&gt;

&lt;p&gt;The failure profile is the mirror image of taint analysis: strong catch-rate on patterns it matches, false positives wherever the pattern's premise fails. Picture the classic offender — a rule that flags every computed property access, &lt;code&gt;obj[key]&lt;/code&gt;, as possible object injection. It fires because it cannot know whether &lt;code&gt;key&lt;/code&gt; is attacker-controlled, so in a codebase where bracket access is mostly over trusted keys it drowns you: a rule like that can sink to 25% precision — three false alarms for every real finding — all on its own. It is not broken. It is a heuristic priced honestly, and the price is only visible once you &lt;a href="https://ofriperetz.dev/go/confusion-matrix-tp-fp-fn-tn?utm_source=devto&amp;amp;from=taint-vs-heuristic-detection" rel="noopener noreferrer"&gt;count TP and FP properly&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The false-positive asymmetry
&lt;/h2&gt;

&lt;p&gt;The two families fail in opposite directions, and the direction is the useful part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Taint engines make FN-shaped errors.&lt;/strong&gt; Wherever the model can't follow — dynamic dispatch, reflection, a framework's dependency injection — the path silently disappears, and with it the finding. What a taint engine says is trustworthy; what it doesn't say is unknown.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Heuristic rules make FP-shaped errors.&lt;/strong&gt; They over-flag whenever a name or shape misleads, and they miss the flows that never look dangerous at the sink line. Look back at the two-hop example: a single-file heuristic staring at &lt;code&gt;fetch(opts.url)&lt;/code&gt; sees a property read with nothing user-ish in the name. The flow is invisible without the graph.&lt;/p&gt;

&lt;p&gt;Now put the two axes from the hook back together. A taint finding carries high &lt;em&gt;confidence&lt;/em&gt; — a demonstrated path. A heuristic finding carries an invitation to look. The &lt;em&gt;impact&lt;/em&gt; axis is untouched by any of this: SSRF is CWE-918 at CVSS 9.1 whether CodeQL proved it or a name-match suggested it. Severity belongs to the weakness; confidence belongs to the detection method. Print one axis in the other's vocabulary and you get &lt;code&gt;9.1&lt;/code&gt; and &lt;code&gt;LOW&lt;/code&gt; four characters apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Complementary, not competing
&lt;/h2&gt;

&lt;p&gt;Editor-grade security linters are heuristic &lt;strong&gt;by design&lt;/strong&gt;, not by a limitation someone forgot to fix. The design constraints are a single file's AST and a millisecond budget, because the rule runs on every keystroke and every commit. Interprocedural taint tracking under those constraints is not difficult — it is excluded by the specification. Some rules track values within one file's scopes, which is useful, but it is not taint analysis, and the most common misreading in this vocabulary — &lt;strong&gt;"ESLint security rules do taint tracking"&lt;/strong&gt; — is simply false. They pattern-match. Knowing this changes how you read a flag: it is a screen, not a verdict.&lt;/p&gt;

&lt;p&gt;The right mental model comes from value investing: a stock screener filtering on price-to-earnings does not tell you a company is undervalued — it tells you which filings are worth reading. Heuristic lint is the screener: cheap, immediate, over-inclusive on purpose, run on everything. Taint analysis is the due diligence: expensive, slower, run where the stakes justify it. In a real pipeline that means heuristic rules in the editor and pre-commit, and an interprocedural engine — CodeQL, Semgrep in taint mode — on the pull-request gate or nightly. Where each tool class sits in the broader taxonomy is &lt;a href="https://ofriperetz.dev/go/static-analysis-vs-sast-vs-linting?utm_source=devto&amp;amp;from=taint-vs-heuristic-detection" rel="noopener noreferrer"&gt;its own article&lt;/a&gt;; the short version is that they answer different questions, so replacing one with the other means a question goes unanswered.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for benchmarks
&lt;/h2&gt;

&lt;p&gt;One honest disclosure follows. A single-file benchmark corpus can only encode &lt;em&gt;heuristically-detectable&lt;/em&gt; patterns — single-file shapes a lint rule could conceivably match — because &lt;a href="https://ofriperetz.dev/go/ground-truth-in-security-testing?utm_source=devto&amp;amp;from=taint-vs-heuristic-detection" rel="noopener noreferrer"&gt;someone had to decide what counts as vulnerable&lt;/a&gt;, and a corpus of one-file fixtures can only hold one-file bugs. So a linter that scores 100% on such a corpus has told you one specific thing: complete coverage of the pattern class the corpus was designed for. It has &lt;em&gt;not&lt;/em&gt; told you a taint engine is unnecessary, because the multi-file flows a taint engine exists to find are exactly what a single-file corpus cannot encode.&lt;/p&gt;

&lt;p&gt;A perfect score on a corpus you built yourself is a tautology wearing a medal — you wrote the test, you wrote the tool, and the number just confirms they agree. The honest reading is the scope statement hiding underneath it: complete coverage of the one pattern class you chose to encode, and silence about everything you left out.&lt;/p&gt;

&lt;p&gt;So the one question to ask any static analysis tool — before the demo, before the leaderboard — is: &lt;strong&gt;what evidence does a finding carry?&lt;/strong&gt; A path means taint. A resemblance means heuristic. Both are worth paying for; neither substitutes for the other; and a tool that names which one it is doing has already told you how to triage its output.&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;&lt;/th&gt;
&lt;th&gt;Taint analysis&lt;/th&gt;
&lt;th&gt;Heuristic detection&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Core question&lt;/td&gt;
&lt;td&gt;Does untrusted data reach a sink unsanitized?&lt;/td&gt;
&lt;td&gt;Does this code shape resemble a vulnerable pattern?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evidence per finding&lt;/td&gt;
&lt;td&gt;A demonstrated source→sink path&lt;/td&gt;
&lt;td&gt;A pattern match&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scope&lt;/td&gt;
&lt;td&gt;Whole program, cross-file&lt;/td&gt;
&lt;td&gt;Single file (one AST)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;Minutes; call graph + alias analysis&lt;/td&gt;
&lt;td&gt;Milliseconds per file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Characteristic error&lt;/td&gt;
&lt;td&gt;False negatives (model gaps: &lt;code&gt;eval&lt;/code&gt;, dynamic dispatch, framework glue)&lt;/td&gt;
&lt;td&gt;False positives (premise fails: names and shapes mislead)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where it belongs&lt;/td&gt;
&lt;td&gt;PR gate, nightly runs&lt;/td&gt;
&lt;td&gt;Editor, pre-commit, CI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Examples&lt;/td&gt;
&lt;td&gt;CodeQL, Semgrep (taint mode)&lt;/td&gt;
&lt;td&gt;ESLint security plugins, single-file lint rules&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;Denning, D. E. (1976). &lt;a href="https://ofriperetz.dev/go/r/19iyhvx1uyn?utm_source=devto&amp;amp;from=taint-vs-heuristic-detection" rel="noopener noreferrer"&gt;A lattice model of secure information flow&lt;/a&gt;. &lt;em&gt;Communications of the ACM&lt;/em&gt;, 19(5). The founding citation — every source/sink/label system in use today descends from this model.&lt;/li&gt;
&lt;li&gt;Livshits, V. B., &amp;amp; Lam, M. S. (2005). &lt;a href="https://ofriperetz.dev/go/r/2gddhspwbrs?utm_source=devto&amp;amp;from=taint-vs-heuristic-detection" rel="noopener noreferrer"&gt;Finding security vulnerabilities in Java applications with static analysis&lt;/a&gt;. &lt;em&gt;USENIX Security Symposium&lt;/em&gt;. The paper that made taint-based static analysis practical against real web-application vulnerability classes.&lt;/li&gt;
&lt;li&gt;Chess, B., &amp;amp; West, J. (2007). &lt;em&gt;&lt;a href="https://ofriperetz.dev/go/r/1y0jz49es4u?utm_source=devto&amp;amp;from=taint-vs-heuristic-detection" rel="noopener noreferrer"&gt;Secure Programming with Static Analysis&lt;/a&gt;&lt;/em&gt;. Addison-Wesley. The standard industrial treatment of both detection families, including why commercial SAST engines mix them.&lt;/li&gt;
&lt;li&gt;OWASP Community: &lt;a href="https://ofriperetz.dev/go/r/1n8jtd57ez1?utm_source=devto&amp;amp;from=taint-vs-heuristic-detection" rel="noopener noreferrer"&gt;Static Code Analysis&lt;/a&gt;. Practitioner-level survey of the technique, with an unusually honest limitations list.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;This is a vocabulary page — bookmark it for the next time a vendor deck says "taint-aware linting," and &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=taint-vs-heuristic-detection" rel="noopener noreferrer"&gt;follow me on Dev.to&lt;/a&gt; for the rest of the foundations series.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Foundations series: ← &lt;a href="https://ofriperetz.dev/go/owasp-top-10-explained?utm_source=devto&amp;amp;from=taint-vs-heuristic-detection" rel="noopener noreferrer"&gt;OWASP Top 10, Explained&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/16wihjeozse?utm_source=devto&amp;amp;from=taint-vs-heuristic-detection" rel="noopener noreferrer"&gt;hub&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/static-analysis-vs-sast-vs-linting?utm_source=devto&amp;amp;from=taint-vs-heuristic-detection" rel="noopener noreferrer"&gt;Static Analysis vs. SAST vs. Linting&lt;/a&gt; →&lt;/em&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=taint-vs-heuristic-detection" 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=taint-vs-heuristic-detection" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · npm: &lt;a href="https://ofriperetz.dev/go/r/29m8v07vw3v?utm_source=devto&amp;amp;from=taint-vs-heuristic-detection" rel="noopener noreferrer"&gt;@interlace&lt;/a&gt; · Follow: &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=taint-vs-heuristic-detection" 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=taint-vs-heuristic-detection" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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