<?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>GitHub Actions won't tell you your CI is getting worse. I built a zero-dep CLI that does.</title>
      <dc:creator>benjamin</dc:creator>
      <pubDate>Fri, 03 Jul 2026 06:43:16 +0000</pubDate>
      <link>https://dev.to/_06a3df6b50aec966668fb/github-actions-wont-tell-you-your-ci-is-getting-worse-i-built-a-zero-dep-cli-that-does-260o</link>
      <guid>https://dev.to/_06a3df6b50aec966668fb/github-actions-wont-tell-you-your-ci-is-getting-worse-i-built-a-zero-dep-cli-that-does-260o</guid>
      <description>&lt;p&gt;GitHub Actions shows you one run at a time. Green check, red X, green check, green check, red X. You scroll the list, you re-run the flaky one, you move on. Nobody's asking the question that actually matters: &lt;strong&gt;is this getting better or worse?&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I calculated how much my CI failures actually cost. Curious what your pipeline success rate looks like — has anyone else tracked the actual wasted compute time over time?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's a real question from someone who did the math by hand and found their failures were burning a real chunk of their compute budget. The replies were the same story you'd expect: heavyweight CI platforms have their own dashboards for this, but nobody had a lightweight, local way to just... track it.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;citrend&lt;/strong&gt;: pull your GitHub Actions run history into a local file, get a trend.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx citrend &lt;span class="nb"&gt;sync&lt;/span&gt; &lt;span class="nt"&gt;--repo&lt;/span&gt; owner/name
npx citrend report &lt;span class="nt"&gt;--repo&lt;/span&gt; owner/name
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What it actually shows you
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;citrend report &lt;span class="nt"&gt;--repo&lt;/span&gt; acme/widgets
&lt;span class="go"&gt;
acme/widgets — 812 run(s) (2 in progress)

  success rate:    87.4%  (699/800 settled, 12 skipped)
  wasted runs:     101 (12.6%)
  total compute:   118h 42m
  wasted compute:  14h 6m

  weekly trend (oldest → newest):
    2026-06-05  91.2% success, 8 wasted (58m)
    2026-06-12  88.0% success, 11 wasted (1h 22m)
    2026-06-19  79.4% success, 22 wasted (3h 8m)
    2026-06-26  84.1% success, 15 wasted (2h 1m)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That weekly column is the entire point. A single &lt;code&gt;gh run list&lt;/code&gt; will never show you that week 3 was a cliff — you'd have to notice it got annoying to work in, which is a much slower and much less precise signal than a number going from 91% to 79%.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;sync&lt;/code&gt; pulls your workflow run history from the GitHub REST API and caches it locally (deduped by run id, so you can run it on a schedule without piling up duplicates). &lt;code&gt;report&lt;/code&gt; reads that cache — no network call — and computes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Success rate&lt;/strong&gt;, over settled runs only (still-running runs don't count either way until they conclude, and skipped runs are excluded from the denominator since they're not a pass/fail outcome).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Wasted" runs and compute&lt;/strong&gt; — anything that settled and wasn't a success or a skip: failures, cancellations, timeouts. "Compute" here is wall-clock run duration from the Actions API, a solid proxy for burned runner time, not an exact billed-minutes figure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A weekly trend&lt;/strong&gt;, so a slow decline (or a sudden cliff) is visible instead of buried in a scrolling run list.&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 citrend &lt;span class="nb"&gt;sync&lt;/span&gt; &lt;span class="nt"&gt;--repo&lt;/span&gt; owner/name &lt;span class="nt"&gt;--token&lt;/span&gt; &lt;span class="nv"&gt;$GITHUB_TOKEN&lt;/span&gt;   &lt;span class="c"&gt;# token optional for public repos, raises the 60/hr rate limit&lt;/span&gt;
npx citrend report &lt;span class="nt"&gt;--repo&lt;/span&gt; owner/name &lt;span class="nt"&gt;--weeks&lt;/span&gt; 8

