<?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: benjamin</title>
    <description>The latest articles on DEV Community by benjamin (@_06a3df6b50aec966668fb).</description>
    <link>https://dev.to/_06a3df6b50aec966668fb</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%2F3975620%2Fbec1cbbc-f5ef-4807-b03b-92d5228e33a4.png</url>
      <title>DEV Community: benjamin</title>
      <link>https://dev.to/_06a3df6b50aec966668fb</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/_06a3df6b50aec966668fb"/>
    <language>en</language>
    <item>
      <title>diskreap: reclaim disk from build artifacts &amp; dep caches, across every language</title>
      <dc:creator>benjamin</dc:creator>
      <pubDate>Mon, 29 Jun 2026 08:18:04 +0000</pubDate>
      <link>https://dev.to/_06a3df6b50aec966668fb/diskreap-reclaim-disk-from-build-artifacts-dep-caches-across-every-language-1hac</link>
      <guid>https://dev.to/_06a3df6b50aec966668fb/diskreap-reclaim-disk-from-build-artifacts-dep-caches-across-every-language-1hac</guid>
      <description>&lt;p&gt;My disk filled up last week. Not from anything I was actively working on — from the &lt;strong&gt;graveyard&lt;/strong&gt; of projects in &lt;code&gt;~/code&lt;/code&gt;. Forty-odd repos, each carrying a &lt;code&gt;node_modules&lt;/code&gt; I haven't touched in months, a &lt;code&gt;target/&lt;/code&gt; from that Rust experiment, a &lt;code&gt;.venv&lt;/code&gt; here, a &lt;code&gt;.next&lt;/code&gt; there. Tens of gigabytes of stuff that a single command would regenerate, just… sitting there.&lt;/p&gt;

&lt;p&gt;The annoying part isn't deleting it. It's &lt;em&gt;finding&lt;/em&gt; it. &lt;code&gt;du -sh */node_modules&lt;/code&gt; only catches Node. Then you do it again for &lt;code&gt;.venv&lt;/code&gt;, again for &lt;code&gt;target&lt;/code&gt;, again for &lt;code&gt;__pycache__&lt;/code&gt;. Different command, different ecosystem, every time.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;diskreap&lt;/strong&gt; — one zero-dependency command that finds reclaimable build artifacts and dependency caches &lt;strong&gt;across every language at once&lt;/strong&gt;, shows you what's eating your disk, and deletes only what's safely regenerable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx diskreap            &lt;span class="c"&gt;# scan the current directory&lt;/span&gt;
&lt;span class="c"&gt;# or&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;diskreap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why not just &lt;code&gt;npkill&lt;/code&gt; / &lt;code&gt;kondo&lt;/code&gt;?
&lt;/h2&gt;

&lt;p&gt;They're good — I used both. But each covers one corner:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;npkill&lt;/strong&gt; is &lt;code&gt;node_modules&lt;/code&gt;-only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;kondo&lt;/strong&gt; is great and multi-language, but it's a Rust binary you have to install first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;diskreap runs &lt;em&gt;instantly&lt;/em&gt; with &lt;code&gt;npx&lt;/code&gt; (or &lt;code&gt;pipx run diskreap&lt;/code&gt;), needs nothing installed, and reaches every ecosystem in a single scan: &lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;.venv&lt;/code&gt;/&lt;code&gt;venv&lt;/code&gt;, &lt;code&gt;__pycache__&lt;/code&gt;, &lt;code&gt;.pytest_cache&lt;/code&gt;, &lt;code&gt;.mypy_cache&lt;/code&gt;, &lt;code&gt;.ruff_cache&lt;/code&gt;, &lt;code&gt;.next&lt;/code&gt;, &lt;code&gt;.nuxt&lt;/code&gt;, &lt;code&gt;.svelte-kit&lt;/code&gt;, &lt;code&gt;.turbo&lt;/code&gt;, &lt;code&gt;.parcel-cache&lt;/code&gt;, &lt;code&gt;target&lt;/code&gt;, &lt;code&gt;.gradle&lt;/code&gt;, &lt;code&gt;vendor&lt;/code&gt;, &lt;code&gt;dist&lt;/code&gt;/&lt;code&gt;build&lt;/code&gt;, &lt;code&gt;.terraform&lt;/code&gt;, &lt;code&gt;Pods&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it
&lt;/h2&gt;

&lt;p&gt;Scan first — it never deletes without being asked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ diskreap scan ~/code

  /Users/you/code

     1.2 GB  Rust/Maven build scanner/target         · 5d ago
     412 MB  Node deps        webapp/node_modules     · 3w ago
      88 MB  Python venv      ml-notes/.venv          · 2mo ago
     ────────────────────────────────────────────────
     total reclaimable: 1.7 GB across 12 dir(s)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then reclaim, with filters to scope the sweep:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;diskreap clean ~/code &lt;span class="nt"&gt;--dry-run&lt;/span&gt;        &lt;span class="c"&gt;# preview exactly what would go&lt;/span&gt;
diskreap clean ~/code &lt;span class="nt"&gt;--older&lt;/span&gt; 60d      &lt;span class="c"&gt;# only long-idle projects&lt;/span&gt;
diskreap clean ~/code &lt;span class="nt"&gt;--min&lt;/span&gt; 100MB      &lt;span class="c"&gt;# skip the small fry&lt;/span&gt;
diskreap clean ~/code &lt;span class="nt"&gt;--yes&lt;/span&gt;            &lt;span class="c"&gt;# no prompt (CI / scripts)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--json&lt;/code&gt; is there too, if you want to pipe the scan somewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one design decision that mattered: safety
&lt;/h2&gt;

&lt;p&gt;The scary names are the &lt;em&gt;generic&lt;/em&gt; ones. &lt;code&gt;node_modules&lt;/code&gt; is unambiguous — but &lt;code&gt;target&lt;/code&gt;, &lt;code&gt;dist&lt;/code&gt;, &lt;code&gt;build&lt;/code&gt;, &lt;code&gt;vendor&lt;/code&gt; could just as easily be &lt;strong&gt;your&lt;/strong&gt; folders.&lt;/p&gt;

&lt;p&gt;So diskreap only treats a generic name as reclaimable when a &lt;strong&gt;sibling project file proves its origin&lt;/strong&gt;: &lt;code&gt;target/&lt;/code&gt; counts next to a &lt;code&gt;Cargo.toml&lt;/code&gt; or &lt;code&gt;pom.xml&lt;/code&gt;; &lt;code&gt;dist/&lt;/code&gt; and &lt;code&gt;build/&lt;/code&gt; count next to a &lt;code&gt;package.json&lt;/code&gt;/&lt;code&gt;pyproject.toml&lt;/code&gt;/&lt;code&gt;setup.py&lt;/code&gt;; &lt;code&gt;vendor/&lt;/code&gt; next to a &lt;code&gt;composer.json&lt;/code&gt;/&lt;code&gt;go.mod&lt;/code&gt;. A bare &lt;code&gt;build/&lt;/code&gt; of your own files is never touched. Symlinks are never followed, and &lt;code&gt;clean&lt;/code&gt; always confirms unless you pass &lt;code&gt;--yes&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It's also genuinely zero-dependency — pure Node stdlib and pure Python stdlib, same logic in both, byte-for-byte aligned output. No supply chain, nothing to audit.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx diskreap          &lt;span class="c"&gt;# Node&lt;/span&gt;
pipx run diskreap     &lt;span class="c"&gt;# Python&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/diskreap" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/diskreap&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyPI: &lt;a href="https://pypi.org/project/diskreap/" rel="noopener noreferrer"&gt;https://pypi.org/project/diskreap/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/jjdoor/diskreap" rel="noopener noreferrer"&gt;https://github.com/jjdoor/diskreap&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's the most disk you've ever clawed back from a single &lt;code&gt;~/code&lt;/code&gt; sweep? And which artifact dir surprised you the most — for me it's always &lt;code&gt;.gradle&lt;/code&gt;. 👇&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>devops</category>
      <category>cli</category>
      <category>opensource</category>
    </item>
    <item>
      <title>My code reviewer kept asking for JSDoc — so I built a zero-dep CLI that catches it first</title>
      <dc:creator>benjamin</dc:creator>
      <pubDate>Tue, 23 Jun 2026 03:36:54 +0000</pubDate>
      <link>https://dev.to/_06a3df6b50aec966668fb/my-code-reviewer-kept-asking-for-jsdoc-so-i-built-a-zero-dep-cli-that-catches-it-first-mh9</link>
      <guid>https://dev.to/_06a3df6b50aec966668fb/my-code-reviewer-kept-asking-for-jsdoc-so-i-built-a-zero-dep-cli-that-catches-it-first-mh9</guid>
      <description>&lt;p&gt;I kept running &lt;code&gt;eslint&lt;/code&gt; and thinking my codebase was fine — then someone opened a PR and the first comment was "this function needs JSDoc."&lt;/p&gt;

&lt;p&gt;The problem: linters check your &lt;em&gt;syntax&lt;/em&gt;. Nobody checks whether your &lt;em&gt;exported API&lt;/em&gt; is actually documented. Those are two very different things.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;jsdocscan&lt;/strong&gt; — a zero-dependency CLI that walks your JS/TS files and flags every exported function or class that is missing JSDoc, or has undocumented parameters.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Errors&lt;/strong&gt; — exported function or class with no &lt;code&gt;/** … */&lt;/code&gt; block at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✗ src/api.js:12 fetchUser  missing JSDoc
✗ src/utils.js:34 formatDate  missing JSDoc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Warnings&lt;/strong&gt; — JSDoc exists but a parameter has no &lt;code&gt;@param&lt;/code&gt; tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;! src/render.js:7 renderCard  undocumented params: opts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit codes are &lt;code&gt;0&lt;/code&gt; (all clean), &lt;code&gt;1&lt;/code&gt; (issues found), &lt;code&gt;2&lt;/code&gt; (usage error) — pipe-friendly.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to use it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# scan a directory&lt;/span&gt;
npx jsdocscan src/