pip &lt;span class="nb"&gt;install &lt;/span&gt;citrend   &lt;span class="c"&gt;# Python build, same commands, shares the same local cache format&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero dependencies in either language — just the standard library talking to &lt;code&gt;api.github.com&lt;/code&gt;. History lives at &lt;code&gt;~/.citrend/&amp;lt;owner&amp;gt;__&amp;lt;repo&amp;gt;/&lt;/code&gt;, not committed to the repo it's tracking (it's a cache of data GitHub already has, not something worth diffing in git).&lt;/p&gt;

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

&lt;p&gt;Does your team track this anywhere already — a scheduled job dumping stats into a spreadsheet, a Grafana panel someone built once and nobody looks at? Curious what's actually surviving in practice versus what gets built and abandoned.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>devops</category>
      <category>cli</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The same root cause keeps coming back because nobody tracks it. I built a zero-dep CLI that does.</title>
      <dc:creator>benjamin</dc:creator>
      <pubDate>Fri, 03 Jul 2026 03:38:57 +0000</pubDate>
      <link>https://dev.to/_06a3df6b50aec966668fb/the-same-root-cause-keeps-coming-back-because-nobody-tracks-it-i-built-a-zero-dep-cli-that-does-3i34</link>
      <guid>https://dev.to/_06a3df6b50aec966668fb/the-same-root-cause-keeps-coming-back-because-nobody-tracks-it-i-built-a-zero-dep-cli-that-does-3i34</guid>
      <description>&lt;p&gt;You write the postmortem. You file the action items. Everyone nods, the doc gets archived, and life moves on.&lt;/p&gt;

&lt;p&gt;Six months later, the exact same root cause takes down the exact same service — and nobody in the room remembers the first incident, let alone that its fix never actually shipped.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"We use rootly to track this automatically. It flags when incidents have the same root cause as previous ones."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's a real answer from an SRE thread about this exact problem — and it's a paid, hosted feature of a full incident-management platform. Most teams don't have rootly or incident.io. What they have is a folder of markdown postmortems that nobody diffs against each other.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;rootecho&lt;/strong&gt;: a zero-dependency CLI that does the one useful thing those platforms do for this — flag when a new incident's root cause echoes a past one, and show you whether that past incident's action items ever actually got finished.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Each postmortem is one JSON record — free-text &lt;code&gt;root_cause&lt;/code&gt; and/or curated &lt;code&gt;root_cause_tags&lt;/code&gt;, plus &lt;code&gt;action_items&lt;/code&gt; with a status:&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"INC-2026-014"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Payment webhook retries exhausted"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"root_cause"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"webhook retry queue misconfigured to drop after 3 attempts, no dead-letter fallback"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"root_cause_tags"&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;"webhook"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"retry-queue"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dead-letter"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"config"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"action_items"&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AI-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;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Add dead-letter queue for webhook retries"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"open"&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;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;&lt;code&gt;rootecho add&lt;/code&gt; records it and compares against your history:&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;rootecho add inc-2026-014.json
&lt;span class="go"&gt;⚠ root cause echo detected for "INC-2026-014":

  INC-2026-003 (2026-03-15) — 100% similar root cause
  Payment webhook retries exhausted
    ✓ Add retry backoff [done]
    ✗ Add monitoring alert for queue depth [open] — 93d overdue
  → 1 action item(s) from this past incident were never finished.

recorded to .rootecho/history.jsonl
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole point of the tool in one output: not just "you've seen this before," but &lt;em&gt;"and here's the fix that never happened."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;rootecho check&lt;/code&gt; does the same comparison without recording — exit code 1 on an echo, so you can wire it into CI as a gate on the PR that closes out a postmortem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just grep the old postmortems?
&lt;/h2&gt;