&lt;span class="c"&gt;# Python version&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;jsdocscan
jsdocscan src/

&lt;span class="c"&gt;# skip @param checks — just verify JSDoc exists&lt;/span&gt;
npx jsdocscan &lt;span class="nt"&gt;--no-params&lt;/span&gt; src/

&lt;span class="c"&gt;# machine-readable output&lt;/span&gt;
npx jsdocscan &lt;span class="nt"&gt;--json&lt;/span&gt; src/ | jq &lt;span class="s1"&gt;'.[].findings'&lt;/span&gt;

&lt;span class="c"&gt;# summary line only&lt;/span&gt;
npx jsdocscan &lt;span class="nt"&gt;--quiet&lt;/span&gt; src/

&lt;span class="c"&gt;# custom extensions&lt;/span&gt;
npx jsdocscan &lt;span class="nt"&gt;--ext&lt;/span&gt; .js,.ts src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What it skips (intentionally)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Non-exported functions and internal helpers — these are implementation details&lt;/li&gt;
&lt;li&gt;Destructured params &lt;code&gt;({ a, b })&lt;/code&gt; — too many valid &lt;code&gt;@param opts&lt;/code&gt; patterns&lt;/li&gt;
&lt;li&gt;TypeScript type annotations on params — &lt;code&gt;name: string&lt;/code&gt; is stripped, &lt;code&gt;@param name&lt;/code&gt; is still required&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Zero dependencies
&lt;/h2&gt;

&lt;p&gt;No parsers, no AST, no &lt;code&gt;require("typescript")&lt;/code&gt;. It uses a line-by-line scanner that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Detects &lt;code&gt;export function/const/class&lt;/code&gt; patterns via regexes&lt;/li&gt;
&lt;li&gt;Walks backwards to find a preceding &lt;code&gt;/** */&lt;/code&gt; block&lt;/li&gt;
&lt;li&gt;Compares &lt;code&gt;@param&lt;/code&gt; names in the JSDoc against the actual parameter list&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;npx jsdocscan&lt;/code&gt; works with zero install time. &lt;code&gt;pip install jsdocscan&lt;/code&gt; brings in nothing extra.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/jsdocscan" rel="noopener noreferrer"&gt;npmjs.com/package/jsdocscan&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyPI: &lt;a href="https://pypi.org/project/jsdocscan/" rel="noopener noreferrer"&gt;pypi.org/project/jsdocscan&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub (Node): &lt;a href="https://github.com/jjdoor/jsdocscan" rel="noopener noreferrer"&gt;jjdoor/jsdocscan&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub (Python): &lt;a href="https://github.com/jjdoor/jsdocscan-py" rel="noopener noreferrer"&gt;jjdoor/jsdocscan-py&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both versions pass the same 38-test suite. Same exit codes, same output format.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>javascript</category>
      <category>cli</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I kept finding gaps in my repos after open-sourcing — so I built a zero-dep CLI to catch them first</title>
      <dc:creator>benjamin</dc:creator>
      <pubDate>Mon, 22 Jun 2026 13:15:31 +0000</pubDate>
      <link>https://dev.to/_06a3df6b50aec966668fb/i-kept-finding-gaps-in-my-repos-after-open-sourcing-so-i-built-a-zero-dep-cli-to-catch-them-first-20mb</link>
      <guid>https://dev.to/_06a3df6b50aec966668fb/i-kept-finding-gaps-in-my-repos-after-open-sourcing-so-i-built-a-zero-dep-cli-to-catch-them-first-20mb</guid>
      <description>&lt;p&gt;You've just bootstrapped a new project. You have the code, the tests, the CI. Then a week later someone opens an issue asking for a license. Another asks why there's no CHANGELOG. Your &lt;code&gt;package.json&lt;/code&gt; has been missing a &lt;code&gt;description&lt;/code&gt; field since day one — you just never noticed.&lt;/p&gt;

&lt;p&gt;This isn't a bug. It's a repo hygiene gap. And it's easy to miss when you're moving fast.&lt;/p&gt;

&lt;p&gt;So I built &lt;code&gt;repogap&lt;/code&gt; — a zero-dependency CLI that catches these gaps before they become problems.&lt;/p&gt;

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

&lt;p&gt;Run it in any repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;repogap
&lt;span class="go"&gt;
  ✓ README.md
  ✗ LICENSE         missing
  ✓ .gitignore
  ! CHANGELOG       missing (recommended)

  package.json
    ✓ name          my-tool
    ✓ version       1.0.0
    ✗ description   missing or empty
    ✓ license       MIT
    ! repository    missing