&lt;p&gt;Because "webhook retry queue misconfigured" and "retry queue drops webhooks after repeated failures" are the same root cause in different words, and grep doesn't know that. rootecho scores similarity with Jaccard overlap — tags first (curated, low-noise, weighted 70%), free text as a fallback/secondary signal (30%) — no ML dependency, no network call, runs in milliseconds on a folder of JSON files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design notes for the technical reader
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Storage is project-local, not &lt;code&gt;~/.rootecho&lt;/code&gt;.&lt;/strong&gt; History lives in &lt;code&gt;.rootecho/history.jsonl&lt;/code&gt; in your repo, one JSON object per line, meant to be committed — so &lt;code&gt;git blame&lt;/code&gt;/&lt;code&gt;git log&lt;/code&gt; on that file doubles as an incident timeline the whole team shares, and &lt;code&gt;git diff&lt;/code&gt; on it during a PR review shows exactly what changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero dependencies, dual-language.&lt;/strong&gt; A Node build (&lt;code&gt;npx rootecho&lt;/code&gt;) and a Python build (&lt;code&gt;pip install rootecho&lt;/code&gt;) exist because teams aren't single-language, and they read/write the &lt;em&gt;exact same&lt;/em&gt; history file — down to byte-identical &lt;code&gt;--json&lt;/code&gt; output, which took more care than I expected (Python's &lt;code&gt;json.dumps&lt;/code&gt; escapes non-ASCII by default and prints whole floats as &lt;code&gt;1.0&lt;/code&gt;; JS's &lt;code&gt;Date.parse&lt;/code&gt; and Python's &lt;code&gt;datetime.fromisoformat&lt;/code&gt; accept different date grammars depending on Python version. All ironed out — timestamps are epoch milliseconds on both sides, dates are a hand-rolled strict ISO 8601 parser shared in spirit between both implementations instead of trusting either language's built-in leniency).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Corrupt data degrades, it doesn't crash.&lt;/strong&gt; A JSONL file meant to be hand-edited and merged by a team &lt;em&gt;will&lt;/em&gt; occasionally get a stray &lt;code&gt;null&lt;/code&gt; line from a bad merge. Both CLIs skip malformed entries instead of taking down &lt;code&gt;add&lt;/code&gt;/&lt;code&gt;check&lt;/code&gt;/&lt;code&gt;list&lt;/code&gt; for the whole team.&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 rootecho init incident.json      &lt;span class="c"&gt;# scaffold a postmortem&lt;/span&gt;
npx rootecho add incident.json       &lt;span class="c"&gt;# record it, flag any echo&lt;/span&gt;

pip &lt;span class="nb"&gt;install &lt;/span&gt;rootecho                 &lt;span class="c"&gt;# or the Python build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MIT licensed, ~500 lines total, no dependencies in either language.&lt;/p&gt;

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

&lt;p&gt;Does your team already have a home-grown way of catching repeat root causes — a spreadsheet, a Slack bot, a wiki convention? I'd like to hear what's actually working (or not) before I add anything past this MVP.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>devops</category>
      <category>cli</category>
      <category>opensource</category>
    </item>
    <item>
      <title>branchreap: safely reap merged &amp; gone-upstream git branches (zero-dep, npx or pip)</title>
      <dc:creator>benjamin</dc:creator>
      <pubDate>Wed, 01 Jul 2026 07:59:53 +0000</pubDate>
      <link>https://dev.to/_06a3df6b50aec966668fb/branchreap-safely-reap-merged-gone-upstream-git-branches-zero-dep-npx-or-pip-598o</link>
      <guid>https://dev.to/_06a3df6b50aec966668fb/branchreap-safely-reap-merged-gone-upstream-git-branches-zero-dep-npx-or-pip-598o</guid>
      <description>&lt;p&gt;I ran &lt;code&gt;git branch&lt;/code&gt; on a repo I'd been on for a year and counted &lt;strong&gt;forty-seven&lt;/strong&gt; local branches. Maybe six were alive. The rest were &lt;code&gt;feature/*&lt;/code&gt; that shipped months ago, &lt;code&gt;fix/*&lt;/code&gt; whose PRs merged and whose remotes were deleted the same minute, and a couple of experiments I'd genuinely forgotten writing.&lt;/p&gt;

&lt;p&gt;The usual advice is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git branch &lt;span class="nt"&gt;--merged&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'\*'&lt;/span&gt; | xargs git branch &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I've pasted that snippet into a dozen shells. It has three problems: it misses branches whose upstream is &lt;strong&gt;gone&lt;/strong&gt; (deleted remote, but the local sticks around forever), it cheerfully includes &lt;code&gt;main&lt;/code&gt; if you forget the &lt;code&gt;grep&lt;/code&gt;, and it makes &lt;em&gt;you&lt;/em&gt; the safety check.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;branchreap&lt;/strong&gt; — it finds the branches that are actually safe to reap, and &lt;em&gt;only&lt;/em&gt; those.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx branchreap          &lt;span class="c"&gt;# scan, read-only&lt;/span&gt;
&lt;span class="c"&gt;# or&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;branchreap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What it reaps — and what it refuses to
&lt;/h2&gt;

&lt;p&gt;Reaps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;merged&lt;/strong&gt; branches (fully merged into your default branch)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;gone&lt;/strong&gt; branches (upstream deleted), &lt;em&gt;as long as they carry no unmerged commits&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Never touches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;current&lt;/strong&gt; branch&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;default&lt;/strong&gt; branch (&lt;code&gt;main&lt;/code&gt; / &lt;code&gt;master&lt;/code&gt; / whatever &lt;code&gt;origin/HEAD&lt;/code&gt; says)&lt;/li&gt;
&lt;li&gt;anything &lt;strong&gt;unmerged / unpushed&lt;/strong&gt;, unless you explicitly pass &lt;code&gt;--include-unmerged&lt;/code&gt; (and even then it still won't delete current or default)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That safety floor is the whole design. Run it without reading a word of docs and the worst case is it deletes a branch git itself already considers merged.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like
&lt;/h2&gt;



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

default branch: main

  ✗ feature/old-login          merged         merged into main
  ✗ fix/typo                    gone           upstream gone · merged into main
  ────────────────────────────────────────────────────────
  2 reapable  (1 merged · 1 gone)  ·  4 kept

  1 gone branch(es) held back (unmerged commits). Use --include-unmerged to reap.

$ branchreap clean            # asks first
$ branchreap clean --yes      # for a shell alias
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--dry-run&lt;/code&gt; previews, &lt;code&gt;--json&lt;/code&gt; pipes, &lt;code&gt;--fetch&lt;/code&gt; runs a &lt;code&gt;git fetch --prune&lt;/code&gt; first so the &lt;code&gt;gone&lt;/code&gt; status is fresh (git only marks upstreams gone after a prune).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why another branch cleaner?
&lt;/h2&gt;

&lt;p&gt;There are a few good ones (git-sweep, git-trim), but most are single-purpose — merged-only &lt;em&gt;or&lt;/em&gt; remote-only — and several are unmaintained. branchreap does &lt;strong&gt;merged and gone in one pass&lt;/strong&gt;, leads with safety, and is genuinely zero-dependency: it just shells out to &lt;code&gt;git&lt;/code&gt; and parses the output. Same logic ships as a Node package and a Python package (byte-for-byte aligned), so &lt;code&gt;npx branchreap&lt;/code&gt; and &lt;code&gt;pipx run branchreap&lt;/code&gt; both work with nothing to install.&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 branchreap          &lt;span class="c"&gt;# Node&lt;/span&gt;
pipx run branchreap     &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/branchreap" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/branchreap&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyPI: &lt;a href="https://pypi.org/project/branchreap/" rel="noopener noreferrer"&gt;https://pypi.org/project/branchreap/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/jjdoor/branchreap" rel="noopener noreferrer"&gt;https://github.com/jjdoor/branchreap&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How many local branches are in your busiest repo right now? Run &lt;code&gt;git branch | wc -l&lt;/code&gt; and tell me — I bet it's higher than you'd guess. 👇&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>git</category>
      <category>cli</category>
      <category>opensource</category>
    </item>
    <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>
  </channel>
</rss>