repogap: drift detected — 2 errors, 2 warnings
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;File checks&lt;/strong&gt; (auto-detected, all variants):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;README&lt;/code&gt; — accepts &lt;code&gt;.md&lt;/code&gt;, &lt;code&gt;.rst&lt;/code&gt;, &lt;code&gt;.txt&lt;/code&gt;, bare&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LICENSE&lt;/code&gt; — accepts &lt;code&gt;LICENCE&lt;/code&gt;, &lt;code&gt;COPYING&lt;/code&gt;, &lt;code&gt;.txt&lt;/code&gt;, &lt;code&gt;.md&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.gitignore&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CHANGELOG&lt;/code&gt; — warn by default, accepts &lt;code&gt;.md&lt;/code&gt;, &lt;code&gt;.rst&lt;/code&gt;, &lt;code&gt;HISTORY.md&lt;/code&gt;, etc.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Manifest field checks&lt;/strong&gt; (auto-detects &lt;code&gt;package.json&lt;/code&gt; or &lt;code&gt;pyproject.toml&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;name&lt;/code&gt;, &lt;code&gt;version&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;, &lt;code&gt;license&lt;/code&gt; — required&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;repository&lt;/code&gt; — warn&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Install (zero dependencies)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Node — no install needed&lt;/span&gt;
npx repogap

&lt;span class="c"&gt;# Python&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;repogap
repogap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both versions produce identical output. Drop either one into CI and it behaves the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flags
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;repogap &lt;span class="nt"&gt;--strict&lt;/span&gt;         &lt;span class="c"&gt;# warnings become errors (exit 1)&lt;/span&gt;
repogap &lt;span class="nt"&gt;--no-changelog&lt;/span&gt;   &lt;span class="c"&gt;# skip CHANGELOG check&lt;/span&gt;
repogap &lt;span class="nt"&gt;--no-fields&lt;/span&gt;      &lt;span class="c"&gt;# skip manifest field checks&lt;/span&gt;
repogap &lt;span class="nt"&gt;--json&lt;/span&gt;           &lt;span class="c"&gt;# machine-readable output&lt;/span&gt;
repogap ./packages/ui    &lt;span class="c"&gt;# check a sub-package&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Add to CI
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Check repo hygiene&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx repogap&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or run it once before open-sourcing a private repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx repogap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Design note: warnings vs errors
&lt;/h2&gt;

&lt;p&gt;The distinction is intentional. Missing README, LICENSE, &lt;code&gt;.gitignore&lt;/code&gt;, or a blank &lt;code&gt;description&lt;/code&gt; are &lt;strong&gt;errors&lt;/strong&gt; — they're either legally important (LICENSE), functionally broken (&lt;code&gt;description&lt;/code&gt; shows up in npm search), or universally expected. A missing CHANGELOG and &lt;code&gt;repository&lt;/code&gt; field are &lt;strong&gt;warnings&lt;/strong&gt; — common gaps, but not blocking.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--strict&lt;/code&gt; collapses the distinction if you want zero tolerance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/repogap" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/repogap&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyPI: &lt;a href="https://pypi.org/project/repogap/" rel="noopener noreferrer"&gt;https://pypi.org/project/repogap/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub (Node): &lt;a href="https://github.com/jjdoor/repogap" rel="noopener noreferrer"&gt;https://github.com/jjdoor/repogap&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub (Python): &lt;a href="https://github.com/jjdoor/repogap-py" rel="noopener noreferrer"&gt;https://github.com/jjdoor/repogap-py&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;What's in your repo hygiene checklist?&lt;/strong&gt; Is there a file or field you always forget to add? Curious what others have burned by.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>opensource</category>
      <category>cli</category>
      <category>devops</category>
    </item>
    <item>
      <title>I kept shipping version mismatches — so I built a zero-dep CLI that checks all three sources at once</title>
      <dc:creator>benjamin</dc:creator>
      <pubDate>Mon, 22 Jun 2026 07:59:33 +0000</pubDate>
      <link>https://dev.to/_06a3df6b50aec966668fb/i-kept-shipping-version-mismatches-so-i-built-a-zero-dep-cli-that-checks-all-three-sources-at-once-3dh0</link>
      <guid>https://dev.to/_06a3df6b50aec966668fb/i-kept-shipping-version-mismatches-so-i-built-a-zero-dep-cli-that-checks-all-three-sources-at-once-3dh0</guid>
      <description>&lt;p&gt;You know the drill. You bump &lt;code&gt;package.json&lt;/code&gt;, update the CHANGELOG, run the tests, push the tag — and then someone opens a bug report six hours later because the latest entry in &lt;code&gt;CHANGELOG.md&lt;/code&gt; still says &lt;code&gt;1.3.1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It's not a bug in your code. It's a release metadata mismatch. And it happens way more often than it should.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why existing tools don't fully cover this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;semantic-release&lt;/code&gt;&lt;/strong&gt; is great if you let a bot own your entire release workflow. For solo projects or teams who prefer manual control, it's overkill — and it owns your tag format too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;standard-version&lt;/code&gt;&lt;/strong&gt; is deprecated.&lt;/li&gt;
&lt;li&gt;There's no lightweight way to just &lt;em&gt;check&lt;/em&gt; that three things agree: your manifest version, your changelog's latest entry, and your latest git tag.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So after the third time I shipped a mismatch, I built &lt;code&gt;verscan&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;Run it before every release (or add it to CI):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;verscan
&lt;span class="go"&gt;
  ✓ package.json   1.4.0  (reference)
  ✓ CHANGELOG.md   1.4.0
  ✓ git tag        1.4.0

verscan: all sources match → 1.4.0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It checks three sources in one shot:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;package.json&lt;/code&gt; version&lt;/strong&gt; (or &lt;code&gt;pyproject.toml&lt;/code&gt; — auto-detected)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latest &lt;code&gt;## [x.y.z]&lt;/code&gt; heading&lt;/strong&gt; in your &lt;code&gt;CHANGELOG.md&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latest git tag&lt;/strong&gt; via &lt;code&gt;git describe --tags --abbrev=0&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When something disagrees, it tells you exactly what:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;verscan
&lt;span class="go"&gt;
  ✓ package.json   1.4.0  (reference)
  ! CHANGELOG.md   [no version heading found in CHANGELOG.md]
  ✓ git tag        1.4.0

verscan: 1 source(s) could not be read
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit codes: &lt;code&gt;0&lt;/code&gt; all match · &lt;code&gt;1&lt;/code&gt; mismatch · &lt;code&gt;2&lt;/code&gt; parse/read error — CI-friendly by design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install (zero dependencies, dual Node + Python)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Node — no install needed&lt;/span&gt;
npx verscan

&lt;span class="c"&gt;# Python&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;verscan
verscan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both versions produce identical output. I wrote them that way intentionally — if your team spans both toolchains, either version drops into CI without behavioral differences.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flags
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;verscan &lt;span class="nt"&gt;--no-git&lt;/span&gt;          &lt;span class="c"&gt;# skip git tag check (useful before you've tagged)&lt;/span&gt;
verscan &lt;span class="nt"&gt;--no-changelog&lt;/span&gt;    &lt;span class="c"&gt;# skip CHANGELOG (for projects without one)&lt;/span&gt;
verscan &lt;span class="nt"&gt;--json&lt;/span&gt;            &lt;span class="c"&gt;# machine-readable output&lt;/span&gt;
verscan ./packages/ui     &lt;span class="c"&gt;# check a sub-package&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Drop it in CI or a pre-push hook
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# GitHub Actions&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Verify versions aligned&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx verscan&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# pre-push hook&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'verscan'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; .git/hooks/pre-push
&lt;span class="nb"&gt;chmod&lt;/span&gt; +x .git/hooks/pre-push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Two design decisions worth noting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why regex instead of a TOML parser for &lt;code&gt;pyproject.toml&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;tomllib&lt;/code&gt; is only available in Python 3.11+. To support Python &amp;gt;= 3.8 with zero dependencies, I use a targeted regex: &lt;code&gt;r'^\[project\][^[]*?\bversion\s*=\s*["\']([^"\']+)["\']'&lt;/code&gt; with &lt;code&gt;MULTILINE|DOTALL&lt;/code&gt;. It's deliberately conservative — it only matches &lt;code&gt;version&lt;/code&gt; inside &lt;code&gt;[project]&lt;/code&gt;, not &lt;code&gt;[tool.poetry]&lt;/code&gt; or anywhere else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why three sources instead of two?&lt;/strong&gt;&lt;br&gt;
The most common mismatch I've seen isn't the manifest vs. the tag — it's the CHANGELOG vs. everything else. Bumping &lt;code&gt;package.json&lt;/code&gt; is often automated; updating the changelog is manual. Three-way verification catches both the "forgot to tag" and "forgot to update CHANGELOG" cases in one pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/verscan" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/verscan&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyPI: &lt;a href="https://pypi.org/project/verscan/" rel="noopener noreferrer"&gt;https://pypi.org/project/verscan/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub (Node): &lt;a href="https://github.com/jjdoor/verscan" rel="noopener noreferrer"&gt;https://github.com/jjdoor/verscan&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub (Python): &lt;a href="https://github.com/jjdoor/verscan-py" rel="noopener noreferrer"&gt;https://github.com/jjdoor/verscan-py&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;What do you check before shipping a release?&lt;/strong&gt; Do you have a checklist, a script, or do you rely on memory? Curious what others have landed on.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>devops</category>
      <category>cli</category>
      <category>opensource</category>
    </item>
    <item>
      <title>A reformatted PR shows 80 changed lines but changed nothing — I built a zero-dep diff that sees through it</title>
      <dc:creator>benjamin</dc:creator>
      <pubDate>Mon, 22 Jun 2026 02:17:45 +0000</pubDate>
      <link>https://dev.to/_06a3df6b50aec966668fb/a-reformatted-pr-shows-80-changed-lines-but-changed-nothing-i-built-a-zero-dep-diff-that-sees-41pf</link>
      <guid>https://dev.to/_06a3df6b50aec966668fb/a-reformatted-pr-shows-80-changed-lines-but-changed-nothing-i-built-a-zero-dep-diff-that-sees-41pf</guid>
      <description>&lt;p&gt;Someone runs the formatter, the line width changes, and suddenly the pull request touches 80 lines. You scroll through all of it looking for the actual change — and there isn't one. Or there's exactly one, buried in 79 lines of re-wrapping. We've all reviewed that PR.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git diff -w&lt;/code&gt; is supposed to save you here, and it half does: it ignores &lt;em&gt;spacing&lt;/em&gt; changes. But it's still line-anchored, so it &lt;strong&gt;cannot fold reflow&lt;/strong&gt; — re-wrap a function signature across three lines and &lt;code&gt;git diff -w&lt;/code&gt; still shows &lt;code&gt;1 removed + 3 added&lt;/code&gt;, even though not a single token changed. (This is &lt;a href="https://github.com/orgs/community/discussions/20610" rel="noopener noreferrer"&gt;GitHub discussion #20610&lt;/a&gt;, "Ignore Format Changes in Diff" — open and unanswered for years.)&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;logicdiff&lt;/strong&gt;: a diff that folds away whitespace &lt;strong&gt;and&lt;/strong&gt; reflow, and tells you whether a change is real or just formatting.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;logicdiff old.js new.js
&lt;span class="go"&gt;only formatting differs - no logical change (a line diff would show 80 changed lines)

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;logicdiff a.js b.js
&lt;span class="go"&gt;--- a.js
+++ b.js
&lt;/span&gt;&lt;span class="gp"&gt;-42:   const total = price * qty;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;+51:   const total = price + qty;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="go"&gt;
1 token removed, 1 added across 2 logical lines (78 lines folded as reflow/whitespace)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exits &lt;code&gt;0&lt;/code&gt; when the change is formatting-only and &lt;code&gt;1&lt;/code&gt; when it's logical — so CI can flag "this PR is more than a reformat, review carefully." Zero dependencies, and &lt;code&gt;pip install logicdiff&lt;/code&gt; gets the same tool in Python with byte-for-byte identical output.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it folds reflow
&lt;/h2&gt;

&lt;p&gt;The trick is to stop diffing &lt;em&gt;lines&lt;/em&gt; and diff &lt;em&gt;tokens&lt;/em&gt;. logicdiff tokenizes each file into a stream where a token is a run of &lt;code&gt;[A-Za-z0-9_]&lt;/code&gt; or a single punctuation character, and &lt;strong&gt;whitespace is dropped entirely&lt;/strong&gt;. So all of these collapse to the same stream &lt;code&gt;[a, +, b]&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;a+b        a + b        a +
                          b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Respacing and line breaks become invisible. Then it runs a plain &lt;a href="https://blog.jcoglan.com/2017/02/12/the-myers-diff-algorithm-part-1/" rel="noopener noreferrer"&gt;Myers diff&lt;/a&gt; on the token streams. Equal streams ⇒ formatting-only. Different ⇒ the changed tokens get mapped back to their line numbers and shown. Crucially, tokens are compared by &lt;strong&gt;text only&lt;/strong&gt; — their line numbers are metadata — which is exactly why a token that moved to a different line still matches.&lt;/p&gt;

&lt;p&gt;It's language-agnostic on purpose: no parser, no grammar, works on any text (code, YAML, logs, DSLs). The trade-off, which I document rather than hide: like &lt;code&gt;git diff -w&lt;/code&gt;, whitespace &lt;em&gt;inside string literals&lt;/em&gt; is also ignored — &lt;code&gt;"a b"&lt;/code&gt; and &lt;code&gt;"a  b"&lt;/code&gt; read as formatting-only. (&lt;code&gt;difftastic&lt;/code&gt; gets this right with per-language tree-sitter parsing, but it's a multi-megabyte binary that needs a grammar per language and has no "is this formatting-only?" exit code. logicdiff is the zero-config, language-agnostic middle ground.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The fun part: two languages, one diff, to the byte
&lt;/h2&gt;

&lt;p&gt;I wanted a Node build and a Python build that emit identical output — and a diff is a brutal place to attempt that, because Myers has many equal-cost edit scripts and the one you emit depends entirely on tie-breaking. Pick &lt;code&gt;&amp;lt;&lt;/code&gt; vs &lt;code&gt;&amp;lt;=&lt;/code&gt; in one line and the two languages silently diverge (both "correct", different output).&lt;/p&gt;

&lt;p&gt;So the whole thing is pinned:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One canonical Myers variant, ported literally to both languages: V seeded &lt;code&gt;V[1]=0&lt;/code&gt;, &lt;code&gt;k&lt;/code&gt; from &lt;code&gt;-d&lt;/code&gt; to &lt;code&gt;d&lt;/code&gt; step 2, the down-vs-right choice is &lt;strong&gt;exactly&lt;/strong&gt; &lt;code&gt;k == -d || (k != d &amp;amp;&amp;amp; V[k-1] &amp;lt; V[k+1])&lt;/code&gt; (strict &lt;code&gt;&amp;lt;&lt;/code&gt;), and V is snapshotted &lt;em&gt;before&lt;/em&gt; each round so backtracking reads the right one. No &lt;code&gt;difflib.SequenceMatcher&lt;/code&gt; (its heuristics wouldn't match).&lt;/li&gt;
&lt;li&gt;Files are read as &lt;strong&gt;latin-1&lt;/strong&gt; — the one encoding where byte ↔ codepoint is a total bijection — so any input (UTF-8, binary, broken) decodes deterministically, token equality is byte equality, and Node's UTF-16 string indexing vs Python's codepoint indexing stops mattering.&lt;/li&gt;
&lt;li&gt;The tokenizer uses explicit ASCII classes, never &lt;code&gt;\w&lt;/code&gt;/&lt;code&gt;\s&lt;/code&gt; (those are Unicode-aware in Python but ASCII in JS).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A differential fuzz test runs both builds over 500 random file pairs and gets zero byte differences.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx logicdiff old new       &lt;span class="c"&gt;# Node, zero deps&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;logicdiff       &lt;span class="c"&gt;# Python, zero deps, identical output&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MIT licensed, both builds open source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/logicdiff" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/logicdiff&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyPI: &lt;a href="https://pypi.org/project/logicdiff/" rel="noopener noreferrer"&gt;https://pypi.org/project/logicdiff/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/jjdoor/logicdiff" rel="noopener noreferrer"&gt;https://github.com/jjdoor/logicdiff&lt;/a&gt; (Node) · &lt;a href="https://github.com/jjdoor/logicdiff-py" rel="noopener noreferrer"&gt;https://github.com/jjdoor/logicdiff-py&lt;/a&gt; (Python)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's your worst "the diff is all noise" story — a formatter run, a line-ending flip, a mass re-indent? And would a formatting-only CI signal have helped?&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>git</category>
      <category>cli</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Your repo has whitespace problems you can't see — I built a zero-dep CLI that finds and fixes them all</title>
      <dc:creator>benjamin</dc:creator>
      <pubDate>Sat, 20 Jun 2026 15:46:05 +0000</pubDate>
      <link>https://dev.to/_06a3df6b50aec966668fb/your-repo-has-whitespace-problems-you-cant-see-i-built-a-zero-dep-cli-that-finds-and-fixes-them-2lpb</link>
      <guid>https://dev.to/_06a3df6b50aec966668fb/your-repo-has-whitespace-problems-you-cant-see-i-built-a-zero-dep-cli-that-finds-and-fixes-them-2lpb</guid>
      <description>&lt;p&gt;Whitespace problems are the ones you can't see until they bite. A pull request where half the "changes" are trailing-space diffs. A shell script that breaks in CI because someone's editor saved it CRLF. A &lt;code&gt;.env&lt;/code&gt; with a UTF-8 BOM that makes the first variable name mysteriously not match. A file with no final newline that turns one-line changes into two-line diffs forever.&lt;/p&gt;

&lt;p&gt;None of it shows up on screen. All of it shows up in &lt;code&gt;git blame&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Today, catching this takes three or four tools stitched together — and I got tired of that, so I built &lt;strong&gt;wssweep&lt;/strong&gt;: one zero-config command that finds &lt;em&gt;all&lt;/em&gt; the common whitespace smells and, with &lt;code&gt;--fix&lt;/code&gt;, cleans them in place.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;npx wssweep
&lt;span class="go"&gt;
  src/app.js  (2)
      14: trailing-whitespace  trailing whitespace
       -  missing-final-newline  no newline at end of file
  config.yml  (1)
       -  mixed-eol  mixed line endings (CRLF×3, LF×1)

  ✖ 3 whitespace issues in 2 files  (mixed-eol=1, missing-final-newline=1, trailing-whitespace=1)

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;npx wssweep &lt;span class="nt"&gt;--fix&lt;/span&gt;     &lt;span class="c"&gt;# clean them&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It checks seven things: trailing whitespace, mixed CRLF/LF line endings, lone CRs, a missing final newline, extra trailing blank lines, a UTF-8 BOM, and tabs mixed with spaces in one indent. Non-zero exit on findings, so it's a CI gate. &lt;code&gt;pip install wssweep&lt;/code&gt; gets the same tool in Python — &lt;strong&gt;byte-for-byte identical&lt;/strong&gt; output and fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not editorconfig-checker / pre-commit / prettier?
&lt;/h2&gt;

&lt;p&gt;Because each does part of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;editorconfig-checker&lt;/strong&gt; reports — but you have to author an &lt;code&gt;.editorconfig&lt;/code&gt; first, and it can't fix anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pre-commit&lt;/strong&gt;'s &lt;code&gt;trailing-whitespace&lt;/code&gt; / &lt;code&gt;end-of-file-fixer&lt;/code&gt; / &lt;code&gt;mixed-line-ending&lt;/code&gt; hooks &lt;em&gt;do&lt;/em&gt; fix, but only inside the pre-commit framework, and they're three separate hooks. Nobody runs them ad-hoc on a fresh checkout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;prettier&lt;/strong&gt; fixes whitespace only as a side effect of reformatting &lt;em&gt;all&lt;/em&gt; your code, and won't touch files it can't parse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;dos2unix&lt;/strong&gt; does line endings and nothing else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;wssweep is the one &lt;code&gt;npx&lt;/code&gt;/&lt;code&gt;pip&lt;/code&gt; command, no config and no framework, that does the whole set at once and drops into any CI regardless of toolchain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The opinions that make it zero-config
&lt;/h2&gt;

&lt;p&gt;A whitespace tool with no config has to make the right calls by default, or it's noise:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;consistently-CRLF&lt;/strong&gt; file is &lt;em&gt;fine&lt;/em&gt; — only a file mixing CRLF and LF is flagged. &lt;code&gt;.bat&lt;/code&gt;/&lt;code&gt;.cmd&lt;/code&gt; even keep CRLF when fixed, so it never breaks a Windows script.&lt;/li&gt;
&lt;li&gt;In &lt;strong&gt;Markdown&lt;/strong&gt;, a line ending in exactly two spaces is a hard line break — semantically meaningful — so the trailing-whitespace check is skipped in &lt;code&gt;.md&lt;/code&gt;. (BOM, final-newline, and EOL checks still apply.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mixed-indentation is report-only.&lt;/strong&gt; Auto-converting tabs↔spaces needs a tab-width guess that can silently wreck alignment, so wssweep tells you and leaves it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The fun part: --fix that two languages agree on, to the byte
&lt;/h2&gt;

&lt;p&gt;I wanted a Node build and a Python build that produce identical reports &lt;em&gt;and write identical bytes&lt;/em&gt; on &lt;code&gt;--fix&lt;/code&gt;. Whitespace is the worst possible domain for that, because every shortcut leaks platform behavior:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;str.splitlines()&lt;/code&gt; in Python splits on &lt;strong&gt;VT, FF, NEL, U+2028, U+2029&lt;/strong&gt; too — it would invent phantom lines a &lt;code&gt;split(/\r\n|\r|\n/)&lt;/code&gt; never sees. Forbidden.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;\s&lt;/code&gt; matches different things in JS and Python (NBSP, the BOM char, …). So "whitespace" here means &lt;em&gt;exactly&lt;/em&gt; &lt;code&gt;[ \t]&lt;/code&gt; — explicit, never &lt;code&gt;\s&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Python's text-mode file IO silently translates &lt;code&gt;\r\n&lt;/code&gt;→&lt;code&gt;\n&lt;/code&gt; on read and &lt;code&gt;\n&lt;/code&gt;→&lt;code&gt;os.linesep&lt;/code&gt; on write — it would rewrite the very bytes the tool inspects. So everything is &lt;strong&gt;raw bytes&lt;/strong&gt;, and the scan runs on a latin-1 (byte-faithful) view where every byte maps 1:1 to a code point.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--fix&lt;/code&gt; builds the corrected bytes, compares to the original, and writes only if they differ — atomically (temp file + rename), preserving the file mode, never touching a binary or skipped file. And it's idempotent: run it twice, the second run does nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A differential test fixes two identical trees with the two builds and &lt;code&gt;cmp&lt;/code&gt;s every file — zero differing bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wssweep &lt;span class="nt"&gt;--fix&lt;/span&gt;      &lt;span class="c"&gt;# Node, zero deps&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;wssweep    &lt;span class="c"&gt;# Python, zero deps, identical fixes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MIT licensed, both builds open source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/wssweep" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/wssweep&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyPI: &lt;a href="https://pypi.org/project/wssweep/" rel="noopener noreferrer"&gt;https://pypi.org/project/wssweep/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/jjdoor/wssweep" rel="noopener noreferrer"&gt;https://github.com/jjdoor/wssweep&lt;/a&gt; (Node) · &lt;a href="https://github.com/jjdoor/wssweep-py" rel="noopener noreferrer"&gt;https://github.com/jjdoor/wssweep-py&lt;/a&gt; (Python)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run &lt;code&gt;npx wssweep&lt;/code&gt; on your current project. How many trailing-whitespace lines and missing newlines is it hiding? (Mine's never zero.)&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>cli</category>
      <category>productivity</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Your design-token migration isn't done — here's a zero-dep CLI that finds the colors that escaped</title>
      <dc:creator>benjamin</dc:creator>
      <pubDate>Sat, 20 Jun 2026 14:21:04 +0000</pubDate>
      <link>https://dev.to/_06a3df6b50aec966668fb/your-design-token-migration-isnt-done-heres-a-zero-dep-cli-that-finds-the-colors-that-escaped-190f</link>
      <guid>https://dev.to/_06a3df6b50aec966668fb/your-design-token-migration-isnt-done-heres-a-zero-dep-cli-that-finds-the-colors-that-escaped-190f</guid>
      <description>&lt;p&gt;You did the design-token migration. You celebrated. Then three months later someone changed the brand color, dark mode shipped, and a dozen buttons stayed stubbornly the old blue — because they still had &lt;code&gt;background: #3f82f0&lt;/code&gt; hardcoded instead of &lt;code&gt;var(--primary)&lt;/code&gt;, and nobody noticed.&lt;/p&gt;

&lt;p&gt;This is the boring, recurring way token systems rot: the migration is "done," but raw colors are still buried in components, and you don't find out until the day they break.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;hexsweep&lt;/strong&gt; — a zero-dependency CLI that scans your source and flags the raw color literals that escaped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;npx hexsweep src/
&lt;span class="go"&gt;
  src/components/Button.tsx  (2)
&lt;/span&gt;&lt;span class="gp"&gt;    14:18  #&lt;/span&gt;3f82f0  &lt;span class="o"&gt;[&lt;/span&gt;hex6]  background
&lt;span class="go"&gt;    27:10  rgb(255, 0, 0)  [rgb]  color

  ✖ 2 hardcoded colors in 1 file (23 files clean)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exits non-zero when it finds something, so it drops straight into CI as a gate. &lt;code&gt;pip install hexsweep&lt;/code&gt; gets you the same tool in Python — the two builds print byte-for-byte identical output.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Just grep for it" — no
&lt;/h2&gt;

&lt;p&gt;The reflex is &lt;code&gt;grep '#[0-9a-f]{6}'&lt;/code&gt;. It's noisy enough that nobody keeps it in CI:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it flags &lt;code&gt;#header&lt;/code&gt; id-selectors and &lt;code&gt;url(#gradient)&lt;/code&gt; references (not colors);&lt;/li&gt;
&lt;li&gt;it flags colors sitting in comments;&lt;/li&gt;
&lt;li&gt;it misses &lt;code&gt;rgb()&lt;/code&gt;/&lt;code&gt;hsl()&lt;/code&gt;, 3/4/8-digit hex, and CSS-in-JS;&lt;/li&gt;
&lt;li&gt;and it has no idea that &lt;code&gt;--primary: #3f82f0&lt;/code&gt; is &lt;em&gt;correct&lt;/em&gt; and &lt;code&gt;color: #3f82f0&lt;/code&gt; is the bug.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  "Use stylelint" — closer, but it yells at the wrong file
&lt;/h2&gt;

&lt;p&gt;stylelint's &lt;code&gt;color-no-hex&lt;/code&gt; can do this for plain CSS, but it needs a config + postcss/custom-syntax, it can't see hex inside TSX/CSS-in-JS string literals, and — the part that makes people turn it off — it flags your &lt;code&gt;tokens.css&lt;/code&gt; exactly as loudly as a stray hex in a component. The request to &lt;strong&gt;exempt token definitions&lt;/strong&gt; has sat open and unimplemented for years. So the one file that's &lt;em&gt;supposed&lt;/em&gt; to contain raw colors is the loudest thing in your report.&lt;/p&gt;

&lt;p&gt;hexsweep's whole point is that distinction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--primary: #3f82f0&lt;/code&gt;&lt;/strong&gt; (a definition — custom property, &lt;code&gt;$scss&lt;/code&gt;, or &lt;code&gt;@less&lt;/code&gt; var) → &lt;strong&gt;allowed by default&lt;/strong&gt;. That's where colors are supposed to live.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;color: #3f82f0&lt;/code&gt;&lt;/strong&gt; (a usage) → &lt;strong&gt;flagged&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run &lt;code&gt;--strict&lt;/code&gt; if you want zero raw hex anywhere, tokens included.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fun part: a linter with no parser, that two languages agree on byte-for-byte
&lt;/h2&gt;

&lt;p&gt;I wanted a Node build &lt;em&gt;and&lt;/em&gt; a Python build with identical output, and I wanted zero dependencies — which rules out a real CSS/JS parser. So hexsweep is a &lt;strong&gt;line scanner&lt;/strong&gt; with a few careful tricks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It &lt;strong&gt;blanks comment spans&lt;/strong&gt; (&lt;code&gt;//&lt;/code&gt;, &lt;code&gt;/* */&lt;/code&gt;, &lt;code&gt;&amp;lt;!-- --&amp;gt;&lt;/code&gt;) to spaces with a small state machine — but &lt;strong&gt;keeps string contents&lt;/strong&gt;, because CSS-in-JS colors live in strings (&lt;code&gt;styled.div`color:#3f82f0`&lt;/code&gt; &lt;em&gt;should&lt;/em&gt; be caught).&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;id-selector / &lt;code&gt;url()&lt;/code&gt; gate&lt;/strong&gt; disambiguates &lt;code&gt;#fff {&lt;/code&gt; (a selector) from &lt;code&gt;color: #fff&lt;/code&gt; (a value) by position.&lt;/li&gt;
&lt;li&gt;The hex regex enumerates only legal lengths (3/4/6/8) so &lt;code&gt;#1234567&lt;/code&gt; isn't half-matched.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Getting Node and Python to agree to the byte was the real work — and the bugs are &lt;em&gt;exactly&lt;/em&gt; the cross-language ones you'd expect: Python's &lt;code&gt;\d&lt;/code&gt;/&lt;code&gt;\w&lt;/code&gt; match Unicode digits while JS's don't (so every regex uses explicit &lt;code&gt;[0-9A-Fa-f]&lt;/code&gt;); &lt;code&gt;os.path.join('.', x)&lt;/code&gt; keeps a &lt;code&gt;./&lt;/code&gt; that Node's &lt;code&gt;path.join&lt;/code&gt; strips; an emoji inside a comment blanks to a different number of spaces if you iterate UTF-16 units vs code points (so the comment stripper iterates code points); and columns are counted in &lt;strong&gt;UTF-8 bytes&lt;/strong&gt; to dodge the UTF-16-vs-code-point off-by-one. A differential test runs both builds over the same trees and gets zero diffs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it deliberately doesn't do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No named colors&lt;/strong&gt; (&lt;code&gt;red&lt;/code&gt;, &lt;code&gt;tan&lt;/code&gt;) by default — too noisy, they collide with identifiers and class names. The validated pain is raw hex.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No autofix&lt;/strong&gt; — it has no &lt;code&gt;#3f82f0 → --primary&lt;/code&gt; map, and a report that rewrites your files is a report you can't trust in CI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No config file, no &lt;code&gt;oklch()/lab()&lt;/code&gt;&lt;/strong&gt; — the goal is a high-signal, zero-config gate, not a parser.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx hexsweep src/      &lt;span class="c"&gt;# Node, zero deps&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;hexsweep   &lt;span class="c"&gt;# Python, zero deps, identical output&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MIT licensed, both builds open source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/hexsweep" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/hexsweep&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyPI: &lt;a href="https://pypi.org/project/hexsweep/" rel="noopener noreferrer"&gt;https://pypi.org/project/hexsweep/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/jjdoor/hexsweep" rel="noopener noreferrer"&gt;https://github.com/jjdoor/hexsweep&lt;/a&gt; (Node) · &lt;a href="https://github.com/jjdoor/hexsweep-py" rel="noopener noreferrer"&gt;https://github.com/jjdoor/hexsweep-py&lt;/a&gt; (Python)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run &lt;code&gt;npx hexsweep&lt;/code&gt; on a project you migrated to tokens a while ago. I'd bet there's at least one &lt;code&gt;#hex&lt;/code&gt; still hiding in there — what did you find?&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>css</category>
      <category>frontend</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The most-installed XML viewers are 2★ and abandoned. So I hand-wrote one.</title>
      <dc:creator>benjamin</dc:creator>
      <pubDate>Sat, 20 Jun 2026 14:18:41 +0000</pubDate>
      <link>https://dev.to/_06a3df6b50aec966668fb/the-most-installed-xml-viewers-are-2-and-abandoned-so-i-hand-wrote-one-267g</link>
      <guid>https://dev.to/_06a3df6b50aec966668fb/the-most-installed-xml-viewers-are-2-and-abandoned-so-i-hand-wrote-one-267g</guid>
      <description>&lt;p&gt;Open a &lt;code&gt;.xml&lt;/code&gt; file in your browser and you usually get one of three things: a wall of unindented text, a viewer that freezes the tab on anything big, or — when the XML is actually broken — a blank page that won't tell you &lt;em&gt;why&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So I went looking for a good XML viewer extension. The most-installed ones on the stores are &lt;strong&gt;stuck around 2 stars and haven't been updated since 2023&lt;/strong&gt;. The reviews rhyme: "doesn't work", "freezes", "just shows plain text". I built &lt;strong&gt;Xwift&lt;/strong&gt; to do the three things those don't.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tree view&lt;/strong&gt; — collapsible, syntax-highlighted elements, attributes, text, CDATA, comments and processing instructions. Expand/collapse all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source view&lt;/strong&gt; — pretty-print (2 / 4 / tab) or minify, with line numbers and one-click copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tells you where it's broken&lt;/strong&gt; — a &lt;em&gt;tolerant&lt;/em&gt; parser that keeps going on malformed input and lists every well-formedness problem with its &lt;code&gt;line:column&lt;/code&gt;: mismatched and unclosed tags, duplicate attributes, unbound namespace prefixes, an unescaped &lt;code&gt;&amp;amp;&lt;/code&gt;, a stray &lt;code&gt;]]&amp;gt;&lt;/code&gt;… click an error to jump to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Doesn't choke on big files&lt;/strong&gt; — parsing runs in a Web Worker, so the tab stays responsive where "load it all into the DOM" viewers stall.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The interesting part (for fellow devs)
&lt;/h2&gt;

&lt;p&gt;The whole thing is &lt;strong&gt;hand-written with zero dependencies&lt;/strong&gt; — no &lt;code&gt;DOMParser&lt;/code&gt;, no library. That was the point. The parser is a tolerant single-pass scanner that builds a best-effort tree &lt;em&gt;and&lt;/em&gt; collects precise errors, and the formatter re-emits significant whitespace verbatim, so mixed content and &lt;code&gt;xml:space="preserve"&lt;/code&gt; survive a round-trip.&lt;/p&gt;

&lt;p&gt;Before shipping I ran an adversarial audit over the core, and it surfaced &lt;strong&gt;15 real bugs&lt;/strong&gt; I would never have caught by eye. A few favorites:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Namespace scopes were plain objects, so an &lt;em&gt;unbound&lt;/em&gt; prefix literally named &lt;code&gt;toString&lt;/code&gt; or &lt;code&gt;__proto__&lt;/code&gt; resolved up the prototype chain and got treated as &lt;strong&gt;bound&lt;/strong&gt;. Fixed with &lt;code&gt;Map&lt;/code&gt;-based scopes.&lt;/li&gt;
&lt;li&gt;The serializer was recursive, so a deeply-nested-but-valid document overflowed the call stack even though the iterative parser handled it fine. Rewrote it as an explicit-stack walk.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;[hidden]&lt;/code&gt; attribute was silently overridden by a &lt;code&gt;display:flex&lt;/code&gt; CSS rule — the empty drop-zone covered the whole viewer. My first E2E missed it because it asserted element &lt;em&gt;content&lt;/em&gt;, not &lt;em&gt;visibility&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then it's verified in real Chrome and real Firefox — the parser produces byte-identical output under V8 and SpiderMonkey.&lt;/p&gt;

&lt;h2&gt;
  
  
  Respectful by default
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero data collection.&lt;/strong&gt; Everything runs locally — no network requests with your content, no account, no telemetry. Files you open never leave your browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimal permissions.&lt;/strong&gt; No host permissions, no &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt;, no tab snooping. It reads a page only the moment you ask it to.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Get it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Source (MIT, audit the parser):&lt;/strong&gt; &lt;a href="https://github.com/jjdoor/xwift" rel="noopener noreferrer"&gt;https://github.com/jjdoor/xwift&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chrome / Edge / Firefox:&lt;/strong&gt; rolling out as each store finishes review.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's the worst XML-viewing moment you've had — the freeze, the blank page, or the "valid" XML that very much wasn't?&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Is that timestamp in seconds or milliseconds? I built a zero-dep CLI that just tells you — both directions.</title>
      <dc:creator>benjamin</dc:creator>
      <pubDate>Fri, 19 Jun 2026 05:29:05 +0000</pubDate>
      <link>https://dev.to/_06a3df6b50aec966668fb/is-that-timestamp-in-seconds-or-milliseconds-i-built-a-zero-dep-cli-that-just-tells-you-both-3nh9</link>
      <guid>https://dev.to/_06a3df6b50aec966668fb/is-that-timestamp-in-seconds-or-milliseconds-i-built-a-zero-dep-cli-that-just-tells-you-both-3nh9</guid>
      <description>&lt;p&gt;You find a timestamp in a log line: &lt;code&gt;1718750000123&lt;/code&gt;. Is that seconds? Milliseconds? You reach for &lt;code&gt;date&lt;/code&gt;... and on macOS it's &lt;code&gt;date -r&lt;/code&gt;, on Linux it's &lt;code&gt;date -d @&lt;/code&gt;, and &lt;em&gt;neither&lt;/em&gt; of them will tell you that you grabbed milliseconds and your "date" is now in the year 56435. So you give up and paste the number into the third epoch-converter website that Google hands you.&lt;/p&gt;

&lt;p&gt;I do this several times a week. So I built &lt;strong&gt;epochlens&lt;/strong&gt; — one zero-dependency command that auto-detects the unit, works the same on every platform, and goes both directions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;npx epochlens 1718750000
&lt;span class="go"&gt;
  input     1718750000  (unix seconds)

  unix s    1718750000
  unix ms   1718750000000
  unix µs   1718750000000000
  unix ns   1718750000000000000
  iso utc   2024-06-18T22:33:20Z
  iso local 2024-06-19T06:33:20+08:00
  relative  2 minutes ago
  rfc 2822  Tue, 18 Jun 2024 22:33:20 +0000
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It guesses seconds vs millis vs micros vs nanos by magnitude and &lt;strong&gt;echoes the guess&lt;/strong&gt; so you can catch it (&lt;code&gt;--unit ms&lt;/code&gt; to override). It goes the other way too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;epochlens 2024-06-18T22:33:20Z   &lt;span class="c"&gt;# any ISO 8601 / RFC 2822 date → every epoch precision&lt;/span&gt;
epochlens now                    &lt;span class="c"&gt;# the current moment, all forms&lt;/span&gt;
&lt;span class="nb"&gt;echo &lt;/span&gt;1718750000 | epochlens      &lt;span class="c"&gt;# reads stdin&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pip install epochlens&lt;/code&gt; gets you the exact same tool in Python — the two builds print &lt;strong&gt;byte-for-byte identical&lt;/strong&gt; output.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fun part: making two languages agree to the byte
&lt;/h2&gt;

&lt;p&gt;I wanted a Node build &lt;em&gt;and&lt;/em&gt; a Python build that produce identical output, because half of us live in &lt;code&gt;npx&lt;/code&gt; and half in &lt;code&gt;pip&lt;/code&gt;. That turned out to be the hard part — date handling is a minefield of disagreements, and not just across languages but within them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;toISOString()&lt;/code&gt; vs &lt;code&gt;isoformat()&lt;/code&gt;&lt;/strong&gt;: Node gives &lt;code&gt;...20.000Z&lt;/code&gt; (always 3 fractional digits, literal &lt;code&gt;Z&lt;/code&gt;); Python gives &lt;code&gt;...20+00:00&lt;/code&gt; (no fraction when zero, 6 digits otherwise, &lt;code&gt;+00:00&lt;/code&gt; not &lt;code&gt;Z&lt;/code&gt;). Three mismatches in one field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parsing&lt;/strong&gt;: &lt;code&gt;Date.parse("2024-06-18 12:00")&lt;/code&gt; is lenient and reads it as &lt;em&gt;local&lt;/em&gt; time; Python's &lt;code&gt;fromisoformat&lt;/code&gt; reads it as naive; &lt;code&gt;"June 18 2024"&lt;/code&gt; parses in Node and throws in Python.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rounding&lt;/strong&gt;: &lt;code&gt;Math.round(2.5)&lt;/code&gt; is &lt;code&gt;3&lt;/code&gt;; Python's &lt;code&gt;round(2.5)&lt;/code&gt; is &lt;code&gt;2&lt;/code&gt; (banker's rounding).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Negative floor division&lt;/strong&gt; (pre-1970 timestamps): JS truncates toward zero, Python floors toward −∞, so &lt;code&gt;-3 % 1000&lt;/code&gt; disagrees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nanoseconds&lt;/strong&gt;: a 19-digit ns value blows past &lt;code&gt;Number.MAX_SAFE_INTEGER&lt;/code&gt;, so Node needs &lt;code&gt;BigInt&lt;/code&gt; where Python's ints just work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sub-minute timezones&lt;/strong&gt;: for pre-1900 dates, &lt;code&gt;getTimezoneOffset()&lt;/code&gt; (minutes) and &lt;code&gt;tm_gmtoff&lt;/code&gt; (seconds) literally disagree about the local offset.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix was to delegate &lt;strong&gt;nothing&lt;/strong&gt; to the runtime's date library. Every conversion is plain integer math over a &lt;a href="https://howardhinnant.github.io/date_algorithms.html" rel="noopener noreferrer"&gt;proleptic-Gregorian civil-day algorithm&lt;/a&gt;, and every output field is hand-formatted. No &lt;code&gt;Date.parse&lt;/code&gt;, no &lt;code&gt;toISOString&lt;/code&gt;, no &lt;code&gt;fromisoformat&lt;/code&gt;, no &lt;code&gt;strftime&lt;/code&gt;. The reward: a property test that diffs the two builds over thousands of inputs — from year 1 to year 9999, every precision, every offset — and gets zero differences.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it deliberately doesn't do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No named &lt;code&gt;--tz America/New_York&lt;/code&gt;&lt;/strong&gt; yet. Python's &lt;code&gt;zoneinfo&lt;/code&gt; reads an OS tz database that Windows doesn't ship, and pulling in &lt;code&gt;tzdata&lt;/code&gt; would break the zero-dependency promise. You get UTC, your local time, and a fixed &lt;code&gt;--offset ±HH:MM&lt;/code&gt; (pure arithmetic, portable everywhere).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No natural-language input&lt;/strong&gt; (&lt;code&gt;"3 days ago"&lt;/code&gt;). Relative time is output only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Years are clamped to 0001–9999&lt;/strong&gt; (anything else is rejected rather than silently producing &lt;code&gt;+275760&lt;/code&gt; on one platform and crashing on the other).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx epochlens 1718750000     &lt;span class="c"&gt;# Node, zero deps&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;epochlens        &lt;span class="c"&gt;# Python, zero deps, identical output&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MIT licensed, both builds open source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/epochlens" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/epochlens&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyPI: &lt;a href="https://pypi.org/project/epochlens/" rel="noopener noreferrer"&gt;https://pypi.org/project/epochlens/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/jjdoor/epochlens" rel="noopener noreferrer"&gt;https://github.com/jjdoor/epochlens&lt;/a&gt; (Node) · &lt;a href="https://github.com/jjdoor/epochlens-py" rel="noopener noreferrer"&gt;https://github.com/jjdoor/epochlens-py&lt;/a&gt; (Python)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's your most-hated timestamp footgun — the seconds/millis mixup, timezone math, or something worse? And does anyone actually remember &lt;code&gt;date -d @&lt;/code&gt; vs &lt;code&gt;date -r&lt;/code&gt; without looking it up?&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>cli</category>
      <category>productivity</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Putting a file in .gitignore does nothing if git already tracks it. I built a CLI to find the leftovers.</title>
      <dc:creator>benjamin</dc:creator>
      <pubDate>Fri, 19 Jun 2026 03:46:33 +0000</pubDate>
      <link>https://dev.to/_06a3df6b50aec966668fb/putting-a-file-in-gitignore-does-nothing-if-git-already-tracks-it-i-built-a-cli-to-find-the-31cl</link>
      <guid>https://dev.to/_06a3df6b50aec966668fb/putting-a-file-in-gitignore-does-nothing-if-git-already-tracks-it-i-built-a-cli-to-find-the-31cl</guid>
      <description>&lt;p&gt;You added &lt;code&gt;.env&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt;. You felt responsible. But three weeks later it's still in the repo, still pushed to GitHub, still in every clone — because &lt;strong&gt;adding a path to &lt;code&gt;.gitignore&lt;/code&gt; does nothing to a file git already tracks.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's not a bug. It's documented behavior: &lt;code&gt;.gitignore&lt;/code&gt; only stops &lt;em&gt;untracked&lt;/em&gt; files from being added. Anything already committed keeps getting tracked, ignore rule or not. So the secrets, build artifacts, and 40 MB log files that were committed &lt;em&gt;before&lt;/em&gt; someone wrote the rule just... stay.&lt;/p&gt;

&lt;p&gt;The fix is one command — &lt;code&gt;git rm --cached&lt;/code&gt; — but only once someone &lt;em&gt;notices&lt;/em&gt;. And nobody notices, because &lt;code&gt;git status&lt;/code&gt; is clean and the file looks ignored.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;gitslip&lt;/strong&gt;: a zero-dependency CLI that finds every tracked file your own ignore rules say should be gone, and hands you the exact fix.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;npx gitslip
&lt;span class="go"&gt;
2 tracked files are ignored by your rules but still committed:

  config/secrets.env
      ↳ .gitignore:7  *.env
  logs/app.log
      ↳ .gitignore:2  *.log

Fix — stop tracking them (keeps your local copy):
  git rm --cached -- config/secrets.env
  git rm --cached -- logs/app.log

  or let gitslip do it:  gitslip --apply
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It tells you &lt;strong&gt;which rule&lt;/strong&gt; caught each file (&lt;code&gt;.gitignore:7  *.env&lt;/code&gt;), so there's no guessing. And &lt;code&gt;--apply&lt;/code&gt; runs the &lt;code&gt;git rm --cached&lt;/code&gt; for you — it only un-tracks, it never deletes your working copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just grep?
&lt;/h2&gt;

&lt;p&gt;You &lt;em&gt;can&lt;/em&gt; &lt;code&gt;grep&lt;/code&gt; your &lt;code&gt;.gitignore&lt;/code&gt; patterns against &lt;code&gt;git ls-files&lt;/code&gt;. But:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A raw &lt;code&gt;grep '\.env'&lt;/code&gt; can't tell a still-tracked leftover from a file that's correctly excluded, and it has no idea about &lt;code&gt;!negation&lt;/code&gt; rules, &lt;code&gt;build/&lt;/code&gt; directory rules, nested &lt;code&gt;.gitignore&lt;/code&gt; files, &lt;code&gt;.git/info/exclude&lt;/code&gt;, or your global &lt;code&gt;core.excludesFile&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Reimplementing gitignore's matching semantics to get this right is exactly the kind of subtly-wrong code you don't want guarding your secrets.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;gitslip doesn't reimplement anything. It asks git.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works (the fun part)
&lt;/h2&gt;

&lt;p&gt;Detection is a single git incantation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git ls-files &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nt"&gt;--exclude-standard&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-c&lt;/code&gt; = tracked (cached), &lt;code&gt;-i&lt;/code&gt; = ignored, &lt;code&gt;--exclude-standard&lt;/code&gt; = use all the standard ignore sources. That's the authoritative "tracked &lt;strong&gt;and&lt;/strong&gt; ignored" set, and git handles every negation/directory/nested rule correctly. No matching logic of our own = no disagreements with git.&lt;/p&gt;

&lt;p&gt;The interesting part is &lt;strong&gt;naming the rule&lt;/strong&gt; that caught each file. The obvious tool is &lt;code&gt;git check-ignore -v&lt;/code&gt;... except it short-circuits: for a file git is &lt;em&gt;already tracking&lt;/em&gt;, check-ignore reports "not ignored" and refuses to name a pattern. (And &lt;code&gt;--no-index&lt;/code&gt; didn't reliably fix it on the git I tested.)&lt;/p&gt;

&lt;p&gt;The trick: run check-ignore against an &lt;strong&gt;empty index&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;GIT_INDEX_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/empty git check-ignore &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="nt"&gt;--stdin&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point &lt;code&gt;GIT_INDEX_FILE&lt;/code&gt; at a path that doesn't exist — git treats it as an empty index, so &lt;em&gt;nothing&lt;/em&gt; is tracked, so check-ignore stops short-circuiting and happily names the matching &lt;code&gt;.gitignore:line:pattern&lt;/code&gt; for every path. It's read-only, so the file is never even created.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx gitslip          &lt;span class="c"&gt;# Node, zero deps&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;gitslip  &lt;span class="c"&gt;# Python, zero deps — byte-for-byte identical output&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both builds are pure standard library. There's a Node version and a Python version because half of you live in one ecosystem and half in the other, and they produce identical output down to the byte (I diff them in CI).&lt;/p&gt;

&lt;p&gt;It's also a clean CI gate — exits &lt;code&gt;1&lt;/code&gt; if anything slipped, so you can fail a build that's about to commit an ignored file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx gitslip&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Try it on your repo
&lt;/h2&gt;

&lt;p&gt;Seriously, run &lt;code&gt;npx gitslip&lt;/code&gt; in your current project right now. If you've ever &lt;code&gt;git add -A&lt;/code&gt;'d before writing a &lt;code&gt;.gitignore&lt;/code&gt;, there's a decent chance something's in there.&lt;/p&gt;

&lt;p&gt;What's the worst thing you've found still tracked in a repo — a secret, a 100 MB binary, someone's &lt;code&gt;.DS_Store&lt;/code&gt; from 2019? Tell me below.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/gitslip" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/gitslip&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyPI: &lt;a href="https://pypi.org/project/gitslip/" rel="noopener noreferrer"&gt;https://pypi.org/project/gitslip/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/jjdoor/gitslip" rel="noopener noreferrer"&gt;https://github.com/jjdoor/gitslip&lt;/a&gt; (Node) · &lt;a href="https://github.com/jjdoor/gitslip-py" rel="noopener noreferrer"&gt;https://github.com/jjdoor/gitslip-py&lt;/a&gt; (Python)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;MIT licensed. Issues and PRs welcome.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>git</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Most JSON-to-Schema tools over-fit one example. mkschema merges many samples.</title>
      <dc:creator>benjamin</dc:creator>
      <pubDate>Fri, 19 Jun 2026 02:51:41 +0000</pubDate>
      <link>https://dev.to/_06a3df6b50aec966668fb/most-json-to-schema-tools-over-fit-one-example-mkschema-merges-many-samples-51lg</link>
      <guid>https://dev.to/_06a3df6b50aec966668fb/most-json-to-schema-tools-over-fit-one-example-mkschema-merges-many-samples-51lg</guid>
      <description>&lt;p&gt;You need a JSON Schema for an API response, a config file, a stream of log records — for validation, docs, or contract tests. Hand-writing it is tedious and you'll get it subtly wrong. So you reach for a "JSON to JSON Schema" generator… and it hands you a schema built from &lt;strong&gt;one&lt;/strong&gt; example: every field marked &lt;code&gt;required&lt;/code&gt;, every type pinned to whatever that single record happened to contain. The first real payload that omits an optional field fails validation against a schema you just generated.&lt;/p&gt;

&lt;p&gt;The problem isn't generating a schema. It's that one example isn't your data. So I built &lt;strong&gt;mkschema&lt;/strong&gt; to merge &lt;strong&gt;many&lt;/strong&gt; samples. &lt;strong&gt;Zero dependencies, no network.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ printf '{"id":1,"name":"Ada","age":30}\n{"id":2,"age":30.5}\n' | npx mkschema --ndjson -

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "age":  { "type": "number" },     // 30 (int) and 30.5 (float) unioned
    "id":   { "type": "integer" },
    "name": { "type": "string" }
  },
  "required": ["age", "id"]           // name was missing from sample 2 → optional
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Feed it one sample and everything is required (same as the others). Feed it your &lt;strong&gt;actual&lt;/strong&gt; data — a &lt;code&gt;--ndjson&lt;/code&gt; log file, a folder of fixtures, a paged API dump — and it figures out what's really there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A key in &lt;strong&gt;every&lt;/strong&gt; sample is &lt;code&gt;required&lt;/code&gt;; a key in only some is &lt;strong&gt;optional&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;integer&lt;/code&gt; here and a &lt;code&gt;float&lt;/code&gt; there union to &lt;code&gt;number&lt;/code&gt;; genuinely different types become a &lt;code&gt;type&lt;/code&gt; array.&lt;/li&gt;
&lt;li&gt;String &lt;strong&gt;formats&lt;/strong&gt; are inferred (&lt;code&gt;date-time&lt;/code&gt;, &lt;code&gt;date&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;uuid&lt;/code&gt;, &lt;code&gt;ipv4&lt;/code&gt;, &lt;code&gt;uri&lt;/code&gt;) — but only kept when every sample of that field agrees.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Usage
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mkschema response.json                 &lt;span class="c"&gt;# one file&lt;/span&gt;
mkschema a.json b.json c.json          &lt;span class="c"&gt;# merge several files&lt;/span&gt;
mkschema &lt;span class="nt"&gt;--ndjson&lt;/span&gt; events.ndjson        &lt;span class="c"&gt;# one sample per line&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://api/users | mkschema -  &lt;span class="c"&gt;# straight from an API&lt;/span&gt;
mkschema users.json &lt;span class="nt"&gt;--title&lt;/span&gt; User &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; user.schema.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It writes the schema to stdout (draft 2020-12), with properties and &lt;code&gt;required&lt;/code&gt;&lt;br&gt;
sorted, so it diffs cleanly in version control.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few honest notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero dependencies, both builds&lt;/strong&gt; — a Node build and a Python build that
produce identical output. &lt;code&gt;npx mkschema&lt;/code&gt; or &lt;code&gt;pip install mkschema&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Numbers are classified by value&lt;/strong&gt;, so &lt;code&gt;5.0&lt;/code&gt; is an &lt;code&gt;integer&lt;/code&gt; — and the two
builds agree (a subtlety that took an adversarial pass to get right, along
with rejecting &lt;code&gt;NaN&lt;/code&gt;/&lt;code&gt;Infinity&lt;/code&gt; identically and not mistaking a &lt;code&gt;user@host&lt;/code&gt;
URL for an email).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It infers structure, not constraints.&lt;/strong&gt; You get the scaffold from real data;
add your own &lt;code&gt;enum&lt;/code&gt;, &lt;code&gt;minLength&lt;/code&gt;, &lt;code&gt;pattern&lt;/code&gt; on top.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;npm:&lt;/strong&gt; &lt;a href="https://www.npmjs.com/package/mkschema" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/mkschema&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PyPI:&lt;/strong&gt; &lt;a href="https://pypi.org/project/mkschema/" rel="noopener noreferrer"&gt;https://pypi.org/project/mkschema/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source:&lt;/strong&gt; &lt;a href="https://github.com/jjdoor/mkschema" rel="noopener noreferrer"&gt;https://github.com/jjdoor/mkschema&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;How do you produce JSON Schemas today — by hand, from a single example, or from a&lt;br&gt;
framework's types? And would "schema from N real samples" actually fit your&lt;br&gt;
workflow?&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>json</category>
      <category>cli</category>
      <category>opensource</category>
    </item>
    <item>
      <title>dotenv loads your .env — it doesn't check it. So I built a typed validator.</title>
      <dc:creator>benjamin</dc:creator>
      <pubDate>Fri, 19 Jun 2026 01:58:29 +0000</pubDate>
      <link>https://dev.to/_06a3df6b50aec966668fb/dotenv-loads-your-env-it-doesnt-check-it-so-i-built-a-typed-validator-42p5</link>
      <guid>https://dev.to/_06a3df6b50aec966668fb/dotenv-loads-your-env-it-doesnt-check-it-so-i-built-a-typed-validator-42p5</guid>
      <description>&lt;p&gt;A &lt;code&gt;PORT=8O80&lt;/code&gt; typo (that's a letter O), a &lt;code&gt;DATABASE_URL&lt;/code&gt; you forgot to set, a &lt;code&gt;NODE_ENV=prodd&lt;/code&gt; — none of these fail when your app &lt;em&gt;starts&lt;/em&gt;. They fail later: a cryptic stack trace three layers into startup, a service that boots but talks to the wrong database, a feature flag that's silently off in production. The error is never "your env is wrong"; it's whatever broke downstream.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dotenv&lt;/code&gt; loads your &lt;code&gt;.env&lt;/code&gt;. It doesn't &lt;em&gt;check&lt;/em&gt; it. So I built &lt;strong&gt;envward&lt;/strong&gt;: validate the whole environment against a small typed schema up front, and fail loudly — with the actual problem — before a single line of app code runs. &lt;strong&gt;Zero dependencies, no network.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ envward

.env — checked against env.schema.json

  ✗ API_KEY   missing — required string
  ✗ NODE_ENV  "prodd" is not one of: development, production, test
  ✗ PORT      70000 is above max 65535

3 problem(s), 3 key(s) valid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exits non-zero on any problem, so it drops straight into a &lt;code&gt;prestart&lt;/code&gt; hook or a CI step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get a schema in one command
&lt;/h2&gt;

&lt;p&gt;No hand-writing JSON from scratch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;envward &lt;span class="nt"&gt;--init&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; env.schema.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--init&lt;/code&gt; reads your existing &lt;code&gt;.env&lt;/code&gt; and guesses a type for each key (&lt;code&gt;8080&lt;/code&gt; → &lt;code&gt;int&lt;/code&gt;, &lt;code&gt;https://…&lt;/code&gt; → &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;true&lt;/code&gt; → &lt;code&gt;bool&lt;/code&gt;, …), marking them required. Then you tighten it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"PORT"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;         &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"int"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"min"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"max"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;65535&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"DATABASE_URL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"NODE_ENV"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"enum"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"values"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"development"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"API_KEY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minLength"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Types: &lt;code&gt;string&lt;/code&gt; (with &lt;code&gt;minLength&lt;/code&gt;/&lt;code&gt;maxLength&lt;/code&gt;/&lt;code&gt;pattern&lt;/code&gt;), &lt;code&gt;int&lt;/code&gt; / &lt;code&gt;number&lt;/code&gt; (with &lt;code&gt;min&lt;/code&gt;/&lt;code&gt;max&lt;/code&gt;), &lt;code&gt;bool&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;enum&lt;/code&gt;. An empty value (&lt;code&gt;KEY=&lt;/code&gt;) counts as missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it's different from a drift checker
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;.env&lt;/code&gt; drift tool tells you which keys are &lt;em&gt;missing&lt;/em&gt; versus &lt;code&gt;.env.example&lt;/code&gt;. envward validates the &lt;strong&gt;values&lt;/strong&gt;: is &lt;code&gt;PORT&lt;/code&gt; actually an integer in range, is &lt;code&gt;DATABASE_URL&lt;/code&gt; actually a URL, is &lt;code&gt;NODE_ENV&lt;/code&gt; one of the allowed set. Different failure mode, caught at a different time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx envward          &lt;span class="c"&gt;# Node&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;envward  &lt;span class="c"&gt;# Python — same behavior&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two builds (Node + Python) that validate identically, so it fits whatever your stack already runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use it as a gate
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# package.json: "prestart": "envward"   — refuse to boot with a broken .env&lt;/span&gt;
&lt;span class="c"&gt;# CI:           envward --env .env.ci --strict&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--strict&lt;/code&gt; also flags keys present in &lt;code&gt;.env&lt;/code&gt; but missing from the schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  A couple of honest notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero dependencies, both builds&lt;/strong&gt; — stdlib only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A malformed schema is an error, not a guess.&lt;/strong&gt; A non-numeric &lt;code&gt;min&lt;/code&gt;, a bad regex &lt;code&gt;pattern&lt;/code&gt;, an unknown type — envward exits &lt;code&gt;2&lt;/code&gt; with a clear message in both builds, rather than crashing or silently passing. (Getting Node and Python to agree on every edge here took a real adversarial pass.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pattern&lt;/code&gt; is matched in ASCII mode&lt;/strong&gt; and as an unanchored search — wrap it in &lt;code&gt;^…$&lt;/code&gt;; keep to a portable regex subset for identical behavior across both builds.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;npm:&lt;/strong&gt; &lt;a href="https://www.npmjs.com/package/envward" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/envward&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PyPI:&lt;/strong&gt; &lt;a href="https://pypi.org/project/envward/" rel="noopener noreferrer"&gt;https://pypi.org/project/envward/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source:&lt;/strong&gt; &lt;a href="https://github.com/jjdoor/envward" rel="noopener noreferrer"&gt;https://github.com/jjdoor/envward&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;How do you guard environment config today — a hand-rolled startup check, a framework feature, or just hope? And would you gate CI on it?&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>devops</category>
      <category>cli</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
