<?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: Kazu</title>
    <description>The latest articles on DEV Community by Kazu (@_402ccbd6e5cb02871506).</description>
    <link>https://dev.to/_402ccbd6e5cb02871506</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%2F3422958%2F561728f2-3289-4079-9cba-cc1c855c8b68.png</url>
      <title>DEV Community: Kazu</title>
      <link>https://dev.to/_402ccbd6e5cb02871506</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/_402ccbd6e5cb02871506"/>
    <language>en</language>
    <item>
      <title>The first 30 seconds of a Postgres incident: why they take 30 minutes</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 23 Jun 2026 13:02:00 +0000</pubDate>
      <link>https://dev.to/_402ccbd6e5cb02871506/the-first-30-seconds-of-a-postgres-incident-why-they-take-30-minutes-5bnb</link>
      <guid>https://dev.to/_402ccbd6e5cb02871506/the-first-30-seconds-of-a-postgres-incident-why-they-take-30-minutes-5bnb</guid>
      <description>&lt;p&gt;2 a.m. PagerDuty goes off. "Production is slow."&lt;/p&gt;

&lt;p&gt;You open your laptop, fire up psql with bleary eyes, and connect to production. The prompt comes up. The cursor blinks after &lt;code&gt;production=&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And your hands stop.&lt;/p&gt;

&lt;p&gt;Now, where was I supposed to look first? Do I get an overview of the whole DB, or do I start drilling into individual queries? It's been a while since the last incident, and the first move doesn't come to me.&lt;/p&gt;

&lt;h2&gt;
  
  
  "What kind of incident is this even?" — and you freeze
&lt;/h2&gt;

&lt;p&gt;"Production is slow." If you could act on that much information, this would be easy. Is the slowness in the code or the DB? Is one runaway slow query thrashing, or is the overall load up? Are connections exhausted? Is a chain of lock waits piling up somewhere? Is an &lt;code&gt;idle in transaction&lt;/code&gt; session holding a transaction open?&lt;/p&gt;

&lt;p&gt;These are all different incidents. Different places to look, different moves to make. Do you check &lt;code&gt;pg_stat_activity&lt;/code&gt;? &lt;code&gt;pg_locks&lt;/code&gt;? Cache hit ratio in &lt;code&gt;pg_stat_database&lt;/code&gt;? Before you can decide which &lt;code&gt;pg_stat_*&lt;/code&gt; to hit, you need a hunch about which category of incident this is.&lt;/p&gt;

&lt;p&gt;But you don't have anything yet to form that hunch.&lt;/p&gt;

&lt;p&gt;What you need, really, is the big picture. The goal is to narrow it down. "Ah, this is connection exhaustion." "No, this is a lock." Only once you've placed the incident into a category does the first query become obvious. And for that, you first want to see — on one screen — what the entire database looks like at this exact moment. How are connections doing? Is TPS spiking or dropping? Is anything waiting? You don't want the overview for its own sake. It's the footing you need before you can make the call.&lt;/p&gt;

&lt;p&gt;But what psql gives you is one query, one snapshot. Fire off a &lt;code&gt;SELECT&lt;/code&gt; and you get back a single slice. No big picture. You want the whole picture, and you have to start by guessing the right query to get a view of it.&lt;/p&gt;

&lt;p&gt;So you pick the first query on instinct. Usually it's "&lt;code&gt;pg_stat_activity&lt;/code&gt; for now." Not wrong. But you're running it without knowing whether it's the right call. With no hunch yet, you're running the very query that was supposed to give you one.&lt;/p&gt;

&lt;p&gt;Guess wrong and you pay for it in extra time. Once, I saw the connection count was unusually high, decided "connection exhaustion," suspected the app's connection pool, and nearly started a conversation about adding servers. The actual culprit was a single long query thrown by a nightly batch. It had settled in and clogged everything behind it, and the connections were just stacking up as a result. I'd looked at the result (connection count) and mistaken it for the cause (the long query). If I'd been able to see the whole thing on one screen first, I'd probably have caught it in a couple of minutes.&lt;/p&gt;

&lt;p&gt;These few minutes — from the alert firing to the first keystroke — are the most nerve-wracking part.&lt;/p&gt;

&lt;h2&gt;
  
  
  You don't have the queries memorized
&lt;/h2&gt;

&lt;p&gt;Say you commit to "&lt;code&gt;pg_stat_activity&lt;/code&gt; first." The next problem arrives.&lt;/p&gt;

&lt;p&gt;You can't write that query, properly filtered, from memory.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SELECT * FROM pg_stat_activity&lt;/code&gt; you can write. You can — but run it and 143 rows scroll past, and you can't tell which one is the culprit. What you want is "only the active, long-running queries, ordered by duration descending, only the columns I need." Can you write that &lt;code&gt;SELECT&lt;/code&gt;, with the &lt;code&gt;state = 'active'&lt;/code&gt; filter and the &lt;code&gt;now() - query_start&lt;/code&gt; math, at 2 a.m. with nothing in front of you? I can't.&lt;/p&gt;

&lt;p&gt;So you open a browser. You search "postgres long running queries." You open Stack Overflow. You copy a query. You paste it into psql. &lt;code&gt;ERROR: column "waiting" does not exist&lt;/code&gt;. An old answer referencing a column that was removed in PG 9.6. You fix it.&lt;/p&gt;

&lt;p&gt;Locks are even worse. JOIN &lt;code&gt;pg_locks&lt;/code&gt; and &lt;code&gt;pg_stat_activity&lt;/code&gt; to produce the pairs of which session is blocking which — how many people have that query memorized? You search again. You paste again.&lt;/p&gt;

&lt;p&gt;Then you remember, "we put a query collection in the team wiki," and go looking for it. It does exist. But it was last updated two years ago, and half of it doesn't run as-is. Figuring out which ones still work and which are stale is, again, more searching. In the middle of an incident, you're bouncing between search results and a wiki instead of your own repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is that the cause, or the result?
&lt;/h2&gt;

&lt;p&gt;So you go through all that trouble and finally produce a list of long-running queries. At the top sits a query that's been running for 2 minutes 14 seconds.&lt;/p&gt;

&lt;p&gt;Is this the culprit? You don't know.&lt;/p&gt;

&lt;p&gt;Is the query itself heavy and slow, or is it being blocked somewhere else and just sitting there long as a &lt;em&gt;result&lt;/em&gt; of waiting? One snapshot can't separate the two. You think you've grabbed the cause, when you might be looking at the victim.&lt;/p&gt;

&lt;p&gt;To separate them, you also need to look at the lock side. Which means another query. And then you reconcile both in your head. At 2 a.m.&lt;/p&gt;

&lt;h2&gt;
  
  
  And you blink, and 30 minutes are gone
&lt;/h2&gt;

&lt;p&gt;Queries are snapshots, so if you want to follow how things change, you have to re-run them by hand. Hit &lt;code&gt;pg_stat_activity&lt;/code&gt; once. The situation shifts. Hit it again. Recall the history with &lt;code&gt;↑&lt;/code&gt;, hit enter. Run it again.&lt;/p&gt;

&lt;p&gt;Written out, the first response looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Search "postgres running queries"
→ Copy a query from Stack Overflow
→ Fix it for a PG version mismatch
→ Sort by duration
→ Wonder: is this the cause or the result?
→ Search for a separate locks query
→ Paste and run
→ The situation changed, so re-run everything
→ …
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It feels like an instant. Your hands never stop. Searching, pasting, fixing, re-running. And yet you glance at the clock and 30 minutes have passed.&lt;/p&gt;

&lt;p&gt;For those 30 minutes, the incident channel in Slack keeps growing. When someone asks "what's the status?", all you can say is "still investigating." Your hands haven't stopped. You've been hitting something the whole time. And yet you haven't even taken the first step toward recovery. You don't even have a hunch about the cause.&lt;/p&gt;

&lt;p&gt;One tool has a tagline: "the first 30 seconds of a Postgres incident." But with nothing in hand, those "30 seconds" quietly balloon into 30 minutes of manual queries and searching. Where it should have taken 30 seconds, 30 minutes go by. That gap is what hits hardest in a late-night incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually wanted was "now, on one screen"
&lt;/h2&gt;

&lt;p&gt;Looking back, what I wanted in the first 30 seconds was always the same thing.&lt;/p&gt;

&lt;p&gt;"What does the database look like right now" — on one screen. Are connections filling up? Has cache hit ratio dropped? Are there long-running queries? Is a lock queue forming? Is a session sitting with a transaction open? I want to see this as one picture first, without memorizing queries and without searching.&lt;/p&gt;

&lt;p&gt;Then, having placed it — "this is connection exhaustion," "no, this is a lock" — I want to drill down only in that direction. Overview first, then dig. The order was always supposed to be this, and yet with psql I had no choice but to start from the one query for "digging."&lt;/p&gt;

&lt;p&gt;Because I was digging without an overview, I dug holes on instinct and filled them back in, and 30 minutes went by.&lt;/p&gt;

&lt;h2&gt;
  
  
  pgincident — putting that into one TUI
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/shinagawa-web/pgincident" rel="noopener noreferrer"&gt;pgincident&lt;/a&gt; is a tool that packs that "overview first, then dig" into a single TUI in your terminal. Instead of the many queries you'd hit in psql, you take your guess from an overall-health overview screen, then drop into a category dashboard to dig.&lt;/p&gt;

&lt;p&gt;Setup is nothing dramatic. On macOS, you install it with Homebrew.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew tap shinagawa-web/tap
brew &lt;span class="nb"&gt;install &lt;/span&gt;pgincident
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Linux / macOS, a one-liner works too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/shinagawa-web/pgincident/main/install.sh | sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Getting started is three steps. First, generate a config file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pgincident &lt;span class="nt"&gt;--init&lt;/span&gt;
&lt;span class="c"&gt;# Created /your/project/.pgincident.toml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write your connection details into the generated &lt;code&gt;.pgincident.toml&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[connections.default]&lt;/span&gt;
&lt;span class="py"&gt;dsn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"postgres://user:password@localhost:5432/mydb"&lt;/span&gt;

&lt;span class="nn"&gt;[thresholds]&lt;/span&gt;
&lt;span class="py"&gt;long_running&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"5s"&lt;/span&gt;
&lt;span class="py"&gt;idle_in_transaction&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"30s"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then just launch it.&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;The connecting role doesn't have to be a superuser. As long as it's a member of the &lt;code&gt;pg_monitor&lt;/code&gt; role, it works. This is a quiet detail, but it matters — on managed Postgres like RDS or Cloud SQL, you often can't get a superuser at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you see in the first 30 seconds
&lt;/h2&gt;

&lt;p&gt;When you launch it, the first thing you get is the &lt;strong&gt;Overview screen&lt;/strong&gt;. That "now, on one screen" is right here.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;primary  10.0.1.42:5432  PG 16.1                              interval: 5.0s
──────────────────────────────────────────────────────────────────────────
  DB Health Overview
──────────────────────────────────────────────────────────────────────────

  Metric                Value                 Status
  ──────────────────────────────────────────────────
  Connections           142 / 200 (71%)       OK
  TPS                   2340                  OK
  Cache hit             99.2%                 OK
  Checkpoints           req: 0                OK
  Autovacuum            0 workers             OK

──────────────────────────────────────────────────────────────────────────
[o]dashboard  [q]uit  [+/-]interval  [?]help
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Connections, TPS, Cache hit, Checkpoints, Autovacuum. Each has a threshold set, and the badge reads &lt;code&gt;OK&lt;/code&gt; when healthy, &lt;code&gt;WARN&lt;/code&gt; when shaky, &lt;code&gt;CRIT&lt;/code&gt; when bad. If your setup has a replication standby, a Replication lag row joins them.&lt;/p&gt;

&lt;p&gt;This is exactly what I wanted at 2 a.m. If Connections is red at &lt;code&gt;90%&lt;/code&gt;, this looks like connection exhaustion. If Cache hit has dropped, another lead opens up. Without memorizing queries, without searching, the hunch about "which category of incident" forms right here. And this screen refreshes itself on the interval you set, so there's no re-running it by hand.&lt;/p&gt;

&lt;p&gt;Once you've placed it, press &lt;code&gt;o&lt;/code&gt; to drop into the &lt;strong&gt;Dashboard screen&lt;/strong&gt;. Here, the things you'd hit with several queries in psql line up in three categories.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Long-running queries&lt;/strong&gt; — active queries still running past the threshold (default 5s)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Locks&lt;/strong&gt; — pairs of the blocking and the blocked sessions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idle in transaction&lt;/strong&gt; — sessions left holding a transaction open past the threshold (default 30s)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That "cause or result?" separation you were stuck on moves forward here. The list of long-running queries and the blocking relationships of locks are on the same screen at the same time. If the top long-running query shows up on the blocked side, it's not the culprit — it's the victim. No need to re-run a separate query and reconcile it in your head.&lt;/p&gt;

&lt;p&gt;Press &lt;code&gt;Enter&lt;/code&gt; on a long-running query row and its full text opens in an overlay. Formatted with line breaks, keywords highlighted.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="err"&gt;┌─&lt;/span&gt; &lt;span class="n"&gt;Query&lt;/span&gt; &lt;span class="n"&gt;Detail&lt;/span&gt; &lt;span class="err"&gt;──────────────────────────────────────────────────────────┐&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="n"&gt;PID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12345&lt;/span&gt;   &lt;span class="k"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt;   &lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;02&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;   &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;     &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="err"&gt;───────────────────────────────────────────────────────────────────────&lt;/span&gt; &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;                                                                   &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;   &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                                &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;   &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_amount&lt;/span&gt;                            &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;                                                             &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;                                       &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;                                               &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'7 days'&lt;/span&gt;                         &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;                                              &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;                                                               &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;└─────────────────────────────────────────────────────────────────────────┘&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;any&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;close&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Beyond that: if you've configured multiple DBs, &lt;code&gt;c&lt;/code&gt; switches the connection (the use case being to move between a primary and a replica). You adjust the polling interval with &lt;code&gt;+&lt;/code&gt; / &lt;code&gt;-&lt;/code&gt;, and &lt;code&gt;Tab&lt;/code&gt; moves between sections.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the "first 30 seconds" back
&lt;/h2&gt;

&lt;p&gt;What you actually needed in the first response to an incident wasn't a new query collection, or &lt;code&gt;pg_stat_*&lt;/code&gt; syntax to re-learn. It was being able to take in "what's happening right now" as an overview on one screen, place your guess, and then dig — and to walk that order without searching or re-pasting. Because I had no way to get the overview, I kept digging on instinct — holes I'd just fill back in. What pgincident gives back is that first overview.&lt;/p&gt;

&lt;p&gt;This article covers only the first 30 seconds, from launch — the core experience. Deeper dives into lock chains, and the investigation further down the line, are for another post.&lt;/p&gt;

&lt;p&gt;2 a.m., those few minutes where your hands stopped after &lt;code&gt;production=&amp;gt;&lt;/code&gt;. So that they don't turn into 30 minutes, have the one-screen &lt;em&gt;now&lt;/em&gt; on hand first — that alone changes the first response quite a bit.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How to safely remove a Rails column: finding every real reference before you delete</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 16 Jun 2026 13:03:00 +0000</pubDate>
      <link>https://dev.to/_402ccbd6e5cb02871506/how-to-safely-remove-a-rails-column-finding-every-real-reference-before-you-delete-3b08</link>
      <guid>https://dev.to/_402ccbd6e5cb02871506/how-to-safely-remove-a-rails-column-finding-every-real-reference-before-you-delete-3b08</guid>
      <description>&lt;p&gt;Every Rails project has at least one of these. A model with an old column that's probably not used anymore. "Probably" is the scary part. If something in production is still referencing it, deleting the column breaks the app. &lt;code&gt;NoMethodError&lt;/code&gt;, in production.&lt;/p&gt;

&lt;p&gt;You know what that looks like. It's 11:30 PM the night before sprint planning. You're tidying up the &lt;code&gt;Article&lt;/code&gt; model — the kind of low-stakes cleanup you save for when nothing urgent is on fire. You spot &lt;code&gt;summary&lt;/code&gt; in &lt;code&gt;db/schema.rb&lt;/code&gt;. It doesn't appear in any recent ticket. The last commit touching it was fourteen months ago. You look at the column definition: &lt;code&gt;t.text :summary&lt;/code&gt;. Probably a description field from some old feature. You open the controller. You don't see it used. You check the views quickly. Nothing obvious.&lt;/p&gt;

&lt;p&gt;You think: this is probably safe to delete. You run the migration, deploy, go to sleep.&lt;/p&gt;

&lt;p&gt;At 7:15 AM your on-call pager fires. Five hundred errors per minute. &lt;code&gt;NoMethodError: undefined method 'summary' for an instance of Article&lt;/code&gt;. Users are getting blank pages. Logs are flooding. Slack has twelve messages: "site down?" "was it that deploy last night?" Your stomach drops. You push a rollback. The errors stop. Now you spend the morning in a postmortem figuring out that an admin reporting feature — a rarely-used export endpoint buried in &lt;code&gt;app/reports/&lt;/code&gt; — was still calling &lt;code&gt;article.summary&lt;/code&gt; to build a CSV. Nobody thought to check it. You didn't even know that file existed. The column wasn't unused; it just looked unused.&lt;/p&gt;

&lt;p&gt;That's why nobody deletes anything: you can't be sure, so you don't. It's a reasonable call — except there was never a way to get sure.&lt;/p&gt;

&lt;p&gt;The obvious thing to try: search for the column name in VS Code. In one Rails project, searching for &lt;code&gt;summary&lt;/code&gt; returned &lt;strong&gt;3,847 results&lt;/strong&gt;. I started going through them and quickly noticed: almost none were the real thing. &lt;code&gt;&amp;lt;summary&amp;gt;&lt;/code&gt; tags in ERB templates — the HTML accordion element. Translation keys in locale files. Description strings in RSpec examples. Actual code accessing &lt;code&gt;article.summary&lt;/code&gt;: &lt;strong&gt;9 results&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I gave up somewhere around result 50.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why full-text search isn't enough
&lt;/h2&gt;

&lt;p&gt;VS Code search and grep answer "does this string appear anywhere in this file?" That's useful for a lot of things. But when you want to know "is this column actually referenced in code?", text search picks up way too much. The column name in a string literal, in a comment, as an HTML tag name: it all counts as a hit. Sorting through them is manual work.&lt;/p&gt;

&lt;p&gt;Those 3,847 VS Code results were full-text matches. Narrowing with &lt;code&gt;grep -rn "\bsummary\b" --include="*.rb"&lt;/code&gt; to Ruby files left 847. Here's what those broke down to:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;summary&amp;gt;&lt;/code&gt; tags in ERB templates (HTML element)&lt;/td&gt;
&lt;td&gt;312&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Translation keys in locale files (&lt;code&gt;summary:&lt;/code&gt;, etc.)&lt;/td&gt;
&lt;td&gt;218&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Description strings in RSpec &lt;code&gt;describe&lt;/code&gt; / &lt;code&gt;it&lt;/code&gt; blocks&lt;/td&gt;
&lt;td&gt;157&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Comments, variable names, unrelated strings&lt;/td&gt;
&lt;td&gt;151&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Actual column accesses&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;summary&lt;/code&gt; is particularly tricky because HTML5 has a &lt;code&gt;&amp;lt;details&amp;gt;/&amp;lt;summary&amp;gt;&lt;/code&gt; accordion element. If your project uses that tag in templates, every view file is a potential hit. To text search, &lt;code&gt;&amp;lt;summary&amp;gt;&lt;/code&gt; and &lt;code&gt;article.summary&lt;/code&gt; are the same thing: a string match.&lt;/p&gt;

&lt;p&gt;Even filtering to Ruby files, any &lt;code&gt;'summary'&lt;/code&gt; string in a serializer field list or a comment still hits. "Ruby files only" and "actual column access" are completely different things.&lt;/p&gt;

&lt;p&gt;The more you refine the regex, the more you start wondering whether the regex itself is missing something. You end up needing to verify the verification.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;summary&lt;/code&gt; is not even the worst case. Consider &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, or &lt;code&gt;type&lt;/code&gt; — column names that appear in dozens of unrelated contexts throughout a typical Rails project. Variable names, hash keys, RSpec subject descriptions, i18n keys, FactoryBot attributes. Hundreds of hits. Same problem, worse noise.&lt;/p&gt;

&lt;p&gt;Try it right now if you're curious. Open a Rails project you've been on for a year. Run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="s2"&gt;status&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.rb"&lt;/span&gt; ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Count the results. Most of them have nothing to do with the &lt;code&gt;status&lt;/code&gt; column you're thinking about. They're local variables, hash keys in unrelated parts of the app, &lt;code&gt;describe "updates the status"&lt;/code&gt; in test files. The signal you need — "is &lt;code&gt;article.status&lt;/code&gt; actually accessed somewhere?" — is buried in hundreds of lines of noise.&lt;/p&gt;

&lt;p&gt;There's also the psychological cost. You open VS Code, run the search, see 847 results for &lt;code&gt;status&lt;/code&gt;, and your shoulders drop. You close the tab. You tell yourself you'll check it later. "Later" never comes. Nobody should have to hand-verify 847 results to answer a yes/no question.&lt;/p&gt;

&lt;p&gt;"I searched, couldn't check everything, left it alone." Most Rails developers have been here. You want to delete the column but can't. Checking properly is possible, but it costs more time than the cleanup is worth. So the column stays, and they pile up.&lt;/p&gt;

&lt;p&gt;So the columns accumulate. Here's what that does to a codebase.&lt;/p&gt;

&lt;p&gt;The schema bloats. You open &lt;code&gt;db/schema.rb&lt;/code&gt; and it's 600 lines. You scroll past columns you half-recognize — &lt;code&gt;legacy_body&lt;/code&gt;, &lt;code&gt;old_slug&lt;/code&gt;, &lt;code&gt;deprecated_export_format&lt;/code&gt;, &lt;code&gt;summary&lt;/code&gt; — added by developers who've since moved on, tied to tickets that closed eighteen months ago. Nobody knows what they did, so nobody touches them. Every &lt;code&gt;SELECT *&lt;/code&gt; drags them along. Every &lt;code&gt;Article.new&lt;/code&gt; builds an object with two dozen attributes, most of them nil because they've been unused for a year.&lt;/p&gt;

&lt;p&gt;A new developer joins and runs &lt;code&gt;Article.column_names&lt;/code&gt; to understand the schema, then stares at the output. "What's &lt;code&gt;legacy_body&lt;/code&gt;? What does &lt;code&gt;deprecated_export_format&lt;/code&gt; mean?" They ask in Slack. Nobody knows for certain, and the answer is "don't touch those." Reasonable in isolation. But repeat that across five models and it hardens into an unspoken rule: don't touch anything you didn't write.&lt;/p&gt;

&lt;p&gt;Design options narrow with it. You want to add a &lt;code&gt;content_format&lt;/code&gt; column, but you can't tell whether &lt;code&gt;legacy_body&lt;/code&gt; and &lt;code&gt;body&lt;/code&gt; are two competing implementations of the same concept or two genuinely different things. So the new feature gets bolted onto the side of the model instead of replacing the old thing cleanly. Every migration feels slightly riskier because the schema is full of things nobody understands, and the unease compounds until nobody touches anything at all. Six months later, another developer hits the same dead end.&lt;/p&gt;

&lt;p&gt;This is the same kind of debt as missing tests: it accumulates quietly and you can never point to the moment it started. The real cost is cognitive load. Every unused column is a small tax on everyone who reads the model. Thirty columns, two years of new developers, one noisy on-call incident caused by a column that was supposed to be gone: that's how "we never clean up old columns" becomes a drag that's hard to measure and impossible to ignore. And none of it is a skills problem — the tool to check properly just didn't exist yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  How colref reads code structure instead of text
&lt;/h2&gt;

&lt;p&gt;What you actually wanted to know was: where is &lt;code&gt;article.summary&lt;/code&gt; referenced in Ruby code? &lt;a href="https://github.com/shinagawa-web/colref" rel="noopener noreferrer"&gt;colref&lt;/a&gt; answers that, ignoring ERB &lt;code&gt;&amp;lt;summary&amp;gt;&lt;/code&gt; tags and locale-file &lt;code&gt;summary:&lt;/code&gt; keys.&lt;/p&gt;

&lt;p&gt;How does it tell the difference? Instead of treating code as a sequence of characters, it reads the code structure.&lt;/p&gt;

&lt;p&gt;When you write &lt;code&gt;article.summary&lt;/code&gt;, Ruby sees "call the &lt;code&gt;summary&lt;/code&gt; method on the &lt;code&gt;article&lt;/code&gt; object" — a specific structure. &lt;code&gt;&amp;lt;summary&amp;gt;&lt;/code&gt; is written as an HTML tag name — structurally, it's not a method call. &lt;code&gt;:summary&lt;/code&gt; is written as a symbol. Reading code structure makes those differences detectable. Only places written as &lt;code&gt;object.column_name&lt;/code&gt; get picked up. HTML tags, symbols, and strings that happen to contain &lt;code&gt;summary&lt;/code&gt; are ignored.&lt;/p&gt;

&lt;p&gt;Text search is Ctrl+F. Reading code structure is closer to a human reading through every line — except it handles thousands of lines in a second.&lt;/p&gt;

&lt;p&gt;Here's what that looks like in practice:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Hits (for &lt;code&gt;summary&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;What it sees&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VS Code full-text search&lt;/td&gt;
&lt;td&gt;3,847&lt;/td&gt;
&lt;td&gt;All string matches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;grep &lt;code&gt;\bsummary\b&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;847&lt;/td&gt;
&lt;td&gt;Word-boundary matches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;colref&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Actual column accesses only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;3,847 or 847 becomes 9. Whether you can act on the results depends entirely on how many there are.&lt;/p&gt;

&lt;p&gt;When you get 9 results: open each one. &lt;code&gt;app/controllers/articles_controller.rb:42&lt;/code&gt; means go to that line and check whether &lt;code&gt;article.summary&lt;/code&gt; is actually being accessed there. Nine results takes maybe 15 minutes.&lt;/p&gt;

&lt;p&gt;A few things you'll encounter while reviewing results: the column appearing in a migration file (colref skips migrations, but if it surfaced it, the migration is just recording the column's history, not actively using it). You might also see test factories or fixtures that assign the column's value. If you delete the column and forget to clean up the factory, your test suite will fail. That's not a reason not to delete — it's just something to handle as part of the deletion.&lt;/p&gt;

&lt;p&gt;When you get zero: you have a fact. "Not found in Ruby code" is different from "I think it's probably fine." It's the signal to move on: dynamic access patterns, templates, Strong Parameters, serializers. Treat a zero from colref as the first check, with several more still to run.&lt;/p&gt;

&lt;p&gt;The shift is from "check 847 things" to "check 9 things, then a handful of specific files." That's the difference between a task you'll defer indefinitely and one you'll do today.&lt;/p&gt;

&lt;h3&gt;
  
  
  Installation
&lt;/h3&gt;

&lt;p&gt;Add to your Gemfile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bundle add colref &lt;span class="nt"&gt;--group&lt;/span&gt; development
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or install globally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gem &lt;span class="nb"&gt;install &lt;/span&gt;colref
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Specify the model name, field name, and your project directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; rails &lt;span class="nt"&gt;--model&lt;/span&gt; Article &lt;span class="nt"&gt;--field&lt;/span&gt; summary ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Results come back as &lt;code&gt;filename:line_number&lt;/code&gt;. Each one is something you can open directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What zero results doesn't cover
&lt;/h2&gt;

&lt;p&gt;Zero results doesn't mean "safe to delete." It means "not found in Ruby code," and the gap between those two is where columns come back to bite you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic Access Pattern:&lt;/strong&gt; colref detects literal-symbol forms like &lt;code&gt;article.send(:summary)&lt;/code&gt; and &lt;code&gt;article.read_attribute(:summary)&lt;/code&gt; — these appear in results with a &lt;code&gt;[symbol]&lt;/code&gt; confidence label, meaning they need manual verification. What colref cannot catch is when the method name is stored in a variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# colref cannot catch this — field name is in a variable, not on the same call&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:body&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="kp"&gt;attr&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kp"&gt;attr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The variable form can't be reliably caught with a single grep either — &lt;code&gt;send.*summary&lt;/code&gt; only matches lines where &lt;code&gt;send&lt;/code&gt; and &lt;code&gt;summary&lt;/code&gt; appear together, which misses the loop above entirely. To find this pattern, read every &lt;code&gt;send&lt;/code&gt;, &lt;code&gt;public_send&lt;/code&gt;, and &lt;code&gt;read_attribute&lt;/code&gt; call site directly. As a starting point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"send.*summary&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;read_attribute.*summary"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.rb"&lt;/span&gt; ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This catches the literal form (&lt;code&gt;article.send(:summary)&lt;/code&gt;) that colref already surfaces, and it's a useful double-check. But don't treat zero results as proof there's no variable-form usage — scan the call sites by eye for the loop pattern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symbol Permit Pattern:&lt;/strong&gt; The column name appears as a symbol in &lt;code&gt;permit&lt;/code&gt; inside controllers, which colref does not detect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# controller&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;article_params&lt;/span&gt;
  &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:article&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;permit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;colref won't detect this &lt;code&gt;:summary&lt;/code&gt; symbol. Opening the controller file directly is the reliable check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Serializer Field Pattern:&lt;/strong&gt; Blueprinter, JSONAPI::Serializer, ActiveModelSerializers — all list fields as strings or symbols:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Blueprinter&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ArticleBlueprint&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;Blueprinter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Base&lt;/span&gt;
  &lt;span class="n"&gt;fields&lt;/span&gt; &lt;span class="ss"&gt;:title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:summary&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;# JSONAPI::Serializer&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ArticleSerializer&lt;/span&gt;
  &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;JSONAPI&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Serializer&lt;/span&gt;
  &lt;span class="n"&gt;attributes&lt;/span&gt; &lt;span class="ss"&gt;:title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:summary&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;# ActiveModelSerializers&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ArticleSerializer&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActiveModel&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Serializer&lt;/span&gt;
  &lt;span class="n"&gt;attributes&lt;/span&gt; &lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:summary&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Determining which model the &lt;code&gt;:summary&lt;/code&gt; symbol in a list refers to requires tracing class inheritance, which colref doesn't handle yet. Serializer files tend to be few in number — opening them directly is the reliable check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ERB templates:&lt;/strong&gt; &lt;code&gt;&amp;lt;%= @article.summary %&amp;gt;&lt;/code&gt; lives in &lt;code&gt;.html.erb&lt;/code&gt; files. colref only scans &lt;code&gt;.rb&lt;/code&gt; files. Check templates separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"summary"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.erb"&lt;/span&gt; ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that &lt;code&gt;&amp;lt;summary&amp;gt;&lt;/code&gt; tags will also hit here, so results will be noisy. Narrowing to &lt;code&gt;@article.summary&lt;/code&gt; or &lt;code&gt;article.summary&lt;/code&gt; is more practical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ActiveAdmin / RailsAdmin:&lt;/strong&gt; If you're displaying or editing the column in an admin interface, the reference is likely a string or symbol there too. I've seen &lt;code&gt;column :summary&lt;/code&gt; sitting in an ActiveAdmin &lt;code&gt;show&lt;/code&gt; block for a column that had been "confirmed removed" twice already — nobody checked the admin file because it only gets opened once a month. If your project uses either, check those files as well.&lt;/p&gt;

&lt;p&gt;Checking serializers and admin files by eye sounds tedious, but in practice it takes a few minutes. These files tend to be organized by model. Open &lt;code&gt;app/serializers/article_serializer.rb&lt;/code&gt;, find the relevant serializer, check the &lt;code&gt;attributes&lt;/code&gt; list. Open &lt;code&gt;app/admin/article.rb&lt;/code&gt; if you use ActiveAdmin. This isn't a grep problem. You open two files and look, and you're done — no tooling required.&lt;/p&gt;

&lt;p&gt;colref (Ruby attribute accesses) + grep (variable dynamic patterns and templates) + manual check (Strong Parameters, serializers, admin) covers the vast majority of real-world Rails codebases. There are edge cases colref doesn't handle yet; the &lt;a href="https://shinagawa-web.github.io/colref/docs/detection-patterns/" rel="noopener noreferrer"&gt;Detection Patterns&lt;/a&gt; docs list them. For most projects, this three-part check is enough to move from "I think it's probably unused" to "I have confirmed it's unused."&lt;/p&gt;

&lt;p&gt;Once you've gone through all of that and colref returns zero, that's a grounded deletion rather than a guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  The procedure
&lt;/h2&gt;

&lt;p&gt;Here's the full sequence I run.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Check for column accesses in Ruby code&lt;/span&gt;
colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; rails &lt;span class="nt"&gt;--model&lt;/span&gt; Article &lt;span class="nt"&gt;--field&lt;/span&gt; summary ./

&lt;span class="c"&gt;# 2. Check for dynamic access (catches literal form; scan call sites for variable form)&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"send.*summary&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;read_attribute.*summary"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.rb"&lt;/span&gt; ./

&lt;span class="c"&gt;# 3. Check ERB templates (narrow to .summary access)&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;summary"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.erb"&lt;/span&gt; ./

&lt;span class="c"&gt;# 4. Check Strong Parameters (controllers)&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;":summary&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;'summary'"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.rb"&lt;/span&gt; app/controllers/

&lt;span class="c"&gt;# 5. Check serializers and admin&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;":summary&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;'summary'"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.rb"&lt;/span&gt; app/serializers/ app/admin/

&lt;span class="c"&gt;# 6. Generate the removal migration&lt;/span&gt;
rails generate migration RemoveSummaryFromArticles summary:string

&lt;span class="c"&gt;# 7. Apply to the schema&lt;/span&gt;
rails db:migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Steps 2–5 are still grep — colref doesn't solve everything. But step 1 cuts 847 results down to 9. The "too many results to check, left it alone" situation: this is the one place that changes.&lt;/p&gt;

&lt;p&gt;One more thing about steps 6 and 7: give the migration a descriptive name like &lt;code&gt;RemoveSummaryFromArticles&lt;/code&gt;. Six months from now, someone scanning migration filenames can see what changed and when without opening every file. Run the migration locally and make sure your test suite passes before deploying. When you're confident about a deletion it's tempting to skip verification. Don't. If a factory is still setting the deleted column, tests will catch it before production does.&lt;/p&gt;

&lt;p&gt;The whole process — run colref, run the checklist, generate the migration, run tests locally, deploy — takes maybe 30 minutes for a column that's actually unused. Compare that to leaving it in &lt;code&gt;db/schema.rb&lt;/code&gt; for another year because you couldn't confirm it was safe.&lt;/p&gt;

&lt;p&gt;The difference between "I think it's probably unused" and "zero results in Ruby code, no dynamic &lt;code&gt;send&lt;/code&gt; call sites, nothing in templates" matters when something goes wrong. Knowing what you checked tells you exactly where the cause wasn't — which narrows down where it was. A grounded deletion makes the debugging faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  First run: try a column you know is used
&lt;/h2&gt;

&lt;p&gt;If you don't have a deletion candidate in mind, start with a column you know is in use — something like &lt;code&gt;title&lt;/code&gt; on &lt;code&gt;Article&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; rails &lt;span class="nt"&gt;--model&lt;/span&gt; Article &lt;span class="nt"&gt;--field&lt;/span&gt; title ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;title&lt;/code&gt; is actively used, you'll get multiple results with file and line number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/controllers/articles_controller.rb:42
app/helpers/articles_helper.rb:11
app/serializers/article_serializer.rb:5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seeing what a real result looks like makes it easier to judge zero results later. Then try a column you've been wondering about. Close to zero? Move to steps 2–5.&lt;/p&gt;

&lt;p&gt;From installation to first run: under five minutes. Running it is faster than reading the README.&lt;/p&gt;

&lt;p&gt;What do you do when you get 3 results? Open all three. For each one: is this code still running in production? If a reference is inside a clearly dead code path — wrapped in a feature flag that was turned off, or a method that's never called — it doesn't count as a real reference. If it's live code, the column is still in use. But 3 results is a manageable number. You can make that judgment call.&lt;/p&gt;

&lt;p&gt;What if you get 0 results? Don't stop there. Run steps 2–5. Zero from colref, nothing from the dynamic access grep, clean templates, nothing in the controllers or serializers: that's multiple independent checks pointing the same direction. At that point you have something solid to stand on.&lt;/p&gt;

&lt;p&gt;Even without a deletion candidate right now, colref fits into routine schema review. Scan &lt;code&gt;db/schema.rb&lt;/code&gt;, spot something that looks unused, run colref. Zero results — it goes on the list. "Probably unused" becomes "not referenced in Ruby code" in 30 seconds. Do this periodically on projects you maintain. Every few months scan the migration history for columns you don't recognize, run colref on them, and build a short list. Some end up staying because they're used in ways colref doesn't detect. But a few always turn out to be genuinely gone: references removed over time, nobody noticed, nobody cleaned it up. Those get deleted.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can colref be added to CI?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The use case is catching references to a deleted column that sneak back in through someone's PR. &lt;code&gt;colref check&lt;/code&gt; exits with code 0 when there are zero results, so it fits as a step in GitHub Actions or CircleCI:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Check removed column references&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;colref check --orm rails --model Article --field summary ./&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When something other than zero results comes back, the CI step fails. That prevents deleted column references from ever making it to main.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What should the team know before using this?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The important thing to communicate is what colref doesn't detect — Strong Parameters, serializers, ERB templates, and variable-symbol dynamic access. If "colref returned zero so it's safe" becomes the assumption without those additional checks, things get missed. Documenting a checklist alongside colref — "colref covers direct access in Ruby code; run these greps and open these files for the rest" — means new team members get the full picture from day one.&lt;/p&gt;

&lt;p&gt;colref is still in development. If something doesn't work or you get unexpected results, open an issue at &lt;a href="https://github.com/shinagawa-web/colref" rel="noopener noreferrer"&gt;github.com/shinagawa-web/colref&lt;/a&gt;. Real usage feedback is what shapes the priorities.&lt;/p&gt;

&lt;p&gt;colref currently supports Django and Rails. For the roadmap, see &lt;a href="https://github.com/shinagawa-web/colref/issues/74" rel="noopener noreferrer"&gt;issue #74&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;How many columns are you sitting on that you haven't been able to delete?&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>database</category>
      <category>opensource</category>
    </item>
    <item>
      <title>"Renaming `user` when grep can't tell which model you mean"</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 09 Jun 2026 13:05:00 +0000</pubDate>
      <link>https://dev.to/_402ccbd6e5cb02871506/renaming-user-when-grep-cant-tell-which-model-you-mean-22fc</link>
      <guid>https://dev.to/_402ccbd6e5cb02871506/renaming-user-when-grep-cant-tell-which-model-you-mean-22fc</guid>
      <description>&lt;p&gt;I stopped mid-task trying to add an &lt;code&gt;editor&lt;/code&gt; field to the &lt;code&gt;Article&lt;/code&gt; model.&lt;/p&gt;

&lt;p&gt;The problem was the existing &lt;code&gt;user&lt;/code&gt; field. Adding &lt;code&gt;editor&lt;/code&gt; would leave &lt;code&gt;Article&lt;/code&gt; with two foreign keys to &lt;code&gt;User&lt;/code&gt; sitting side by side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;on_delete&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;editor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;on_delete&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;related_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;edited_articles&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I could already see the code review comment. "What is &lt;code&gt;user&lt;/code&gt; here? The author? The last person who touched it?"&lt;/p&gt;

&lt;p&gt;The right move was to rename &lt;code&gt;user&lt;/code&gt; to &lt;code&gt;author&lt;/code&gt; first, then add &lt;code&gt;editor&lt;/code&gt;. Leave it as-is and that ambiguity is baked into the codebase permanently. Every new developer who opens the model will have the same question. The field name is wrong and everyone who encounters it will eventually notice.&lt;/p&gt;

&lt;p&gt;The problem was how much that rename scared me. &lt;code&gt;user&lt;/code&gt; doesn't live only inside the &lt;code&gt;Article&lt;/code&gt; model definition. It gets accessed as &lt;code&gt;article.user&lt;/code&gt; in views, output as &lt;code&gt;user_id&lt;/code&gt; in serializers, listed in admin's &lt;code&gt;list_display&lt;/code&gt;, returned as a response key the frontend depends on. Every one of those places needs to change to &lt;code&gt;author&lt;/code&gt; or &lt;code&gt;author_id&lt;/code&gt;. The serializer's &lt;code&gt;fields&lt;/code&gt; list, the frontend's expected key names — all of it.&lt;/p&gt;

&lt;p&gt;Miss one and here's what happens: you renamed &lt;code&gt;article.user&lt;/code&gt; to &lt;code&gt;article.author&lt;/code&gt; in &lt;code&gt;views.py&lt;/code&gt; but missed &lt;code&gt;fields = ['user_id']&lt;/code&gt; in the serializer. The API can no longer return &lt;code&gt;author_id&lt;/code&gt;. The frontend's expecting &lt;code&gt;user_id&lt;/code&gt; and that screen breaks. Or you run the migration and there's a reference still sitting somewhere — &lt;code&gt;AttributeError: 'Article' object has no attribute 'user'&lt;/code&gt; starts appearing in production logs. The kind of bug you find after the migration is already live. The logs fill up, Slack lights up, someone asks if we should roll back.&lt;/p&gt;

&lt;p&gt;So the right order is: find every reference first, then rename. That means knowing every file and every line before touching the model definition or running any migration. The obvious tool is grep.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;user"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.py"&lt;/span&gt; ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It returned 340 hits.&lt;/p&gt;

&lt;p&gt;I ran &lt;a href="https://shinagawa-web.github.io/colref/" rel="noopener noreferrer"&gt;colref&lt;/a&gt; on the same codebase. It returned 4 hits for &lt;code&gt;Article --field user&lt;/code&gt; and 3 hits for &lt;code&gt;Article --field user_id&lt;/code&gt;. Seven results total — every actual &lt;code&gt;Article.user&lt;/code&gt; reference in the codebase.&lt;/p&gt;

&lt;p&gt;340 became 7. The gap is what this article is about.&lt;/p&gt;

&lt;p&gt;The same problem comes up with any field name that multiple models share. &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;created_by&lt;/code&gt;, &lt;code&gt;assigned_to&lt;/code&gt; — grep on a common field name returns results from every model that uses it, and there is no way to filter by which model you care about. The rename that looks straightforward from the model definition turns into a search problem the moment you try to verify what will break.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Disambiguation Problem: Ten Models, One Field Name
&lt;/h2&gt;

&lt;p&gt;grep returned 340 results because &lt;code&gt;user&lt;/code&gt; is not specific to &lt;code&gt;Article&lt;/code&gt;. Every model in the project that relates to a user has a &lt;code&gt;user&lt;/code&gt; field. Here's where those 340 hits actually came from:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Hits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Comment.user&lt;/td&gt;
&lt;td&gt;89&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Order.user&lt;/td&gt;
&lt;td&gt;62&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Profile.user&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payment.user&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;other models&lt;/td&gt;
&lt;td&gt;78&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Article.user / Article.user_id&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Seven out of 340. The other 333 are legitimate field accesses — not noise, not comments, not file extensions. Real code that correctly uses &lt;code&gt;.user&lt;/code&gt; on those other models. Text search has no way to distinguish them from what you're looking for.&lt;/p&gt;

&lt;p&gt;This is a different problem from the grep-noise issue that comes up when checking field deletions, where string literals, comments, and file paths inflate the count. &lt;a href="https://dev.to/shinagawa_web/grep-said-1202-the-real-answer-was-10-introducing-colref-3b22"&gt;That problem is covered in an earlier article in this series.&lt;/a&gt; Here the results are all real code. The issue is that &lt;code&gt;user&lt;/code&gt; appears on ten models, and text search cannot tell which model any given &lt;code&gt;obj.user&lt;/code&gt; belongs to.&lt;/p&gt;

&lt;p&gt;You could try narrowing the search. Filter to files that import &lt;code&gt;Article&lt;/code&gt;, or grep for &lt;code&gt;article.user&lt;/code&gt; with a lowercase &lt;code&gt;a&lt;/code&gt; expecting the variable name to match. Every refinement introduces a new failure mode. What if a view stores the article in a variable named &lt;code&gt;obj&lt;/code&gt; or &lt;code&gt;instance&lt;/code&gt;? What if it comes from &lt;code&gt;get_object_or_404&lt;/code&gt; and the variable name is different from what you searched for? A tighter grep pattern gives you more confidence in the hits it returns, but zero information about what it missed. You end up needing to verify your verification.&lt;/p&gt;

&lt;p&gt;Checking all 340 results by hand isn't impossible but it's exactly the kind of task that gets postponed indefinitely. You open the first file, see that it's &lt;code&gt;Comment.user&lt;/code&gt;, close it, open the next one — &lt;code&gt;Order.user&lt;/code&gt; — and somewhere around result 30 you realize this is going to take the rest of the afternoon. You close the tab and tell yourself you'll do it tomorrow. Tomorrow you have other things. The rename doesn't happen and the ambiguity stays.&lt;/p&gt;

&lt;p&gt;The field that should have been renamed six months ago is still called &lt;code&gt;user&lt;/code&gt;. And the next time someone needs to add another user relationship to the model, the situation is even more tangled.&lt;/p&gt;

&lt;p&gt;What you need is a search that understands which model you're asking about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Scope Filter: Telling colref Which Model You Mean
&lt;/h2&gt;

&lt;p&gt;colref reads code as structure rather than text. It parses source files into an AST and targets attribute-access nodes — the nodes that represent &lt;code&gt;obj.field&lt;/code&gt; in running code. String literals, comments, and template paths are invisible to it. Crucially, it accepts a &lt;code&gt;--model&lt;/code&gt; flag.&lt;/p&gt;

&lt;p&gt;Installation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pipx &lt;span class="nb"&gt;install &lt;/span&gt;colref
&lt;span class="c"&gt;# or&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;colref
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a ForeignKey field, two access patterns appear in real code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;      &lt;span class="c1"&gt;# fetches the User object (triggers a JOIN)
&lt;/span&gt;&lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;   &lt;span class="c1"&gt;# fetches the FK integer directly (no JOIN)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Performance-conscious code often uses &lt;code&gt;article.user_id&lt;/code&gt; to avoid the join when only the ID is needed. Both need to be found and updated, so run both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# find obj.user accesses on Article instances&lt;/span&gt;
colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; django &lt;span class="nt"&gt;--model&lt;/span&gt; Article &lt;span class="nt"&gt;--field&lt;/span&gt; user ./

&lt;span class="c"&gt;# find obj.user_id accesses on Article instances&lt;/span&gt;
colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; django &lt;span class="nt"&gt;--model&lt;/span&gt; Article &lt;span class="nt"&gt;--field&lt;/span&gt; user_id ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# --field user
app/views.py:58
app/api.py:23
app/admin.py:11
app/tests/test_views.py:34

# --field user_id
app/serializers.py:18
app/api.py:29
app/tests/test_api.py:41
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven results. Every one is an &lt;code&gt;Article.user&lt;/code&gt; or &lt;code&gt;Article.user_id&lt;/code&gt; access. &lt;code&gt;Comment.user&lt;/code&gt;, &lt;code&gt;Order.user&lt;/code&gt;, every other model's &lt;code&gt;user&lt;/code&gt; field — excluded.&lt;/p&gt;

&lt;p&gt;How does it tell the difference? colref reads the project's &lt;code&gt;models.py&lt;/code&gt; files first and builds a map of which fields belong to which model. It confirms that &lt;code&gt;Article&lt;/code&gt; has a field named &lt;code&gt;user&lt;/code&gt;. Then it walks the AST looking for attribute-access nodes and applies the model context: &lt;code&gt;comment.user&lt;/code&gt; is classified as a &lt;code&gt;Comment&lt;/code&gt; instance access and filtered out. It's not searching for the string &lt;code&gt;user&lt;/code&gt; — it's reading the syntax tree and reasoning about which model each access belongs to.&lt;/p&gt;

&lt;p&gt;Seven results is a number you can act on. Open each file, verify the context, update the reference. At a reasonable pace that's fifteen to twenty minutes of work. For each result, the question is simple: is this code still active in production? If the access is inside a function you know is live, update it. If it's inside commented-out code or a block that clearly never runs, note it and move on. Seven results means you can make that judgment for each one without losing track of where you are.&lt;/p&gt;

&lt;p&gt;Once you've gone through colref's output and updated every location, there are two categories colref doesn't cover: serializer &lt;code&gt;fields&lt;/code&gt; lists and admin class attributes. Neither is detected because determining which model a string like &lt;code&gt;'user_id'&lt;/code&gt; refers to inside a &lt;code&gt;fields = [...]&lt;/code&gt; list requires tracing class inheritance, which colref doesn't handle yet. Check those directly before running the migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"user_id"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.py"&lt;/span&gt; ./app/serializers.py ./app/admin.py ./app/forms.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These files are limited in number. In most projects you have one serializers file, one admin file, maybe one forms file. Opening each and searching for &lt;code&gt;user_id&lt;/code&gt; takes a few minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trap: One Wrong Keystroke Drops Your Data
&lt;/h2&gt;

&lt;p&gt;After updating every reference colref found and checking the serializers manually, the next step is generating the migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python manage.py makemigrations &lt;span class="nt"&gt;--name&lt;/span&gt; rename_article_user_to_author
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Django detects that the old field is gone and a new one appeared, and asks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Did you rename article.user to article.author (a ForeignKey)? [y/N]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default is &lt;code&gt;N&lt;/code&gt;. If you're not paying attention and hit Enter, or answer &lt;code&gt;n&lt;/code&gt; because you're not sure what Django is asking, the resulting migration looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# What you get if you answer n — do not use this
&lt;/span&gt;&lt;span class="n"&gt;operations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="n"&gt;migrations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RemoveField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;article&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;migrations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AddField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;article&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;(...),&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;RemoveField&lt;/code&gt; drops the &lt;code&gt;user_id&lt;/code&gt; column. &lt;code&gt;AddField&lt;/code&gt; creates a new empty &lt;code&gt;author_id&lt;/code&gt; column. All the data that was in &lt;code&gt;user_id&lt;/code&gt; — every article's author relationship — is gone. On a production database with existing rows, this means every &lt;code&gt;Article.author&lt;/code&gt; is now null or missing, depending on whether the field allows null. If it doesn't allow null, the migration fails partway through. Either way, not a situation you want to be in.&lt;/p&gt;

&lt;p&gt;Answer &lt;code&gt;y&lt;/code&gt;. That generates a &lt;code&gt;RenameField&lt;/code&gt; migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# What you get if you answer y — this is what you want
&lt;/span&gt;&lt;span class="n"&gt;operations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="n"&gt;migrations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RenameField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;article&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;old_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;new_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;RenameField&lt;/code&gt; renames the column in place. The data stays exactly where it is. Every row that had a &lt;code&gt;user_id&lt;/code&gt; value now has that same value under &lt;code&gt;author_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you'd rather skip the interactive prompt and write the migration directly, use &lt;code&gt;RenameField&lt;/code&gt; explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.db&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;migrations&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Migration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;migrations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Migration&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;dependencies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0042_previous_migration&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;operations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;migrations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RenameField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;model_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;article&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;old_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;new_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the safest approach: write the migration by hand, review it, apply it. No interactive prompt to accidentally misread.&lt;/p&gt;

&lt;p&gt;One more check before applying: run the migration against your local database and confirm your test suite still passes. If a test factory is still setting &lt;code&gt;user=...&lt;/code&gt; on &lt;code&gt;Article&lt;/code&gt; instead of &lt;code&gt;author=...&lt;/code&gt;, the tests will catch it here rather than in production. Factory and fixture files are another category colref doesn't cover — they often set fields using keyword arguments that look like function calls, not attribute accesses. A passing test suite after the migration is the confirmation that nothing was missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API Surface: Renaming Propagates Past Python
&lt;/h2&gt;

&lt;p&gt;Running &lt;code&gt;python manage.py migrate&lt;/code&gt; isn't the end of the rename. The model field changed from &lt;code&gt;user&lt;/code&gt; to &lt;code&gt;author&lt;/code&gt;, which means the database column changed from &lt;code&gt;user_id&lt;/code&gt; to &lt;code&gt;author_id&lt;/code&gt;. Anything that reads data from the API and expected &lt;code&gt;user_id&lt;/code&gt; in the response now receives &lt;code&gt;author_id&lt;/code&gt; instead. That's a breaking change.&lt;/p&gt;

&lt;p&gt;The places that need to change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;TypeScript type definitions that declare an &lt;code&gt;Article&lt;/code&gt; interface with a &lt;code&gt;userId&lt;/code&gt; field&lt;/li&gt;
&lt;li&gt;API client code that reads &lt;code&gt;response.data.user_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;OpenAPI schemas, and any client SDKs generated from them&lt;/li&gt;
&lt;li&gt;Tests that check response payloads against &lt;code&gt;user_id&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A team that generates TypeScript types from the OpenAPI schema will have &lt;code&gt;userId&lt;/code&gt; in dozens of components, not just one. A mobile app consuming the same API has its own model types. An analytics pipeline reading the JSON might extract &lt;code&gt;user_id&lt;/code&gt; by name. None of these are visible from the Django codebase. The Python rename is only half the work — the other half is knowing what else consumed that response key and how quickly those consumers can be updated.&lt;/p&gt;

&lt;p&gt;If the backend and frontend deploy together — same release, same CI run — update both in one change and deploy together. The rename on the Python side, the type definition update on the TypeScript side, deployed atomically. That's the cleanest path when it's available.&lt;/p&gt;

&lt;p&gt;If backend and frontend deploy independently, there's a window between the backend deploy (which changes the response key from &lt;code&gt;user_id&lt;/code&gt; to &lt;code&gt;author_id&lt;/code&gt;) and the frontend deploy (which updates the code to read &lt;code&gt;author_id&lt;/code&gt;). During that window the frontend is broken.&lt;/p&gt;

&lt;p&gt;Django REST Framework's &lt;code&gt;source&lt;/code&gt; parameter lets you keep the old response key temporarily:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ArticleSerializer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;serializers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ModelSerializer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# temporary bridge: API still returns user_id while the frontend catches up
&lt;/span&gt;    &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;serializers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IntegerField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;author_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;read_only&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Article&lt;/span&gt;
        &lt;span class="n"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this in place, the API continues returning &lt;code&gt;user_id&lt;/code&gt; after the migration while the frontend deploys its update. Once the frontend is deployed and no longer reads &lt;code&gt;user_id&lt;/code&gt;, remove the explicit field declaration and update &lt;code&gt;fields&lt;/code&gt; to include &lt;code&gt;author_id&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;More steps, but no breaking window. Teams doing continuous deployment find this easier to manage than trying to coordinate a simultaneous Python-and-frontend release: Python rename + migration in one deploy, frontend update in the next, serializer bridge removed in a third cleanup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Naming Debt: Why &lt;code&gt;user&lt;/code&gt; Became a Liability
&lt;/h2&gt;

&lt;p&gt;Having made it through the rename, it's worth asking how the situation came up in the first place.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;user&lt;/code&gt; describes an implementation fact — "this is a ForeignKey to User" — without saying anything about what that relationship means. Is it the author? The assignee? The last editor? The name doesn't tell you. That gap becomes a problem the moment you need a second user relationship on the same model, and it becomes a grep problem the moment you need to check where the field is used.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;user&lt;/code&gt; is also the most common ForeignKey name in Django codebases by a wide margin. &lt;code&gt;Comment&lt;/code&gt;, &lt;code&gt;Order&lt;/code&gt;, &lt;code&gt;Payment&lt;/code&gt;, &lt;code&gt;Profile&lt;/code&gt; — every model that touches users tends to call the field &lt;code&gt;user&lt;/code&gt;. That's why grep returned 334 irrelevant results. The name isn't specific to any model or any relationship. It's the field name equivalent of naming a variable &lt;code&gt;data&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What would have been different with &lt;code&gt;author&lt;/code&gt; from the start?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;author"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.py"&lt;/span&gt; ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Far fewer models have an &lt;code&gt;author&lt;/code&gt; ForeignKey. Results would stay in the dozens and remain checkable by hand. You might not have needed colref at all.&lt;/p&gt;

&lt;p&gt;The principle for naming ForeignKey fields: use the name of the relationship, not the name of the target model. Instead of &lt;code&gt;user&lt;/code&gt;, use &lt;code&gt;author&lt;/code&gt;, &lt;code&gt;assignee&lt;/code&gt;, &lt;code&gt;reviewer&lt;/code&gt;, &lt;code&gt;approver&lt;/code&gt; — whatever the relationship actually means in your domain. The field name should describe &lt;em&gt;why&lt;/em&gt; the relationship exists, not just where it points.&lt;/p&gt;

&lt;p&gt;The test is simple: can a developer reading the model definition understand the field's purpose without looking at any other file? &lt;code&gt;author&lt;/code&gt; passes that test. &lt;code&gt;user&lt;/code&gt; doesn't. A field named &lt;code&gt;user&lt;/code&gt; on an &lt;code&gt;Article&lt;/code&gt; model tells you the type of the related object but nothing about why the relationship exists. That gap grows more costly every time a new developer joins the team or a new relationship gets added to the model.&lt;/p&gt;

&lt;p&gt;That's straightforward to apply on a new project, when no one has committed to a name yet. On an inherited codebase, a product that's been running for years, "it should have been &lt;code&gt;author&lt;/code&gt; from the start" is not actionable. &lt;code&gt;user&lt;/code&gt; is already in views, serializers, tests, and migrations. The rename has to happen, and the question is only how to do it without breaking anything in production.&lt;/p&gt;

&lt;p&gt;That's where colref is useful. Seven results instead of 340. A fifteen-minute update instead of an indefinitely postponed task.&lt;/p&gt;

&lt;p&gt;The rename that's been sitting on the backlog is usually not blocked by difficulty. It's blocked by the upfront cost of a check that feels too expensive to run. Reducing that cost from hours to minutes is what makes the work actually happen.&lt;/p&gt;

&lt;p&gt;colref supports Django and Rails. For the full list of what it detects and the current roadmap, see &lt;a href="https://github.com/shinagawa-web/colref" rel="noopener noreferrer"&gt;github.com/shinagawa-web/colref&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Appendix: The rename procedure
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Find every reference.&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;colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; django &lt;span class="nt"&gt;--model&lt;/span&gt; Article &lt;span class="nt"&gt;--field&lt;/span&gt; user ./
colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; django &lt;span class="nt"&gt;--model&lt;/span&gt; Article &lt;span class="nt"&gt;--field&lt;/span&gt; user_id ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Update each location colref returns. Then check serializers, admin, and forms for string references colref does not detect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"user_id"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.py"&lt;/span&gt; ./app/serializers.py ./app/admin.py ./app/forms.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the full list of what colref detects and does not, see the &lt;a href="https://shinagawa-web.github.io/colref/docs/detection-patterns/" rel="noopener noreferrer"&gt;Detection Patterns&lt;/a&gt; docs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Rename the field in the model.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;on_delete&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;related_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;articles&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3: Generate the migration. Answer &lt;code&gt;y&lt;/code&gt;.&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;python manage.py makemigrations &lt;span class="nt"&gt;--name&lt;/span&gt; rename_article_user_to_author
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When Django asks "Did you rename article.user to article.author?", answer &lt;code&gt;y&lt;/code&gt;. &lt;code&gt;n&lt;/code&gt; generates &lt;code&gt;RemoveField + AddField&lt;/code&gt; and drops your data. &lt;code&gt;y&lt;/code&gt; generates &lt;code&gt;RenameField&lt;/code&gt; and keeps it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Run your tests locally, then apply.&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;python manage.py migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 5: Handle the API surface.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Update TypeScript types, API clients, and OpenAPI schemas. If the frontend cannot deploy at the same time, add &lt;code&gt;user_id = serializers.IntegerField(source='author_id')&lt;/code&gt; to the serializer as a temporary bridge, then remove it once the frontend has deployed.&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>database</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How to safely remove a Django model field: finding every real reference before you delete</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 02 Jun 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/_402ccbd6e5cb02871506/how-to-safely-remove-a-django-model-field-finding-every-real-reference-before-you-delete-1b9o</link>
      <guid>https://dev.to/_402ccbd6e5cb02871506/how-to-safely-remove-a-django-model-field-finding-every-real-reference-before-you-delete-1b9o</guid>
      <description>&lt;p&gt;Every Django project has at least one of these. A model with an old field that's probably not used anymore. "Probably" is the scary part. If something in production is still referencing it, deleting the column breaks the app. &lt;code&gt;AttributeError&lt;/code&gt;, in production.&lt;/p&gt;

&lt;p&gt;So you don't delete it. You want to, but you can't figure out how to check safely, so it just sits there. The column takes up space in every query. The field clutters the model definition. And it keeps sitting there, month after month, because the cost of confirming it's unused feels higher than the cost of leaving it.&lt;/p&gt;

&lt;p&gt;Think about what happens when &lt;code&gt;AttributeError: 'Article' object has no attribute 'summary'&lt;/code&gt; hits production. Users get 500 errors every time they open the page. Logs flood. Slack lights up. "Was it that deploy we just pushed?" Already considering a rollback. And the cause was deleting a field you thought was unused.&lt;/p&gt;

&lt;p&gt;That's why nobody deletes anything. You can't be sure, so you don't. That's the right call. The problem is there was no way to get sure.&lt;/p&gt;

&lt;p&gt;I tried the obvious thing: searching for the field name in VS Code. Hundreds of hits. I started opening them one by one and immediately noticed most aren't real references. File paths, comments, unrelated variable names. I searched for the &lt;code&gt;.html&lt;/code&gt; field in one Django project and got &lt;strong&gt;1,202 results&lt;/strong&gt;. The actual code accessing that field: &lt;strong&gt;10 results&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;1,192 were noise. But you couldn't know that upfront.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why search returns 1,202 hits
&lt;/h2&gt;

&lt;p&gt;When you give up because there are too many results, that's not a failure on your part. VS Code search and grep just weren't built for this.&lt;/p&gt;

&lt;p&gt;These tools answer "does this string appear anywhere in this file?" That's useful for a lot of things. But when you want to know "is this field actually referenced in code?", text search picks up way too much. The field name in a string literal, in a comment, as part of a filename: it all counts as a hit. Sorting through them is manual work.&lt;/p&gt;

&lt;p&gt;Here's what those 1,202 results for &lt;code&gt;.html&lt;/code&gt; broke down to:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;File extensions (&lt;code&gt;layout.html&lt;/code&gt;, etc.)&lt;/td&gt;
&lt;td&gt;1,087&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unrelated strings&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Comments&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other noise&lt;/td&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Actual field accesses&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can't check 1,200 results. Giving up was the right call. The issue wasn't how you used the tool. You were using the wrong tool.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;.html&lt;/code&gt; isn't a special case. Say you want to delete a &lt;code&gt;title&lt;/code&gt; field on an &lt;code&gt;Article&lt;/code&gt; model. "title" shows up everywhere in a codebase: variable names, dictionary keys, comments, strings. Hundreds of results. Same problem.&lt;/p&gt;

&lt;p&gt;Common field names are the worst. &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt; — these appear in dozens of unrelated contexts throughout a typical Django project. The search that was supposed to answer a simple question becomes 40 minutes of opening files, closing files, and losing track of what you've already checked.&lt;/p&gt;

&lt;p&gt;"I searched, couldn't check everything, left it alone." Most Django developers have been here. You want to delete it but can't. The check isn't impossible, it's just too expensive. So the field stays. They accumulate.&lt;/p&gt;

&lt;p&gt;This compounds over time. A project that's two years old might have thirty fields that nobody's confident about. The developers who added them have moved on. The tickets that motivated them are closed. The tests, if they exist, pass regardless of whether the field is used. There's no mechanism forcing a cleanup, just the gradual intuition that the schema is getting harder to understand.&lt;/p&gt;

&lt;p&gt;If you know regex, you might try narrowing it: &lt;code&gt;grep -rn "\bhtml\b" --include="*.py"&lt;/code&gt; to limit to Python files. Still the same problem. &lt;code&gt;"html"&lt;/code&gt; as a dictionary key, in a comment — it all still hits. "Python files only" and "actual field access" are completely different things.&lt;/p&gt;

&lt;p&gt;The more you refine the regex, the more you start wondering whether the regex itself is missing something. You end up needing to verify the verification. The tool that was supposed to save you time has become another source of doubt.&lt;/p&gt;

&lt;p&gt;There's also the psychological cost. You open VS Code, run the search, see 847 results for &lt;code&gt;status&lt;/code&gt;, and your shoulders drop. You close the tab. You tell yourself you'll check it later. "Later" never comes. Nobody should have to hand-verify 847 results to answer a yes/no question.&lt;/p&gt;

&lt;p&gt;When undeletable fields pile up, here's what actually happens.&lt;/p&gt;

&lt;p&gt;The schema bloats. Ten, twenty unused columns accumulate. A new developer opens the model, scans the fields. "What's this one for?" They try to find out, can't, and decide not to touch it. Reasonable. But when that pattern repeats, you end up with an unspoken rule: don't touch this model.&lt;/p&gt;

&lt;p&gt;Every migration feels slightly more risky. The unease builds until nobody touches it at all. Six months later, another developer thinks the same thing. The cycle repeats.&lt;/p&gt;

&lt;p&gt;This is technical debt in the same way missing tests are. An unresolvable cost that accumulates. Development slows. Onboarding takes longer. Nobody intended this, but the project gets heavier over time.&lt;/p&gt;

&lt;p&gt;This isn't a skills problem. The tool didn't exist yet.&lt;/p&gt;

&lt;p&gt;Here's the situation I kept ending up in: I'd look at a field, feel like it was probably unused, open VS Code to check, get overwhelmed by results, close it, and go do something else. The field would still be there six months later. The next developer would go through the same loop. The field would still be there a year later.&lt;/p&gt;

&lt;p&gt;At some point the schema becomes archaeology. Fields with names like &lt;code&gt;legacy_content&lt;/code&gt;, &lt;code&gt;old_slug&lt;/code&gt;, &lt;code&gt;deprecated_flag&lt;/code&gt;: nobody knows what they do, nobody wants to touch them, and the project carries them forever. Every &lt;code&gt;SELECT *&lt;/code&gt; is slightly slower. Every new developer's mental model of the data is slightly more confused.&lt;/p&gt;

&lt;p&gt;The real cost is cognitive load. Every unused field is a small tax on everyone who reads the model. Multiply that by thirty fields and two years of new developers and you start to see why "we never clean up old fields" becomes an invisible drag on velocity.&lt;/p&gt;

&lt;h2&gt;
  
  
  How colref reads code structure instead of text
&lt;/h2&gt;

&lt;p&gt;What you actually wanted to know was: where is &lt;code&gt;obj.html&lt;/code&gt; referenced in code? &lt;a href="https://github.com/shinagawa-web/colref" rel="noopener noreferrer"&gt;colref&lt;/a&gt; returns exactly that. File paths and string contents ignored.&lt;/p&gt;

&lt;p&gt;How does it tell the difference? Instead of treating code as a sequence of characters, it reads the code structure.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;embed.html&lt;/code&gt; in Python means "read the &lt;code&gt;html&lt;/code&gt; attribute of the &lt;code&gt;embed&lt;/code&gt; object": a specific structure. &lt;code&gt;"pages/publish.html"&lt;/code&gt; is string data, not an attribute access. Reading code structure makes that difference detectable. Only places written as &lt;code&gt;object.field_name&lt;/code&gt; get picked up. The &lt;code&gt;.html&lt;/code&gt; that appears inside a string is ignored.&lt;/p&gt;

&lt;p&gt;If text search is like pressing Ctrl+F on a page, reading code structure is closer to a human reading through every line. Except it handles thousands of lines in an instant.&lt;/p&gt;

&lt;p&gt;It scans Python code and returns only &lt;code&gt;.field_name&lt;/code&gt; accesses, with file and line number.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Hits (for &lt;code&gt;.html&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;What it sees&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VS Code full-text search&lt;/td&gt;
&lt;td&gt;3,534&lt;/td&gt;
&lt;td&gt;All string matches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;grep &lt;code&gt;\.html\b&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1,202&lt;/td&gt;
&lt;td&gt;Word-boundary matches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;colref&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Actual field accesses only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;3,534 or 1,202 becomes 10. Whether you can act on the results depends entirely on how many there are.&lt;/p&gt;

&lt;p&gt;When you get 10 results: open each one. &lt;code&gt;views.py:42&lt;/code&gt; means go to that line and check whether &lt;code&gt;obj.html&lt;/code&gt; is actually being accessed. Real reference — can't delete. Not a real reference — skip. Ten results takes 10–15 minutes.&lt;/p&gt;

&lt;p&gt;A few common things you'll see when reviewing results: the field name appearing in a migration file (colref skips migrations, but if it didn't, this would be a false positive; the migration is just recording the history of the field's existence, not actively using it). You might also see test factories or fixtures that set the field value. Worth noting: if you delete the field and forget to update the factory, your test suite will break. That's not a reason not to delete, it's just something to clean up as part of the deletion.&lt;/p&gt;

&lt;p&gt;When you get zero: you have a fact. "No references found in Python code." That's different from "I think it's probably unused." Move to the next step: checking getattr, templates, Admin, Forms, and Serializers. Zero from colref is the starting point, not the finish line.&lt;/p&gt;

&lt;p&gt;The shift is from "check 1,200 things" to "check 10 things, then a handful of specific files." That's the difference between a task you'll defer indefinitely and one you'll do today.&lt;/p&gt;

&lt;p&gt;For the technical details of how code structure is read, see &lt;a href="https://github.com/shinagawa-web/colref/blob/main/ARCHITECTURE.md" rel="noopener noreferrer"&gt;ARCHITECTURE.md&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;Install via pipx:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pipx &lt;span class="nb"&gt;install &lt;/span&gt;colref
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or with pip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;colref
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Specify the model name, field name, and your project directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; django &lt;span class="nt"&gt;--model&lt;/span&gt; Embed &lt;span class="nt"&gt;--field&lt;/span&gt; html ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Results come back as &lt;code&gt;filename:line_number&lt;/code&gt;. Each one is something you can open directly. Ten results takes maybe ten minutes to verify. Nothing compared to scrolling through 1,202 results, losing your place, and giving up halfway through.&lt;/p&gt;

&lt;p&gt;A note on model names: use the class name exactly as it appears in your models file, including capitalization. &lt;code&gt;Embed&lt;/code&gt;, not &lt;code&gt;embed&lt;/code&gt; or &lt;code&gt;EMBED&lt;/code&gt;. Field names are case-sensitive too: &lt;code&gt;html&lt;/code&gt;, not &lt;code&gt;HTML&lt;/code&gt;. If you get zero results for a field you know is used, double-check the casing first.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;./&lt;/code&gt; at the end is the path to scan. You can point it at a specific app directory if you want to narrow it down, but pointing at the project root works fine and makes sure nothing gets missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What zero results doesn't cover
&lt;/h2&gt;

&lt;p&gt;Zero results doesn't mean "safe to delete." It means "not found in Python code."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic access:&lt;/strong&gt; &lt;code&gt;getattr(obj, field_name)&lt;/code&gt; with the field name in a variable won't be detected. Check separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"getattr"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.py"&lt;/span&gt; ./ | &lt;span class="nb"&gt;grep &lt;/span&gt;your_field
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Django templates:&lt;/strong&gt; &lt;code&gt;{{ page.html }}&lt;/code&gt; lives in &lt;code&gt;.html&lt;/code&gt; files. colref only scans &lt;code&gt;.py&lt;/code&gt;. Check templates separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"your_field"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.html"&lt;/span&gt; ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Django Admin, Forms, and DRF Serializers:&lt;/strong&gt; This is the easiest one to miss. None of these are detected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Django Admin
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ArticleAdmin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ModelAdmin&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;list_display&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;list_filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# Django Forms
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ArticleForm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;forms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ModelForm&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# DRF Serializer
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ArticleSerializer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;serializers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ModelSerializer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Determining which model the string &lt;code&gt;'title'&lt;/code&gt; in a list refers to requires tracing class inheritance, which colref doesn't handle yet. Check these separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"your_field"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.py"&lt;/span&gt; ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This grep has the same noise problem as full-text search. Opening the Admin, Forms, and Serializer files directly is more reliable. In most projects there aren't many of them.&lt;/p&gt;

&lt;p&gt;These are exactly the places a Django beginner might not think to check. You verify the views, the serializers feel obvious after you remember them, but Django Admin is easy to forget, especially if the admin configuration lives in a file you rarely open. I've seen &lt;code&gt;list_display&lt;/code&gt; hold a reference to a field that had been "confirmed deleted" twice already. The admin file just wasn't in anyone's mental checklist.&lt;/p&gt;

&lt;p&gt;Once you've checked all of the above and colref returns zero, that's a grounded deletion: confirmed in Python code, checked getattr, templates, Admin/Forms/Serializers. Not "I think it's probably fine."&lt;/p&gt;

&lt;p&gt;Checking Admin/Forms/Serializers by eye sounds tedious, but in practice it takes a few minutes. These files tend to be organized by model. Open &lt;code&gt;admin.py&lt;/code&gt;, search for the model name, check &lt;code&gt;list_display&lt;/code&gt; and related attributes. Open &lt;code&gt;serializers.py&lt;/code&gt;, find the relevant serializer, check &lt;code&gt;fields&lt;/code&gt;. Open &lt;code&gt;forms.py&lt;/code&gt; if you have one. It's not a grep problem, it's an "open three files and look" problem. That's manageable even without a tool.&lt;/p&gt;

&lt;p&gt;colref (Python attribute accesses) + grep (dynamic patterns and templates) + manual check (Admin/Forms/Serializers) covers the vast majority of real-world Django codebases. There are edge cases colref doesn't handle yet; the &lt;a href="https://shinagawa-web.github.io/colref/docs/detection-patterns/" rel="noopener noreferrer"&gt;Detection Patterns&lt;/a&gt; docs list them. For most projects, this three-part check is enough to move from "I think it's probably unused" to "I have confirmed it's unused."&lt;/p&gt;

&lt;h2&gt;
  
  
  The five-step procedure
&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;# 1. Check for field accesses in Python code&lt;/span&gt;
colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; django &lt;span class="nt"&gt;--model&lt;/span&gt; YourModel &lt;span class="nt"&gt;--field&lt;/span&gt; your_field ./

&lt;span class="c"&gt;# 2. Check for dynamic access&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"getattr"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.py"&lt;/span&gt; ./ | &lt;span class="nb"&gt;grep &lt;/span&gt;your_field

&lt;span class="c"&gt;# 3. Check templates&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"your_field"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.html"&lt;/span&gt; ./

&lt;span class="c"&gt;# 4. Delete the field and generate the migration&lt;/span&gt;
python manage.py makemigrations &lt;span class="nt"&gt;--name&lt;/span&gt; remove_your_field

&lt;span class="c"&gt;# 5. Apply to the schema&lt;/span&gt;
python manage.py migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Steps 2 and 3 are still grep — colref doesn't solve everything. But step 1 cuts 1,202 results down to 10. The "too many results to check, left it alone" situation: this is the one place that changes.&lt;/p&gt;

&lt;p&gt;The difference between "probably unused, I think" and "zero results in Python code, no getattr, nothing in templates" is real. If something breaks in production, knowing what you checked tells you exactly where to look. You know the cause came from outside your checked scope: a dynamic reference, a template, a pattern colref doesn't handle yet. The cause is narrowed. Grounded deletion makes debugging faster when things go wrong.&lt;/p&gt;

&lt;p&gt;One more thing about step 4 and 5: don't skip &lt;code&gt;makemigrations --name&lt;/code&gt;. Giving the migration a descriptive name like &lt;code&gt;remove_summary_field&lt;/code&gt; makes the history readable. Six months from now, someone scanning migration filenames can see what changed and when without opening every file.&lt;/p&gt;

&lt;p&gt;Also: run the migration locally and make sure your test suite passes before deploying. When you're confident about a deletion it's tempting to skip the verification. Don't. If a test factory is still setting the deleted field, the tests will catch it before production does.&lt;/p&gt;

&lt;p&gt;The whole process — run colref, check the checklist, generate the migration, run tests locally, deploy — takes maybe 30 minutes for a field that's actually unused. Compare that to leaving the field there indefinitely because you couldn't confirm it was safe to remove.&lt;/p&gt;

&lt;h2&gt;
  
  
  First run: try a field you know is used
&lt;/h2&gt;

&lt;p&gt;If you don't have a candidate field in mind, try a field you know is used, something like &lt;code&gt;title&lt;/code&gt; on &lt;code&gt;Article&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; django &lt;span class="nt"&gt;--model&lt;/span&gt; Article &lt;span class="nt"&gt;--field&lt;/span&gt; title ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;title&lt;/code&gt; is in use, you'll get multiple results with file and line number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/views.py:42
app/serializers.py:18
app/templates/article_detail.py:11
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seeing what a real result looks like makes it easier to judge zero results later. Then try a field you've been wondering about. Close to zero? Move to steps 2 and 3.&lt;/p&gt;

&lt;p&gt;From installation to first run: under five minutes. No need to read the README first. Running it is faster than reading about it.&lt;/p&gt;

&lt;p&gt;What do you do when you get 3 results? Open all three. For each one: is this code still running in production? If a reference is inside a function that's clearly dead code, something wrapped in &lt;code&gt;if False&lt;/code&gt; or commented out, it doesn't count. If it's live code, the field is still in use. But 3 results is a manageable number. You can make that judgment call.&lt;/p&gt;

&lt;p&gt;What if you get 0 results? Don't stop there. Run steps 2 and 3. Zero from colref, zero from getattr grep, zero from template grep: that's three independent checks. At that point, also check your Admin, Forms, and Serializer files by eye. If all of that is clear, you have something solid to stand on.&lt;/p&gt;

&lt;p&gt;Even without a deletion candidate right now, colref is useful for routine schema review. Scan migration history, spot something that looks unused, run colref. Zero results? It goes on the deletion candidate list. "Probably unused" becomes "not referenced in Python code" in 30 seconds.&lt;/p&gt;

&lt;p&gt;I do this periodically on projects I maintain. Every few months I scan the migration history for fields I don't recognize, run colref on them, and build a short list. It takes maybe 20 minutes and usually turns up one or two candidates worth investigating further. Some end up staying because they're used in ways colref doesn't detect yet. But a few always turn out to be genuinely gone: references removed over time, nobody noticed, nobody cleaned it up. Those get deleted.&lt;/p&gt;

&lt;p&gt;colref is still in development. If something doesn't work or you get unexpected results, open an issue at &lt;a href="https://github.com/shinagawa-web/colref" rel="noopener noreferrer"&gt;github.com/shinagawa-web/colref&lt;/a&gt;. Real usage feedback is what shapes the priorities. A bug report with a concrete example is more useful than ten feature requests.&lt;/p&gt;

&lt;p&gt;colref currently supports Django and Rails. For the roadmap, see &lt;a href="https://github.com/shinagawa-web/colref/issues/74" rel="noopener noreferrer"&gt;issue #74&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you find a field that colref misses, something it should have flagged but didn't, that's especially useful to report. The detection gap around Admin, Forms, and Serializers is a known limitation, but there may be patterns in your codebase that nobody's encountered yet. The tool gets better with more real-world cases.&lt;/p&gt;

&lt;p&gt;How many fields are you sitting on that you haven't been able to delete?&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>database</category>
      <category>opensource</category>
    </item>
    <item>
      <title>"How I Cut My Go Markdown Linter's Benchmark by 81%"</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 26 May 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/_402ccbd6e5cb02871506/how-i-cut-my-go-markdown-linters-benchmark-by-81-4ain</link>
      <guid>https://dev.to/_402ccbd6e5cb02871506/how-i-cut-my-go-markdown-linters-benchmark-by-81-4ain</guid>
      <description>&lt;p&gt;When I started optimizing &lt;a href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;gomarklint&lt;/a&gt;, I had no benchmarks. I had unit tests. I had coverage. But I had no idea what the linter actually cost to run on a real document.&lt;/p&gt;

&lt;p&gt;Here's what I found, what I changed, and what I'd do differently next time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is gomarklint?
&lt;/h2&gt;

&lt;p&gt;gomarklint is a Go-based CLI Markdown linter I've been building as an open-source project. The pitch: catch broken links before your readers do, keep your Markdown clean, single binary, no Node.js required.&lt;/p&gt;

&lt;p&gt;The main alternative most teams reach for is &lt;a href="https://github.com/DavidAnson/markdownlint" rel="noopener noreferrer"&gt;markdownlint&lt;/a&gt;, which works well but requires a Node.js runtime. For Go projects running in a lean CI environment, pulling in Node.js just to lint Markdown felt like the wrong tradeoff. gomarklint ships as a standalone binary installable via Homebrew, npm, or &lt;code&gt;go install&lt;/code&gt;, and integrates with GitHub Actions and pre-commit out of the box.&lt;/p&gt;

&lt;p&gt;The rule set covers around 25 checks: structural rules like &lt;code&gt;heading-level&lt;/code&gt; (no H4 appearing under H2 without an H3 in between), content rules like &lt;code&gt;no-bare-urls&lt;/code&gt; and &lt;code&gt;fenced-code-language&lt;/code&gt;, and link validation including internal anchor checking. Each rule emits diagnostics with file path, line number, and severity — &lt;code&gt;error&lt;/code&gt; causes a non-zero exit, making it safe as a CI gate.&lt;/p&gt;

&lt;p&gt;Internally, each rule is a function that receives the full file content as a slice of lines and returns a slice of violations. No shared parse tree, no AST, just functions over strings. That made it easy to add rules quickly.&lt;/p&gt;

&lt;p&gt;The current release processes &lt;strong&gt;100,000+ lines in under 170ms&lt;/strong&gt;. A typical 200-line README lints in under 0.2 ms across all rules, and a large documentation site with hundreds of files fits in a CI step nobody notices. Getting there required 20 PRs over three weeks, each measured before it merged.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Starting Point: No Visibility
&lt;/h2&gt;

&lt;p&gt;gomarklint's rules like &lt;code&gt;heading-level&lt;/code&gt;, &lt;code&gt;no-bare-urls&lt;/code&gt;, &lt;code&gt;fenced-code-language&lt;/code&gt;, and around 25 others each receive the full file content split into lines and return a slice of violations. Rules run independently, no shared parse tree, no AST, just functions over strings.&lt;/p&gt;

&lt;p&gt;That architecture is easy to extend. But every rule doing anything non-trivial had to solve the same problem on its own: "is this line inside a code block?" Headings inside fenced blocks aren't real headings. URLs inside fenced blocks aren't real URLs. Every rule that cared had to figure it out independently.&lt;/p&gt;

&lt;p&gt;The solution that grew organically was a shared utility called &lt;code&gt;GetCodeBlockLineRanges&lt;/code&gt;. It scanned the entire document, built a list of &lt;code&gt;[start, end]&lt;/code&gt; line ranges for every fenced block, and returned them. Any rule could then call &lt;code&gt;isInCodeBlock(lineNumber, ranges)&lt;/code&gt; to check membership via a linear search.&lt;/p&gt;

&lt;p&gt;I didn't notice this was a problem because I never measured it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Build a Benchmark You Can Trust
&lt;/h2&gt;

&lt;p&gt;Before optimizing anything, I needed a benchmark I could actually rely on. The existing per-rule &lt;code&gt;_bench_test.go&lt;/code&gt; files were isolated and not included in CI comparisons, so they gave no signal about end-to-end cost.&lt;/p&gt;

&lt;p&gt;I rewrote the benchmark around a single &lt;code&gt;generateComplexMarkdown(n int)&lt;/code&gt; function that produces a realistic &lt;code&gt;n&lt;/code&gt;-section document (headings, paragraphs, lists, fenced code blocks, images, links) exercising every rule's scan path without producing any violations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;writeIntro&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"# Main Title&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"This is the introduction to the document.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;writeHeading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"## Section %d&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;writeParagraph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"This section contains *important* information. "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Here are some **key** details that you should know about.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;writeList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Key points:&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"- First important point&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"- Second critical detail&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"- Third consideration&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;writeCodeBlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"```

go&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"func example() error {&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"    return nil&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"

```&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;writeLinks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Useful resources:&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"- [Documentation](https://example.com/docs/%d)&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"- [GitHub](https://github.com/project/%d)&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;writeImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"![Diagram %d](diagram%d.png)&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;writeSubsection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"### Subsection %d.1&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"More detailed information goes here.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;generateComplexMarkdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sections&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;sb&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;
    &lt;span class="n"&gt;writeIntro&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;writeHeading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;writeParagraph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;writeList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;writeCodeBlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;writeLinks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;writeImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;writeSubsection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last constraint — zero violations — matters. A benchmark that triggers violations measures error-reporting cost, not scanning cost. I added a guard test to enforce it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;TestBenchmarkContentIsViolationFree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;generateComplexMarkdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;lint&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LintContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;benchmarkConfig&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;require&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NoError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;assert&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This test runs in CI on every PR in the series. If anyone accidentally adds a violation to the benchmark content, the build breaks before the numbers become meaningless.&lt;/p&gt;

&lt;p&gt;The benchmark also pays forward. Now that it runs on every PR, any new rule added to gomarklint gets automatically checked for performance regression before it merges. If a new &lt;code&gt;no-trailing-spaces&lt;/code&gt; rule adds 15% to the geomean, &lt;code&gt;benchstat&lt;/code&gt; surfaces that number in the PR diff — before it ships, not after. Without a benchmark in CI, performance regressions from new features are invisible until someone notices the linter "feels slower" on a large repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Profile Before Touching Anything
&lt;/h2&gt;

&lt;p&gt;With the benchmark in place, I ran a CPU and allocation profile against the 1000-section document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-bench&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;BenchmarkFullLinting &lt;span class="nt"&gt;-benchtime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5s &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-cpuprofile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cpu.prof &lt;span class="nt"&gt;-memprofile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mem.prof ./cmd/
go tool pprof &lt;span class="nt"&gt;-top&lt;/span&gt; cpu.prof
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The top result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Showing top 5 nodes out of 42
      flat  flat%   sum%        cum   cum%
   6.32s  63.72% 63.72%      6.32s 63.72%  isInCodeBlock
   0.89s   8.97% 72.69%      0.89s  8.97%  strings.TrimSpace
   0.54s   5.44% 78.14%      0.54s  5.44%  regexp.(*Regexp).FindStringSubmatch
   ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;isInCodeBlock&lt;/code&gt; was a shared helper called from multiple rules. Each call invoked &lt;code&gt;GetCodeBlockLineRanges&lt;/code&gt;, which allocated a &lt;code&gt;[][2]int&lt;/code&gt; slice by scanning the entire document to find fence boundaries, then performed a linear search through that slice to answer one question: "is line N inside a code block?"&lt;/p&gt;

&lt;p&gt;That's &lt;strong&gt;O(n × k)&lt;/strong&gt; per rule per line, where &lt;code&gt;n&lt;/code&gt; is the number of lines and &lt;code&gt;k&lt;/code&gt; is the number of code blocks. In a document with 6 rules calling it and 50 code blocks, every line triggered 6 linear searches over 50 ranges. The cost multiplied silently across every rule that wanted to skip code block content.&lt;/p&gt;

&lt;p&gt;The allocation profile added a second finding: &lt;code&gt;CheckHeadingLevels&lt;/code&gt; was calling &lt;code&gt;regexp.MustCompile(atxHeadingPattern)&lt;/code&gt; inside the function body on every invocation — not once at package init. That single oversight was allocating ~16 MB of heap per benchmark run and showed up clearly in &lt;code&gt;-memprofile&lt;/code&gt; output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Fix the Biggest Problem First
&lt;/h2&gt;

&lt;p&gt;Both issues were in the &lt;code&gt;heading-level&lt;/code&gt; rule. I fixed them together in &lt;a href="https://github.com/shinagawa-web/gomarklint/pull/182" rel="noopener noreferrer"&gt;PR #182&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;CheckHeadingLevels&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minLevel&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;LintError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;LintError&lt;/span&gt;
    &lt;span class="n"&gt;prevLevel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;headingRegex&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;regexp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MustCompile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`^(#{1,6})\s+`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// compiled on every call&lt;/span&gt;
    &lt;span class="n"&gt;codeBlockRanges&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;GetCodeBlockLineRanges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// O(n) alloc&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;isInCodeBlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;codeBlockRanges&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;// O(k) linear search per line&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;matches&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;headingRegex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FindStringSubmatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;matches&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;currentLevel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="c"&gt;// ... violation checks&lt;/span&gt;
            &lt;span class="n"&gt;prevLevel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;currentLevel&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;errs&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// atxHeadingLevel returns the heading level (1–6) or 0.&lt;/span&gt;
&lt;span class="c"&gt;// Pure byte scan — no regex, no allocation.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;atxHeadingLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'#'&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;6&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;' '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'\t'&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;CheckHeadingLevels&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minLevel&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;LintError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;LintError&lt;/span&gt;
    &lt;span class="n"&gt;prevLevel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;inCodeBlock&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;fenceMarker&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;firstNonSpaceByte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c"&gt;// Inline fence tracking — no allocation, no O(n×k) lookup.&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;inCodeBlock&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;fenceMarker&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;trimmed&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;IsClosingFence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fenceMarker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;inCodeBlock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
                    &lt;span class="n"&gt;fenceMarker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'#'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'`'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'~'&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;trimmed&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;openingFenceMarker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;inCodeBlock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
            &lt;span class="n"&gt;fenceMarker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'#'&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;currentLevel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;atxHeadingLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;currentLevel&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c"&gt;// ... violation checks&lt;/span&gt;
        &lt;span class="n"&gt;prevLevel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;currentLevel&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;errs&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result on CI (AMD EPYC, 1000-section document):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;metric&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;th&gt;delta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;time/op (geomean)&lt;/td&gt;
&lt;td&gt;52.55 ms&lt;/td&gt;
&lt;td&gt;14.78 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−72%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;memory/op&lt;/td&gt;
&lt;td&gt;2.108 Mi&lt;/td&gt;
&lt;td&gt;1.912 Mi&lt;/td&gt;
&lt;td&gt;−9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;allocs/op&lt;/td&gt;
&lt;td&gt;9,225&lt;/td&gt;
&lt;td&gt;4,677&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−49%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One PR. 72% of the time gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Turn the Pattern Into a System
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;heading-level&lt;/code&gt; fix revealed a reusable pattern: the &lt;code&gt;firstNonSpaceByte&lt;/code&gt; prefilter. Most rules only care about lines starting with a specific byte. A heading starts with &lt;code&gt;#&lt;/code&gt;. A fenced code block starts with &lt;code&gt;`&lt;/code&gt; or &lt;code&gt;~&lt;/code&gt;. A hard tab starts with &lt;code&gt;\t&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Reading the first non-whitespace byte costs almost nothing — one loop over leading spaces, then a byte read. Calling &lt;code&gt;strings.TrimSpace&lt;/code&gt; on every line is not free, especially when 95% of lines would be skipped anyway.&lt;/p&gt;

&lt;p&gt;I applied this prefilter across 14 more rules over 14 subsequent PRs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;firstNonSpaceByte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;' '&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'\t'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'\r'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'\n'&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each individual PR produced a modest gain — 3% to 15% per rule. But they compounded:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;PR&lt;/th&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;time delta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/shinagawa-web/gomarklint/pull/193" rel="noopener noreferrer"&gt;#193&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;no-bare-urls&lt;/td&gt;
&lt;td&gt;−15.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/shinagawa-web/gomarklint/pull/195" rel="noopener noreferrer"&gt;#195&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;no-emphasis-as-heading&lt;/td&gt;
&lt;td&gt;−8.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/shinagawa-web/gomarklint/pull/190" rel="noopener noreferrer"&gt;#190&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;empty-alt-text&lt;/td&gt;
&lt;td&gt;−6.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/shinagawa-web/gomarklint/pull/194" rel="noopener noreferrer"&gt;#194&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;duplicate-heading&lt;/td&gt;
&lt;td&gt;−3.0% (+ −12.6% memory)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/shinagawa-web/gomarklint/pull/192" rel="noopener noreferrer"&gt;#192&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;no-empty-links&lt;/td&gt;
&lt;td&gt;−2.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;Starting from the benchmark baseline after the scaffolding work, to the last PR in the series:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;metric&lt;/th&gt;
&lt;th&gt;start&lt;/th&gt;
&lt;th&gt;end&lt;/th&gt;
&lt;th&gt;improvement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;time/op (geomean)&lt;/td&gt;
&lt;td&gt;74.37 ms&lt;/td&gt;
&lt;td&gt;13.88 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−81%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;memory/op&lt;/td&gt;
&lt;td&gt;2.221 Mi&lt;/td&gt;
&lt;td&gt;1.654 Mi&lt;/td&gt;
&lt;td&gt;−25%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;allocs/op&lt;/td&gt;
&lt;td&gt;9,533&lt;/td&gt;
&lt;td&gt;4,510&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−53%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Twenty PRs over three weeks. Each one measured, each one merged only after the CI benchmark comparison confirmed no regression.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;Profile before writing a single optimization. I got lucky that the biggest problem (&lt;code&gt;isInCodeBlock&lt;/code&gt; at 63%) was immediately obvious. In a more complex codebase, guessing the hotspot first would have sent me optimizing rules that contributed 0.5% of total CPU while the real bottleneck sat untouched. The 20-minute setup cost of a proper benchmark pays back on the first targeted fix.&lt;/p&gt;

&lt;p&gt;Treat the benchmark content as a first-class artifact. A benchmark that produces violations is measuring error-reporting cost, not scanning cost. A benchmark whose content drifts rule-by-rule becomes impossible to interpret. The &lt;code&gt;TestBenchmarkContentIsViolationFree&lt;/code&gt; guard test caught three cases mid-series where a content addition accidentally triggered a violation. Without it, I would have been optimizing against corrupted numbers and wondering why the geomean kept moving.&lt;/p&gt;

&lt;p&gt;Two PRs in this series landed within measurement noise. One was closed without merging. Having numbers in each PR body made that obvious: the CI &lt;code&gt;benchstat&lt;/code&gt; output showed &lt;code&gt;~ (p=0.485)&lt;/code&gt; and there was nothing to argue about. A single "performance" PR with twenty changes mixed together would have buried those non-results.&lt;/p&gt;

&lt;p&gt;Writing before/after benchmark numbers in every PR description did something I didn't expect: it made the series reviewable after the fact. Looking back at 20 PRs, I can tell exactly which optimization accounted for which fraction of the total gain. The &lt;code&gt;firstNonSpaceByte&lt;/code&gt; prefilter was worth applying to 14 rules because I could see, rule by rule, that it kept working. That's how I found the pattern in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;gomarklint is open source: &lt;a href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;github.com/shinagawa-web/gomarklint&lt;/a&gt;. The entire optimization series is tracked under &lt;a href="https://github.com/shinagawa-web/gomarklint/issues/146" rel="noopener noreferrer"&gt;issue #146&lt;/a&gt;, with each PR linking back to it and carrying its own before/after benchmark numbers.&lt;/p&gt;

&lt;p&gt;If you're running a Go linter or any line-by-line text processor, these two patterns are worth trying before anything fancier: inline fence tracking instead of pre-computed ranges, and a first-byte prefilter before any string work. Zero dependencies, easy to test, and between them they accounted for almost all of the 81%.&lt;/p&gt;

</description>
      <category>go</category>
      <category>performance</category>
      <category>opensource</category>
      <category>programming</category>
    </item>
    <item>
      <title>7 CI Checks I Added After Breaking My Own Go OSS Project</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Wed, 20 May 2026 13:02:00 +0000</pubDate>
      <link>https://dev.to/_402ccbd6e5cb02871506/catching-invisible-degradation-in-a-go-oss-project-7-ci-checks-over-11-months-fmb</link>
      <guid>https://dev.to/_402ccbd6e5cb02871506/catching-invisible-degradation-in-a-go-oss-project-7-ci-checks-over-11-months-fmb</guid>
      <description>&lt;p&gt;Three days after a release, an issue arrived: "The install command doesn't work." A module path change in that release had broken &lt;code&gt;go install&lt;/code&gt;. My test suite had passed. My local build had passed. CI had passed. The binary was broken anyway, and I found out from a user report, not from a check.&lt;/p&gt;

&lt;p&gt;That was the first gap. There were more: no performance baseline, so I wouldn't know when a new rule was 3x slower. No way to verify whether the GitHub Action I'd published actually ran correctly in a user's workflow. Each gap seemed minor on its own. Together, they meant I was shipping changes I couldn't fully verify.&lt;/p&gt;

&lt;p&gt;This is the CI harness I assembled over 11 months: 7 checks, each added after a specific failure made the gap impossible to ignore.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Harness at a Glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Added&lt;/th&gt;
&lt;th&gt;What it prevents&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tests &amp;amp; coverage&lt;/td&gt;
&lt;td&gt;2025-06&lt;/td&gt;
&lt;td&gt;Regressions going unnoticed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Installability&lt;/td&gt;
&lt;td&gt;2025-07&lt;/td&gt;
&lt;td&gt;A broken binary reaching users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static analysis&lt;/td&gt;
&lt;td&gt;2026-01&lt;/td&gt;
&lt;td&gt;Code quality regressions, potential bugs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PR label enforcement&lt;/td&gt;
&lt;td&gt;2026-02&lt;/td&gt;
&lt;td&gt;Incomplete release notes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Benchmark comparison&lt;/td&gt;
&lt;td&gt;2026-02&lt;/td&gt;
&lt;td&gt;Unnoticed performance regressions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Action smoke test&lt;/td&gt;
&lt;td&gt;2026-05&lt;/td&gt;
&lt;td&gt;Breaking changes to the published Action&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invisible Unicode detection&lt;/td&gt;
&lt;td&gt;2026-05&lt;/td&gt;
&lt;td&gt;Zero-width characters sneaking into source&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  1. The Coverage Gate: A Tripwire for Vanishing Tests (June 2025)
&lt;/h2&gt;

&lt;p&gt;Purpose: run the full test suite on every PR and enforce a minimum coverage threshold.&lt;/p&gt;

&lt;p&gt;"Run tests" is not the same as "enforce coverage." A PR that deletes test helpers or bypasses a code path drops the coverage number silently if you're only running &lt;code&gt;go test&lt;/code&gt; without tracking the percentage. The threshold forces the question every time: coverage falls below the minimum, CI fails.&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Test with coverage&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;go test ./... -coverprofile=coverage.out&lt;/span&gt;
    &lt;span class="s"&gt;go tool cover -func=coverage.out | \&lt;/span&gt;
      &lt;span class="s"&gt;awk '/total:/ { pct=$3+0; if (pct &amp;lt; 80) { print "Coverage " $3 " is below 80%"; exit 1 } }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it prevents: regressions going unnoticed. The test suite catches broken behavior; the coverage gate catches the removal of the tests that would have caught it.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The Install Canary: Proving Users Can Still Get In (July 2025)
&lt;/h2&gt;

&lt;p&gt;Purpose: verify that &lt;code&gt;go install github.com/shinagawa-web/gomarklint@latest&lt;/code&gt; still works after every push to main.&lt;/p&gt;

&lt;p&gt;This sounds obvious until it breaks. Module path changes, missing &lt;code&gt;main&lt;/code&gt; packages, incorrect version tags: any of these will produce an install error that a passing test suite won't catch. The check runs &lt;code&gt;go install&lt;/code&gt; on the actual published module (not the local source) in a clean environment. If it fails on a PR, the PR cannot merge; if it catches something on main, main goes red until it's resolved.&lt;/p&gt;

&lt;p&gt;What it prevents: releasing a binary that users cannot install. That's a silent failure that lands in your issue tracker three days later when someone reports "the install command doesn't work."&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The Review Inliner: Surfacing Lint Where You Read Code (January 2026)
&lt;/h2&gt;

&lt;p&gt;Purpose: surface lint errors inline on PRs rather than as a buried log line.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/reviewdog/reviewdog" rel="noopener noreferrer"&gt;reviewdog&lt;/a&gt; runs &lt;code&gt;golangci-lint&lt;/code&gt; and posts results as PR review comments on the specific lines that triggered the warning. The feedback loop matters: a lint error posted as a CI log line gets skimmed; a review comment on the line of code gets read.&lt;/p&gt;

&lt;p&gt;What it prevents: code quality regressions that tests don't exercise. The enabled linters target cyclomatic complexity (&lt;code&gt;gocyclo&lt;/code&gt;), cognitive complexity (&lt;code&gt;gocognit&lt;/code&gt;), function length (&lt;code&gt;funlen&lt;/code&gt;), unchecked errors (&lt;code&gt;errcheck&lt;/code&gt;), and suspicious constructs (&lt;code&gt;staticcheck&lt;/code&gt;, &lt;code&gt;govet&lt;/code&gt;). In practice the complexity linters fire most often — a function that passes every test can still be flagged for being too difficult to reason about.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The Changelog Guard: Blocking the Invisible PR (February 2026)
&lt;/h2&gt;

&lt;p&gt;Purpose: auto-assign labels from Conventional Commits prefixes (&lt;code&gt;feat:&lt;/code&gt;, &lt;code&gt;fix:&lt;/code&gt;, &lt;code&gt;chore:&lt;/code&gt;, etc.) and block merging any PR that carries no label.&lt;/p&gt;

&lt;p&gt;Release notes in gomarklint are generated from PR labels. A PR merged without a label is invisible in the changelog. The enforcement step runs on &lt;code&gt;pull_request&lt;/code&gt; events and fails if no label is present after the auto-assignment pass.&lt;/p&gt;

&lt;p&gt;What it prevents: gaps in release notes. If the changelog can't be trusted, the project's version history can't be trusted either.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. The Performance Witness: Catching the 3x Slowdown That Passes Tests (February 2026)
&lt;/h2&gt;

&lt;p&gt;Purpose: run the full benchmark suite on both the PR branch and main, then post the delta as a PR comment.&lt;/p&gt;

&lt;p&gt;The comment uses a three-state indicator: ✅ if the PR branch is no slower than main, ⚠️ if it is 10–50% slower, and ❌ if it is more than 50% slower. This check does not hard-fail CI: it posts a warning comment and lets the reviewer decide whether to proceed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap: hard-failing on performance regressions trains reviewers to ignore the check.&lt;/strong&gt; Early versions did fail CI when the threshold was crossed. The problem is runner variance: GitHub Actions runners share hardware, and a run on a busy runner is measurably slower than a previous run on a quiet one. That variance exceeded the threshold I needed to catch real regressions. After two false positives in a row, I started dismissing the failures on sight. The fix was to post the data without blocking: the comparison still runs, the comment still appears, but the merge decision stays with the reviewer.&lt;/p&gt;

&lt;p&gt;What it prevents: performance regressions that pass every unit test. A new rule implementation that is correct but 3x slower will show up immediately. The decision to merge or revise is still human, but the data is always there.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. The Wrapper Probe: Testing What Users Actually Run (May 2026)
&lt;/h2&gt;

&lt;p&gt;Purpose: actually run the published GitHub Action against a real Markdown fixture on every relevant change to the Action definition or the underlying binary.&lt;/p&gt;

&lt;p&gt;A GitHub Action can have valid YAML and a valid binary and still fail in ways that neither validates: wrong input names, incorrect default values, broken entrypoint paths. The smoke test checks out the Action from the PR branch and runs it end-to-end in CI against a controlled fixture directory.&lt;/p&gt;

&lt;p&gt;What it prevents: breaking changes to the Action going unnoticed. Users of the Action would hit the failure; I would not, because my own tests don't exercise the Action wrapper layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. The Hidden-Character Scanner: Defending Against Code You Cannot See (May 2026)
&lt;/h2&gt;

&lt;p&gt;Purpose: defend against supply-chain attacks that hide malicious code inside invisible Unicode characters embedded in &lt;code&gt;.go&lt;/code&gt; and &lt;code&gt;.sh&lt;/code&gt; files.&lt;/p&gt;

&lt;p&gt;In March 2026, the &lt;strong&gt;GlassWorm&lt;/strong&gt; attack was disclosed. It works by embedding malicious code inside Unicode variation selector characters (U+E0100–U+E01EF) — characters invisible in editors, invisible in GitHub's diff view, invisible to standard code review. A single visible source line can carry approximately 18,000 hidden lines of code. &lt;a href="https://www.aikido.dev/blog/glassworm-returns-unicode-attack-github-npm-vscode" rel="noopener noreferrer"&gt;Over 151 GitHub repositories&lt;/a&gt; and &lt;a href="https://thehackernews.com/2026/03/glassworm-supply-chain-attack-abuses-72.html" rel="noopener noreferrer"&gt;72 Open VSX extensions&lt;/a&gt; were confirmed compromised before the attack was publicly documented.&lt;/p&gt;

&lt;p&gt;The check greps for the specific character ranges used in the attack on every push and PR touching Go or shell files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/sh&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rPn&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'[\x{200B}\x{200C}\x{200D}\x{FEFF}]|[\x{E0100}-\x{E01EF}]'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.go'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.sh'&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Invisible Unicode characters detected"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
  &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="c"&gt;# grep exits 1 when no match (success), 0 when match found (failure), 2 on error&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 2 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"grep error: scan failed"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The job fails with a non-zero exit code on any match, and with a distinct exit code on grep errors, so a scan failure cannot silently bypass the check.&lt;/p&gt;

&lt;p&gt;What it prevents: a contaminated contribution entering the codebase undetected. The threat isn't accidental encoding corruption — it's deliberate concealment. Standard diff review offers no protection against it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Local Mirror: Catching Failures Before They Become PRs
&lt;/h2&gt;

&lt;p&gt;The CI harness runs on GitHub Actions, which means feedback arrives after a push. A PR that fails lint or drops below the coverage threshold wastes a round-trip: push, wait, read the failure, fix locally, push again.&lt;/p&gt;

&lt;p&gt;The pre-push hook is the local mirror of the harness. It runs &lt;code&gt;golangci-lint&lt;/code&gt;, the unit test suite, and E2E tests before &lt;code&gt;git push&lt;/code&gt; completes. If any check fails, the push is aborted. The PR never gets opened in a broken state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;
golangci-lint run ./...
go &lt;span class="nb"&gt;test&lt;/span&gt; ./...
go &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-tags&lt;/span&gt; e2e ./...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hook can be bypassed with &lt;code&gt;git push --no-verify&lt;/code&gt; for genuine emergencies, but that's an explicit override, not the default path.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the Harness Grew
&lt;/h2&gt;

&lt;p&gt;I started with tests only, because that's where every project should start. Each subsequent check was added after a specific failure: the installability check came after a broken binary slipped to users; benchmarks came after a performance regression went unnoticed through two releases; the Action smoke test came after I realized I had been testing the binary but not the wrapper that users actually invoke.&lt;/p&gt;

&lt;p&gt;The harness wasn't designed. It was accumulated. Each new check costs very little once the infrastructure is in place (a new job, an existing action, a few lines of shell), and the protection adds up.&lt;/p&gt;

&lt;p&gt;One gap it still doesn't close: the pre-push hook is opt-in and only runs on my machine. A new contributor submitting their first PR gets CI feedback rather than local feedback: the round-trip I eliminated for myself still exists for everyone else.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Does Your Harness Look Like?
&lt;/h2&gt;

&lt;p&gt;This is how I maintain quality in gomarklint: automated gates that catch categories of failure I've already experienced, running on every push and PR without my involvement.&lt;/p&gt;

&lt;p&gt;What does your CI harness include? Have you added checks that go beyond tests and lint, things like installability verification, Action smoke tests, or performance baselines? I'm curious what gaps yours was built to close.&lt;/p&gt;

</description>
      <category>go</category>
      <category>github</category>
      <category>opensource</category>
      <category>devops</category>
    </item>
    <item>
      <title>grep Said 1,202. The Real Answer Was 10. — Introducing colref</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 12 May 2026 23:14:48 +0000</pubDate>
      <link>https://dev.to/_402ccbd6e5cb02871506/grep-said-1202-the-real-answer-was-10-introducing-colref-2lce</link>
      <guid>https://dev.to/_402ccbd6e5cb02871506/grep-said-1202-the-real-answer-was-10-introducing-colref-2lce</guid>
      <description>&lt;p&gt;When deleting a database column, I ran &lt;code&gt;grep "\.html\b"&lt;/code&gt; across a Django codebase to check for references. It returned 1,202 hits. The column had 10 actual attribute-access references. The other 1,192 were template paths, HTML file extensions in strings, comments, and import fragments — none of which mattered.&lt;/p&gt;

&lt;p&gt;Filtering 1,200+ grep hits by hand every time you drop a column isn't a workflow, it's a chore I kept putting off.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/shinagawa-web/colref" rel="noopener noreferrer"&gt;colref&lt;/a&gt; — a CLI tool that uses AST parsing to find only the attribute-access references to a model field, filtering out everything grep can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Haystack: One Field Name, Ten Thousand Strings
&lt;/h2&gt;

&lt;p&gt;grep treats your codebase as a flat stream of characters. &lt;code&gt;.html&lt;/code&gt; matches everything containing those five characters — in code, in strings, in comments, in template paths.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;html&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.py"&lt;/span&gt; wagtail/
&lt;span class="c"&gt;# 1,202 hits&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 1,192 noise hits in Wagtail break down like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTML file extensions in strings&lt;/td&gt;
&lt;td&gt;1,087&lt;/td&gt;
&lt;td&gt;&lt;code&gt;template_name = "pages/publish.html"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other string literals&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;td&gt;&lt;code&gt;format_html(...)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Comments&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;&lt;code&gt;# See docs/settings.html#...&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other&lt;/td&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;td&gt;&lt;code&gt;template_html = base + ".html"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same problem appears across every project and every field name. On Mastodon, &lt;code&gt;.domain&lt;/code&gt; gives 269 hits; 175 are spec files and SQL heredocs. On Zulip, &lt;code&gt;.name&lt;/code&gt; for &lt;code&gt;Stream&lt;/code&gt; returns 1,347 hits; 10 are noise.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;grep matches characters. It cannot distinguish &lt;code&gt;obj.html&lt;/code&gt; from &lt;code&gt;publish.html&lt;/code&gt; in a path string.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Blueprint: AST as a Code Structure Map
&lt;/h2&gt;

&lt;p&gt;colref parses your source files into an Abstract Syntax Tree and walks only the attribute-access nodes — the ones that represent &lt;code&gt;obj.field&lt;/code&gt; in running code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; django &lt;span class="nt"&gt;--model&lt;/span&gt; Embed &lt;span class="nt"&gt;--field&lt;/span&gt; html ./wagtail/
&lt;span class="c"&gt;# 10 hits&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;String literals, comments, Django template strings, SQL heredocs, and docstring-embedded code examples are all invisible to the AST walker.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Hits&lt;/th&gt;
&lt;th&gt;What's included&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;grep "html"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3,534&lt;/td&gt;
&lt;td&gt;Everything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;grep "\.html\b"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,202&lt;/td&gt;
&lt;td&gt;File extensions, strings, comments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;colref&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Attribute accesses only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;The AST sees &lt;code&gt;obj.html&lt;/code&gt; as an attribute access and &lt;code&gt;"publish.html"&lt;/code&gt; as a string literal — two different node types.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Anchor: ORM Schema as a Disambiguation Layer
&lt;/h2&gt;

&lt;p&gt;AST parsing alone is not enough. &lt;code&gt;obj.name&lt;/code&gt; might be &lt;code&gt;Stream.name&lt;/code&gt;, &lt;code&gt;User.name&lt;/code&gt;, or a method call with no relation to your database. colref resolves this by reading the ORM schema first.&lt;/p&gt;

&lt;p&gt;For Django, it parses &lt;code&gt;models.py&lt;/code&gt; files to find which fields are declared on which model. For Rails, it reads &lt;code&gt;db/schema.rb&lt;/code&gt; (or replays migrations if &lt;code&gt;schema.rb&lt;/code&gt; is absent). Only references to a field that actually exists on the target model are reported.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; rails &lt;span class="nt"&gt;--model&lt;/span&gt; Account &lt;span class="nt"&gt;--field&lt;/span&gt; username ./mastodon/
&lt;span class="c"&gt;# 40 hits  (grep \.username\b gives 196)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Without the schema, colref would have no way to distinguish &lt;code&gt;account.username&lt;/code&gt; from &lt;code&gt;config.username&lt;/code&gt; in a settings file.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Implicit Self" Trap
&lt;/h2&gt;

&lt;p&gt;The most common false positive colref produces comes from bare method calls with no explicit receiver:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Forem — app/views/articles/show.html.erb&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sx"&gt;% title &lt;/span&gt;&lt;span class="s2"&gt;"Welcome!"&lt;/span&gt; &lt;span class="o"&gt;%&amp;gt;&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sx"&gt;% title &lt;/span&gt;&lt;span class="vi"&gt;@article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title_with_query_preamble&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_signed_in?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the field name matches a helper method called on implicit self, colref currently includes it. For the Forem &lt;code&gt;title&lt;/code&gt; field, this produced 50 false positives out of 340 reported hits.&lt;/p&gt;

&lt;p&gt;The fix is a receiver-aware pass: treat a &lt;code&gt;call&lt;/code&gt; node as a candidate only when it has an explicit receiver. That work is on the roadmap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verified Against 15+ Real-World Projects
&lt;/h2&gt;

&lt;p&gt;colref has been tested against real OSS codebases across both ORMs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Django&lt;/strong&gt; (10 projects: Wagtail, Saleor, Zulip, NetBox, BookWyrm, Misago, django-wiki, and others):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zero false negatives across all tested model/field pairs&lt;/li&gt;
&lt;li&gt;Zero false positives after fixing the &lt;code&gt;models/&lt;/code&gt; package scanner (#65)&lt;/li&gt;
&lt;li&gt;Remaining gap: &lt;code&gt;abstract_models.py&lt;/code&gt; patterns (django-oscar style) not yet supported&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Rails&lt;/strong&gt; (Mastodon, Forem, Fat Free CRM, Lobsters, Publify, mutual-aid, and others):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Same precision/recall profile&lt;/li&gt;
&lt;li&gt;Projects without a committed &lt;code&gt;db/schema.rb&lt;/code&gt; now supported via migration replay&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Detailed results with TP/FN/FP breakdowns per project are in the &lt;a href="https://github.com/shinagawa-web/colref/issues" rel="noopener noreferrer"&gt;GitHub issues&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Django and Rails are the first two ORMs. The roadmap includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Laravel&lt;/strong&gt; (PHP) — migration-based schema, Eloquent attribute access&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spring Boot / JPA&lt;/strong&gt; (Java) — entity annotations, JPA field resolution&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prisma&lt;/strong&gt; (TypeScript/Node) — schema.prisma as the source of truth&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you use one of these and want to help shape the implementation, the issues are open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;colref filters out the text noise that makes grep unreliable for column reference checks. On Wagtail, Mastodon, and Zulip the signal-to-noise ratio went from roughly 1% to 100%, and I now reach for it before grep when removing a column.&lt;/p&gt;

&lt;p&gt;The implicit-self false positives are still there, &lt;code&gt;abstract_models.py&lt;/code&gt; isn't handled, and 15 projects is a small slice of the Django and Rails worlds.&lt;/p&gt;

&lt;p&gt;If you maintain a Django or Rails codebase, I'd like to know how colref does on your models — especially the cases where it misses something obvious or reports a hit that's clearly noise.&lt;/p&gt;

&lt;p&gt;Try it and open an issue if it breaks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go &lt;span class="nb"&gt;install &lt;/span&gt;github.com/shinagawa-web/colref@latest
colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; django &lt;span class="nt"&gt;--model&lt;/span&gt; YourModel &lt;span class="nt"&gt;--field&lt;/span&gt; your_field ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;👉 &lt;a href="https://github.com/shinagawa-web/colref" rel="noopener noreferrer"&gt;github.com/shinagawa-web/colref&lt;/a&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>django</category>
      <category>rails</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Beyond Lines: Announcing "gosemdiff" – A Logic-Aware Diff Tool for Go</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Thu, 30 Apr 2026 05:57:51 +0000</pubDate>
      <link>https://dev.to/_402ccbd6e5cb02871506/beyond-lines-announcing-gosemdiff-a-logic-aware-diff-tool-for-go-30k</link>
      <guid>https://dev.to/_402ccbd6e5cb02871506/beyond-lines-announcing-gosemdiff-a-logic-aware-diff-tool-for-go-30k</guid>
      <description>&lt;p&gt;Today marks a personal milestone: I have finally finished a massive, six-month-long refactoring of my other project, &lt;a href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;gomarklint&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;After spending half a year staring at Go code structures and AST nodes, I realized something painful. Our current tools are still "dumb" when it comes to understanding the &lt;em&gt;intent&lt;/em&gt; of our changes. We are still reviewing code line-by-line, even though we think in structures and logic.&lt;/p&gt;

&lt;p&gt;That's why I'm excited to announce my next OSS project: &lt;a href="https://github.com/shinagawa-web/gosemdiff" rel="noopener noreferrer"&gt;gosemdiff&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem with Current Solutions (Including AI)
&lt;/h2&gt;

&lt;p&gt;We now have AI tools that can summarize Pull Requests for us. They are helpful, but let's be honest: &lt;strong&gt;AI summaries are often "vibes-based."&lt;/strong&gt; An AI might say, &lt;em&gt;"This PR refactors the processing logic,"&lt;/em&gt; but it can't mathematically guarantee that the logic remains unchanged. AI can hallucinate, overlook edge cases, or fail to distinguish between a variable rename and a subtle logic bug.&lt;/p&gt;

&lt;p&gt;As engineers, we don't need a "guess." We need &lt;strong&gt;proof&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Standard &lt;code&gt;git diff&lt;/code&gt; treats code as text. But code is not just text; it's a tree of logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: gosemdiff
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;gosemdiff&lt;/strong&gt; is a semantic diff tool designed specifically for Go. Instead of comparing lines, it parses your source code into an &lt;strong&gt;Abstract Syntax Tree (AST)&lt;/strong&gt; and compares the underlying structures.&lt;/p&gt;

&lt;p&gt;The goal is to provide a "Truth Machine" for your Pull Requests.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key Concepts:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Semantic Labeling&lt;/strong&gt;: Automatically identify changes as &lt;code&gt;MOVE&lt;/code&gt;, &lt;code&gt;RENAME&lt;/code&gt;, or &lt;code&gt;LOGIC CHANGE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logic Integrity&lt;/strong&gt;: Prove that a refactor hasn't changed the execution flow by comparing normalized AST hashes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test-Gap Detection&lt;/strong&gt;: Alert you if a logic change was made without any corresponding updates to your &lt;code&gt;*_test.go&lt;/code&gt; files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero-Noise Mode&lt;/strong&gt;: Completely ignore comments, formatting, and import ordering.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Roadmap to v1.0.0
&lt;/h2&gt;

&lt;p&gt;I've laid out an ambitious roadmap in the repository, moving from basic function inventorying to a fully integrated GitHub Action. &lt;/p&gt;

&lt;p&gt;The ultimate goal? A CI bot that tells your reviewers: &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"This PR is 90% refactoring. Logic changes detected in only 2 files. One logic change is missing a unit test."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  I Need Your Feedback!
&lt;/h2&gt;

&lt;p&gt;I'm taking a short break to recharge after the &lt;code&gt;gomarklint&lt;/code&gt; marathon, and full-scale development on &lt;code&gt;gosemdiff&lt;/code&gt; will kick off in about two months. &lt;/p&gt;

&lt;p&gt;However, the "Grand Vision" is already live in the README. I want to build this for the community, so I need your input:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What kind of diffs annoy you the most during code reviews?&lt;/li&gt;
&lt;li&gt;What "semantic" features would make your life easier?&lt;/li&gt;
&lt;li&gt;What's your dream CI summary?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Please check out the repo and leave your ideas, feature requests, or "what-ifs" in the GitHub Issues!&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/shinagawa-web/gosemdiff" rel="noopener noreferrer"&gt;gosemdiff&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let's make code reviews about logic again, not just lines.&lt;/p&gt;

</description>
      <category>go</category>
      <category>opensource</category>
      <category>analytics</category>
      <category>refactoring</category>
    </item>
    <item>
      <title>How I Built Inline Disable Comments for a Go Markdown Linter</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 28 Apr 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/_402ccbd6e5cb02871506/how-i-built-inline-disable-comments-for-a-go-markdown-linter-598b</link>
      <guid>https://dev.to/_402ccbd6e5cb02871506/how-i-built-inline-disable-comments-for-a-go-markdown-linter-598b</guid>
      <description>&lt;p&gt;If you've used ESLint, you've probably written &lt;code&gt;// eslint-disable-next-line&lt;/code&gt; at least once. It's one of those small features that makes a linter actually usable in the real world — because no rule is right 100% of the time.&lt;/p&gt;

&lt;p&gt;I recently built the same feature for &lt;a href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;gomarklint&lt;/a&gt;, a fast Markdown linter written in Go. This post walks through the design decisions and implementation details.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;gomarklint enforces rules like "no bare URLs," "headings must not skip levels," and "lines must not exceed 120 characters." These rules are useful globally, but sometimes a specific line is legitimately different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A changelog entry that intentionally contains a bare URL&lt;/li&gt;
&lt;li&gt;A generated code block where line length doesn't matter&lt;/li&gt;
&lt;li&gt;A one-off exception that would be wrong to suppress project-wide&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What we want is a way to suppress violations inline, scoped to exactly the lines that need it — just like markdownlint does with HTML comments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- markdownlint-disable MD013 --&amp;gt;&lt;/span&gt;
A very long line that is intentionally long...
&lt;span class="c"&gt;&amp;lt;!-- markdownlint-enable MD013 --&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;gomarklint uses the same HTML comment approach, with its own prefix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- gomarklint-disable MD013 --&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What We Need to Support
&lt;/h2&gt;

&lt;p&gt;There are four directive types, covering both block-level and per-line use cases:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Directive&lt;/th&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;!-- gomarklint-disable --&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Disables all rules from this line onward&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;!-- gomarklint-disable MD013 --&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Disables specific rules from this line onward&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;!-- gomarklint-enable --&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Re-enables all rules (ends a block disable)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;!-- gomarklint-enable MD013 --&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Re-enables specific rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;!-- gomarklint-disable-line --&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Disables all rules on this line only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;!-- gomarklint-disable-line MD013 --&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Disables specific rules on this line only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;!-- gomarklint-disable-next-line --&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Disables all rules on the next line only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;!-- gomarklint-disable-next-line MD013 --&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Disables specific rules on the next line only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Data Model
&lt;/h2&gt;

&lt;p&gt;The core challenge is representing "which rules are disabled on line N" efficiently. I ended up with three types.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;lineDisable&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This struct describes the disable state for a single line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;lineDisable&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;allDisabled&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;names&lt;/span&gt;       &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting part is that &lt;code&gt;names&lt;/code&gt; plays two different roles depending on &lt;code&gt;allDisabled&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If &lt;code&gt;allDisabled&lt;/code&gt; is &lt;strong&gt;false&lt;/strong&gt;: &lt;code&gt;names&lt;/code&gt; is the list of disabled rules&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;allDisabled&lt;/code&gt; is &lt;strong&gt;true&lt;/strong&gt;: &lt;code&gt;names&lt;/code&gt; is the list of &lt;em&gt;exceptions&lt;/em&gt; (rules that are &lt;strong&gt;not&lt;/strong&gt; suppressed)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This lets the same struct handle both "disable everything" and "disable only MD013" without needing separate types. The &lt;code&gt;isRuleDisabled&lt;/code&gt; method encodes this logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ld&lt;/span&gt; &lt;span class="n"&gt;lineDisable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;isRuleDisabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ruleName&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ld&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allDisabled&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;ld&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;names&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ruleName&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt; &lt;span class="c"&gt;// explicitly re-enabled&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;ld&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;names&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ruleName&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;disabledSet&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This is just a map from absolute line numbers to their disable state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;disabledSet&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;lineDisable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Absolute line number" matters here because gomarklint strips YAML frontmatter before linting. We need to track an &lt;code&gt;offset&lt;/code&gt; so that line numbers stay consistent with what the user sees in their editor.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;blockState&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This tracks the &lt;em&gt;current&lt;/em&gt; block-level disable state as we scan through lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;blockState&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;allDisabled&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;exceptions&lt;/span&gt;  &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="c"&gt;// re-enabled rules when allDisabled=true&lt;/span&gt;
    &lt;span class="n"&gt;rules&lt;/span&gt;       &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="c"&gt;// named disabled rules when allDisabled=false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;blockState&lt;/code&gt; is a temporary, mutable value — it gets updated as we encounter &lt;code&gt;disable&lt;/code&gt; and &lt;code&gt;enable&lt;/code&gt; directives, and its current state is applied to each line we visit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parsing: Step 1 — Extracting a Directive from a Line
&lt;/h2&gt;

&lt;p&gt;Before we can build the disable map, we need to extract directives from individual lines. &lt;code&gt;parseDirectiveLine&lt;/code&gt; handles this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;parseDirectiveLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;directive&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ruleNames&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"&amp;lt;!--"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s"&gt;"--&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;inner&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"gomarklint-"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"disable"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"enable"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"disable-line"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"disable-next-line"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Find &lt;code&gt;&amp;lt;!--&lt;/code&gt; and &lt;code&gt;--&amp;gt;&lt;/code&gt; to extract the comment body&lt;/li&gt;
&lt;li&gt;Check for the &lt;code&gt;gomarklint-&lt;/code&gt; prefix&lt;/li&gt;
&lt;li&gt;Split the rest into the directive keyword and optional rule names&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rule names are everything after the directive keyword — so &lt;code&gt;&amp;lt;!-- gomarklint-disable MD013 MD032 --&amp;gt;&lt;/code&gt; yields &lt;code&gt;["MD013", "MD032"]&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parsing: Step 2 — Building the &lt;code&gt;disabledSet&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;parseDisableComments&lt;/code&gt; scans all lines, maintains a running &lt;code&gt;blockState&lt;/code&gt;, and builds the final &lt;code&gt;disabledSet&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;parseDisableComments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;disabledSet&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;set&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;disabledSet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;bs&lt;/span&gt; &lt;span class="n"&gt;blockState&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;absLine&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;
        &lt;span class="n"&gt;directive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ruleNames&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;parseDirectiveLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;directive&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"disable"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ruleNames&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;bs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;blockState&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;allDisabled&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ruleNames&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"enable"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ruleNames&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;bs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;blockState&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="c"&gt;// full reset&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allDisabled&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exceptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exceptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ruleNames&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;removeAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ruleNames&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"disable-line"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;addLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;absLine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ruleNames&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"disable-next-line"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;addLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;absLine&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ruleNames&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;applyTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;absLine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// apply current block state to this line&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;set&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;bs.applyTo(set, absLine)&lt;/code&gt; is called on every line&lt;/strong&gt;, not just lines with directives. This is how block-level disabling propagates — the current block state "stamps" each line as we pass it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;disable-line&lt;/code&gt; and &lt;code&gt;disable-next-line&lt;/code&gt;&lt;/strong&gt; write directly to &lt;code&gt;set&lt;/code&gt; without touching &lt;code&gt;blockState&lt;/code&gt;, since they're scoped to one line only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;enable&lt;/code&gt; has two behaviors&lt;/strong&gt; depending on the current block state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Full &lt;code&gt;enable&lt;/code&gt; → reset &lt;code&gt;blockState&lt;/code&gt; entirely&lt;/li&gt;
&lt;li&gt;Named &lt;code&gt;enable&lt;/code&gt; inside an all-disabled block → add to exceptions list&lt;/li&gt;
&lt;li&gt;Named &lt;code&gt;enable&lt;/code&gt; inside a named-disable block → remove from the rules list&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Priority: What Wins When Rules Conflict?
&lt;/h2&gt;

&lt;p&gt;There are cases where a line can be affected by multiple directives. The priority rules are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;All-disabled beats named-disable.&lt;/strong&gt; If a line is already fully disabled, adding named rules has no effect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Line-specific directives beat block-level.&lt;/strong&gt; A &lt;code&gt;disable-line&lt;/code&gt; or &lt;code&gt;disable-next-line&lt;/code&gt; always takes effect regardless of what the block state says.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;addLine&lt;/code&gt; enforces rule 1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="n"&gt;disabledSet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;addLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ruleNames&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exists&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;exists&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allDisabled&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="c"&gt;// fully disabled with no exceptions — nothing to add&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ruleNames&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lineDisable&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;allDisabled&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lineDisable&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ruleNames&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Integration: Filtering Violations
&lt;/h2&gt;

&lt;p&gt;With the &lt;code&gt;disabledSet&lt;/code&gt; built, filtering in &lt;code&gt;collectErrors&lt;/code&gt; is a map lookup per violation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;disabled&lt;/span&gt; &lt;span class="n"&gt;disabledSet&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"gomarklint-disable"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;disabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parseDisableComments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;allErrors&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collectLineErrors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;// ... external link checks ...&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;filtered&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;allErrors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;allErrors&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;disabled&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isDisabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rule&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;filtered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filtered&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;allErrors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;filtered&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a small but meaningful optimization: we only call &lt;code&gt;parseDisableComments&lt;/code&gt; if the file body actually contains the string &lt;code&gt;"gomarklint-disable"&lt;/code&gt;. For files without any directives — the common case — we skip the parsing step entirely. This keeps the hot path fast.&lt;/p&gt;

&lt;p&gt;The filter uses the in-place slice trick (&lt;code&gt;filtered := allErrors[:0]&lt;/code&gt;) to avoid an extra allocation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Intentional "Silent Fail" for Invalid Rule Names
&lt;/h2&gt;

&lt;p&gt;What happens if a user writes a typo or a nonexistent rule name?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- gomarklint-disable-line no-bare-url --&amp;gt;&lt;/span&gt;
https://example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The correct rule name is &lt;code&gt;no-bare-urls&lt;/code&gt; (with an &lt;code&gt;s&lt;/code&gt;). The result: the directive is silently ignored, and the violation is still reported.&lt;/p&gt;

&lt;p&gt;This is intentional. The lookup in &lt;code&gt;isRuleDisabled&lt;/code&gt; is a plain string comparison:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;ld&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;names&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ruleName&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the name doesn't match any known rule, the function returns &lt;code&gt;false&lt;/code&gt; and the violation passes through. There's no registry lookup or error message.&lt;/p&gt;

&lt;p&gt;This matches the behavior of markdownlint and most other linters. A warning system for invalid directive names could be added, but for now the behavior is predictable: wrong name → no suppression.&lt;/p&gt;

&lt;p&gt;We have explicit E2E tests for this to make sure it stays intentional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- gomarklint-disable-line no-bare-url --&amp;gt;&lt;/span&gt;
https://wrong-rule-name.example.com

&lt;span class="c"&gt;&amp;lt;!-- gomarklint-disable-next-line nonexistent-rule --&amp;gt;&lt;/span&gt;
https://nonexistent-rule.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both lines are expected to produce violations — the test fails if they're silently suppressed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing Strategy
&lt;/h2&gt;

&lt;p&gt;The feature is tested at three levels:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unit tests&lt;/strong&gt; (&lt;code&gt;disable_comment_test.go&lt;/code&gt;) cover &lt;code&gt;parseDirectiveLine&lt;/code&gt; and &lt;code&gt;parseDisableComments&lt;/code&gt; in isolation — 13+ cases for directive parsing, edge cases for block/line scope interaction, priority rules, and frontmatter offset handling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integration tests&lt;/strong&gt; (&lt;code&gt;linter_test.go&lt;/code&gt;) run &lt;code&gt;linter.Run()&lt;/code&gt; against in-memory Markdown strings and verify which violations survive filtering. These tests also include a case that confirms parsing is skipped when no directive keyword is present in the file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;E2E tests&lt;/strong&gt; (&lt;code&gt;e2e_test.go&lt;/code&gt;) run the actual binary against a fixture file (&lt;code&gt;disable_comment.md&lt;/code&gt;) and check the reported line numbers. This catches any disconnect between the parsing logic and how violations are reported to the user.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Full Picture
&lt;/h2&gt;

&lt;p&gt;Here's the complete flow, end to end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Markdown file
    │
    ▼
StripFrontmatter()  →  body string + offset int
    │
    ▼
strings.Contains("gomarklint-disable")?
    │   yes
    ▼
parseDisableComments(lines, offset)
    │   scan each line:
    │     parseDirectiveLine()  →  directive + ruleNames
    │     update blockState
    │     bs.applyTo(set, absLine)
    ▼
disabledSet  (map[int]lineDisable)
    │
    ▼
collectLineErrors()  →  []LintError
    │
    ▼
filter: !disabled.isDisabled(e.Line, e.Rule)
    │
    ▼
sorted []LintError  →  reported to user
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The implementation is about 170 lines of Go (&lt;code&gt;internal/linter/disable_comment.go&lt;/code&gt;). Most of the logic lives in the data model — the filtering step itself is just a map lookup.&lt;/p&gt;




&lt;p&gt;The part I found most interesting was representing "all disabled except these" and "only these are disabled" with the same struct, by flipping what &lt;code&gt;names&lt;/code&gt; means depending on &lt;code&gt;allDisabled&lt;/code&gt;. It kept the code flat while covering the full directive surface.&lt;/p&gt;

&lt;p&gt;Full implementation: &lt;a href="https://github.com/shinagawa-web/gomarklint/blob/main/internal/linter/disable_comment.go" rel="noopener noreferrer"&gt;gomarklint/internal/linter/disable_comment.go&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;gomarklint is an open-source Markdown linter written in Go.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>codequality</category>
      <category>go</category>
      <category>showdev</category>
      <category>tooling</category>
    </item>
    <item>
      <title>How to add a markdown quality gate to your GitHub Actions workflow</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 21 Apr 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/_402ccbd6e5cb02871506/markdown-linting-in-ci-markdownlint-cli2-vs-gomarklint-2gg3</link>
      <guid>https://dev.to/_402ccbd6e5cb02871506/markdown-linting-in-ci-markdownlint-cli2-vs-gomarklint-2gg3</guid>
      <description>&lt;p&gt;My team's docs repo crossed 400 files last year. We merged a PR that quietly introduced three broken external links, one heading that jumped from H2 to H4, and a fenced code block without a language tag. None of it failed CI. A reader filed a GitHub issue two weeks later.&lt;/p&gt;

&lt;p&gt;Adding a markdown quality gate seemed like an obvious fix, but the first option we tried pulled in a Node.js runtime setup step, a pinned Node version, and a question from a teammate: "why does our docs pipeline touch Node?" This tutorial walks through building a working workflow from scratch — the file path, the triggers, the config, and what a real failure looks like.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Create the workflow file
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;.github/workflows/markdown-lint.yml&lt;/code&gt; in your repository. Start with just the trigger:&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="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Markdown lint&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**/*.md"&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**/*.md"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;paths&lt;/code&gt; filter means the job only runs when a &lt;code&gt;.md&lt;/code&gt; file changed. On a busy repo this matters — you don't want a Go or Python change triggering a docs check.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Add the job skeleton
&lt;/h2&gt;

&lt;p&gt;Extend the file with a job and the checkout step:&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="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Markdown lint&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**/*.md"&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**/*.md"&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;lint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing surprising here. Checkout is always first — the linter needs the files on disk.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Add the markdown linter to your GitHub Actions job
&lt;/h2&gt;

&lt;p&gt;We'll use gomarklint — a single static binary with no runtime dependencies. The entire lint step is one line:&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="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Markdown lint&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**/*.md"&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**/*.md"&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;lint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&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;Lint Markdown&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shinagawa-web/gomarklint-action@v1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the complete workflow. Push this file and the markdown lint check will run on every PR that touches a &lt;code&gt;.md&lt;/code&gt; file.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: See a real failure
&lt;/h2&gt;

&lt;p&gt;Without any configuration, gomarklint runs its default rules. A PR that introduces a heading jump produces output like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docs/api/endpoints.md:23: heading-level: expected H3, got H4 (skipped a level)
docs/contributing.md:11: fenced-code-language: code block has no language identifier
docs/contributing.md:58: external-link: https://old-domain.example.com/guide returned 404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The job exits non-zero. The PR check goes red. The broken content doesn't merge.&lt;/p&gt;

&lt;p&gt;The external link check is the one that would have caught our production issue. gomarklint makes real HTTP requests to each linked URL and reports anything that returns 4xx or 5xx, or times out. No second tool required.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Add a config file
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;.gomarklint.yaml&lt;/code&gt; at the repo root to tune which rules run and how:&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="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;heading-level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;minLevel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;fenced-code-language&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;external-link&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;timeoutSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
    &lt;span class="na"&gt;skipPatterns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;localhost"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;example&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;minLevel: 2&lt;/code&gt; means H1 is reserved for the document title and the linter won't flag its absence in sub-pages. &lt;code&gt;skipPatterns&lt;/code&gt; lets you exclude URLs that are intentionally unreachable in CI (local dev URLs, placeholder domains).&lt;/p&gt;

&lt;p&gt;The gomarklint action picks up &lt;code&gt;.gomarklint.yaml&lt;/code&gt; automatically — no flag needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Make the markdown lint check block merges
&lt;/h2&gt;

&lt;p&gt;In your repository settings, go to &lt;strong&gt;Branches → Branch protection rules → Require status checks to pass before merging&lt;/strong&gt; and add &lt;code&gt;lint&lt;/code&gt; (or whatever you named the job). From this point the check is a hard gate, not advisory.&lt;/p&gt;

&lt;p&gt;The full workflow, with config wired in, looks like this:&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="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Markdown lint&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**/*.md"&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**/*.md"&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;lint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&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;Lint Markdown&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shinagawa-web/gomarklint-action@v1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The config lives in &lt;code&gt;.gomarklint.yaml&lt;/code&gt; at the root. The action finds it automatically.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the rules actually catch
&lt;/h2&gt;

&lt;p&gt;gomarklint's default rule set covers the problems that consistently show up in team docs repositories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Heading level jumps (H2 → H4 with no H3)&lt;/li&gt;
&lt;li&gt;Duplicate headings within a file&lt;/li&gt;
&lt;li&gt;More than one H1 per file&lt;/li&gt;
&lt;li&gt;Missing blank lines around headings, lists, and code blocks&lt;/li&gt;
&lt;li&gt;Fenced code blocks with no language identifier&lt;/li&gt;
&lt;li&gt;Images with missing or empty alt text&lt;/li&gt;
&lt;li&gt;Bare URLs that aren't wrapped in link syntax&lt;/li&gt;
&lt;li&gt;Empty link destinations&lt;/li&gt;
&lt;li&gt;External links that return 4xx/5xx or time out&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All rules are on by default and individually toggleable in &lt;code&gt;.gomarklint.yaml&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What it doesn't solve yet
&lt;/h2&gt;

&lt;p&gt;The gate catches structural and link problems reliably. It doesn't enforce prose style — things like passive voice, sentence length, or terminology consistency require a different category of tool. For those, tools like Vale fill the gap, and you can add Vale as a second step in the same job without any conflict.&lt;/p&gt;

&lt;p&gt;I'm currently looking at integrating internal anchor validation (catching &lt;code&gt;[see this](#old-anchor)&lt;/code&gt; when &lt;code&gt;old-anchor&lt;/code&gt; no longer exists after a heading rename). That's the one class of broken link this workflow still misses.&lt;/p&gt;

&lt;p&gt;What's the first markdown rule you'd want failing your CI today — structure, links, or something else?&lt;/p&gt;




&lt;h2&gt;
  
  
  Footnote: why gomarklint over markdownlint-cli2
&lt;/h2&gt;

&lt;p&gt;The other widely-used option is markdownlint-cli2, which has 50+ rules and a large community. The tradeoff is the runtime: it requires a Node.js setup step in CI (~15–20 seconds), which adds friction in non-JavaScript repos.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;markdownlint-cli2&lt;/th&gt;
&lt;th&gt;gomarklint&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Runtime&lt;/td&gt;
&lt;td&gt;Node.js required&lt;/td&gt;
&lt;td&gt;None (single binary)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Install in CI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;npm install -g markdownlint-cli2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GitHub Action or &lt;code&gt;curl&lt;/code&gt; one-liner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP link validation&lt;/td&gt;
&lt;td&gt;Separate tool needed&lt;/td&gt;
&lt;td&gt;Built in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rules&lt;/td&gt;
&lt;td&gt;50+&lt;/td&gt;
&lt;td&gt;14 structural + link validation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you already have Node.js in your pipeline, markdownlint-cli2 is a solid choice. If you don't, gomarklint avoids adding it.&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;gomarklint on GitHub&lt;/a&gt;&lt;br&gt;
→ &lt;a href="https://shinagawa-web.github.io/gomarklint/" rel="noopener noreferrer"&gt;gomarklint docs&lt;/a&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>devops</category>
      <category>markdown</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Shipping a Go CLI to Every Ecosystem: GitHub Releases, Homebrew, and npm</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 14 Apr 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/_402ccbd6e5cb02871506/shipping-a-go-cli-to-every-ecosystem-github-releases-homebrew-and-npm-5g27</link>
      <guid>https://dev.to/_402ccbd6e5cb02871506/shipping-a-go-cli-to-every-ecosystem-github-releases-homebrew-and-npm-5g27</guid>
      <description>&lt;h2&gt;
  
  
  The Problem: Great Tools Die in Obscurity
&lt;/h2&gt;

&lt;p&gt;You can build the fastest, most useful CLI tool in the world. But if installing it requires &lt;code&gt;go install&lt;/code&gt;, you've already lost 90% of your potential users.&lt;/p&gt;

&lt;p&gt;Most developers don't have Go installed. Most frontend engineers don't know what &lt;code&gt;go install&lt;/code&gt; means. And most technical writers — the people who benefit most from a Markdown linter — will close the tab the moment they see a compile step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distribution is not a feature. Distribution is survival.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the story of how I took &lt;a href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;gomarklint&lt;/a&gt; — a Markdown linter written in Go — and made it installable via three ecosystems:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# For anyone — just download and run&lt;/span&gt;
curl &lt;span class="nt"&gt;-L&lt;/span&gt; https://github.com/shinagawa-web/gomarklint/releases/latest

&lt;span class="c"&gt;# For macOS users&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;shinagawa-web/tap/gomarklint

&lt;span class="c"&gt;# For Node.js users&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @shinagawa-web/gomarklint

&lt;span class="c"&gt;# For Go developers&lt;/span&gt;
go &lt;span class="nb"&gt;install &lt;/span&gt;github.com/shinagawa-web/gomarklint@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One binary. Four installation methods. Zero runtime dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Multi-Channel Distribution Matters
&lt;/h2&gt;

&lt;p&gt;Let me share a real scenario.&lt;/p&gt;

&lt;p&gt;A technical writer on your team wants to lint Markdown docs locally. They open the README, see &lt;code&gt;go install ...&lt;/code&gt;, and immediately ask the engineering team for help. The engineer says "just install Go." The writer says "I just want to check my docs." Nothing happens.&lt;/p&gt;

&lt;p&gt;Now imagine this instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @shinagawa-web/gomarklint
gomarklint docs/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Done. No Go. No Homebrew. Just a tool they already know how to use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Each distribution channel unlocks a different audience:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Channel&lt;/th&gt;
&lt;th&gt;Audience&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Releases&lt;/td&gt;
&lt;td&gt;CI/CD pipelines, DevOps engineers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Homebrew&lt;/td&gt;
&lt;td&gt;macOS developers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;npm&lt;/td&gt;
&lt;td&gt;Frontend engineers, technical writers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;go install&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Go developers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your tool only lives in one ecosystem, you're leaving users on the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture: One Binary, Thin Wrappers
&lt;/h2&gt;

&lt;p&gt;The core insight is simple: &lt;strong&gt;don't ship your binary inside the package. Download it at install time.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's how the npm distribution works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm install -g @shinagawa-web/gomarklint
        │
        ▼
  package.json (postinstall → node install.js)
        │
        ▼
  install.js detects OS/arch
        │
        ▼
  Downloads binary from GitHub Releases
        │
        ▼
  Verifies SHA-256 checksum
        │
        ▼
  cli.js (execFileSync → gomarklint binary)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The npm package contains no binary. It's three files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;package.json&lt;/code&gt; — metadata and postinstall hook&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;install.js&lt;/code&gt; — platform detection and binary download&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cli.js&lt;/code&gt; — thin wrapper that invokes the binary&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total package size before install: &lt;strong&gt;under 5KB&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Platform Detection (install.js)
&lt;/h2&gt;

&lt;p&gt;The install script maps Node.js platform identifiers to GoReleaser archive names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PLATFORM_MAP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;darwin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Darwin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;linux&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Linux&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;win32&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Windows&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ARCH_MAP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;x64&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x86_64&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;arm64&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;arm64&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This covers the six combinations that matter: macOS (Intel + Apple Silicon), Linux (x64 + ARM), and Windows (x64 + ARM).&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;npm install&lt;/code&gt; runs, the &lt;code&gt;postinstall&lt;/code&gt; script:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reads the version from &lt;code&gt;package.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Maps &lt;code&gt;process.platform&lt;/code&gt; and &lt;code&gt;process.arch&lt;/code&gt; to archive names&lt;/li&gt;
&lt;li&gt;Downloads the checksums file and the archive in parallel&lt;/li&gt;
&lt;li&gt;Verifies the SHA-256 checksum&lt;/li&gt;
&lt;li&gt;Extracts the binary with &lt;code&gt;tar&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Sets executable permissions&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No dependencies. Just Node.js built-ins: &lt;code&gt;https&lt;/code&gt;, &lt;code&gt;crypto&lt;/code&gt;, &lt;code&gt;child_process&lt;/code&gt;, &lt;code&gt;fs&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: The CLI Wrapper (cli.js)
&lt;/h2&gt;

&lt;p&gt;The wrapper is intentionally minimal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="cp"&gt;#!/usr/bin/env node
&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use strict&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;execFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;child_process&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;path&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;win32&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.exe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;__dirname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gomarklint&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;execFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stdio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;inherit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exitCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key design decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;stdio: "inherit"&lt;/code&gt;&lt;/strong&gt; — passes through stdin/stdout/stderr, so output formatting and piping work exactly like the native binary&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exit code forwarding&lt;/strong&gt; — critical for CI usage where non-zero exit means lint failure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No abstraction&lt;/strong&gt; — the wrapper does nothing but proxy execution&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3: Supply Chain Security
&lt;/h2&gt;

&lt;p&gt;Shipping binaries through npm raises legitimate security concerns. Two mechanisms address this:&lt;/p&gt;

&lt;h3&gt;
  
  
  SHA-256 Checksum Verification
&lt;/h3&gt;

&lt;p&gt;GoReleaser generates a checksums file for every release. The install script downloads it and verifies the archive before extraction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifyChecksum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`Checksum mismatch!\n  Expected: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n  Actual:   &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If someone tampers with the binary on GitHub Releases, the install fails loudly.&lt;/p&gt;

&lt;h3&gt;
  
  
  npm Provenance
&lt;/h3&gt;

&lt;p&gt;The publish step uses &lt;code&gt;--provenance&lt;/code&gt;, which cryptographically proves the package was built from a specific GitHub Actions workflow:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Publish to npm&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;npm publish --provenance --access public&lt;/span&gt;
  &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;NODE_AUTH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.NPM_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Users can verify this with &lt;code&gt;npm audit signatures&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Automated Publishing with GoReleaser
&lt;/h2&gt;

&lt;p&gt;The entire release pipeline runs on a single trigger: pushing a version tag.&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="c1"&gt;# .github/workflows/goreleaser.yml&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v*'&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-go@v6&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;goreleaser/goreleaser-action@v7&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;release --clean&lt;/span&gt;

  &lt;span class="na"&gt;npm-publish&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;release&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;20'&lt;/span&gt;
          &lt;span class="na"&gt;registry-url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://registry.npmjs.org'&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;Set package version from tag&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;VERSION="${GITHUB_REF_NAME#v}"&lt;/span&gt;
          &lt;span class="s"&gt;node -e "&lt;/span&gt;
            &lt;span class="s"&gt;const fs = require('fs');&lt;/span&gt;
            &lt;span class="s"&gt;const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));&lt;/span&gt;
            &lt;span class="s"&gt;pkg.version = '${VERSION}';&lt;/span&gt;
            &lt;span class="s"&gt;fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');&lt;/span&gt;
          &lt;span class="s"&gt;"&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;Publish to npm&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;npm publish --provenance --access public&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;NODE_AUTH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.NPM_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The npm package version is &lt;strong&gt;never manually managed&lt;/strong&gt;. It's extracted from the git tag at publish time. &lt;code&gt;v2.7.1&lt;/code&gt; becomes npm version &lt;code&gt;2.7.1&lt;/code&gt;. Zero version drift.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;npm-publish&lt;/code&gt; job has &lt;code&gt;needs: release&lt;/code&gt;, so it only runs after GoReleaser has finished uploading all binaries. If GoReleaser fails, npm doesn't publish a broken version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: GoReleaser Configuration
&lt;/h2&gt;

&lt;p&gt;One thing I learned the hard way: if you don't explicitly specify architectures, you might be missing builds that your npm users need.&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="na"&gt;builds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CGO_ENABLED=0&lt;/span&gt;
    &lt;span class="na"&gt;goos&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;linux&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;windows&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;darwin&lt;/span&gt;
    &lt;span class="na"&gt;goarch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;amd64&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;arm64&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding explicit &lt;code&gt;arm64&lt;/code&gt; support was essential. Apple Silicon is the default for new Macs, and ARM Linux servers are increasingly common. Without this, &lt;code&gt;npm install&lt;/code&gt; would 404 on &lt;code&gt;gomarklint_Darwin_arm64.tar.gz&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha: The Checksums Filename
&lt;/h2&gt;

&lt;p&gt;Here's something that cost me a failed release.&lt;/p&gt;

&lt;p&gt;I assumed GoReleaser generates &lt;code&gt;checksums.txt&lt;/code&gt;. It doesn't. It generates &lt;code&gt;gomarklint_2.7.0_checksums.txt&lt;/code&gt; — prefixed with the project name and version.&lt;/p&gt;

&lt;p&gt;My first npm release failed with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Download failed: HTTP 404 for
  https://github.com/.../releases/download/v2.7.0/checksums.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always check your actual release assets before writing the download logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh release view v2.7.0 &lt;span class="nt"&gt;--json&lt;/span&gt; assets &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.assets[].name'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;One &lt;code&gt;git tag&lt;/code&gt; + &lt;code&gt;git push&lt;/code&gt; now triggers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;GoReleaser&lt;/strong&gt; builds binaries for 6 platform/arch combinations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Release&lt;/strong&gt; is created with all assets and checksums&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Homebrew formula&lt;/strong&gt; is updated in the tap repository&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;npm package&lt;/strong&gt; is published with provenance attestation&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Total human effort per release: &lt;strong&gt;two commands&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;git tag v2.7.1
git push origin v2.7.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Distribution is a feature.&lt;/strong&gt; The best CLI tool means nothing if people can't install it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't ship binaries in packages.&lt;/strong&gt; Download them at install time. Your npm package stays tiny, and you don't need to rebuild for every platform in npm's ecosystem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify everything.&lt;/strong&gt; SHA-256 checksums and npm provenance are table stakes for binary distribution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automate the version.&lt;/strong&gt; Extract from the git tag. Never manually sync versions across ecosystems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test with real installs.&lt;/strong&gt; &lt;code&gt;npm install -g&lt;/code&gt; in a clean environment catches things unit tests never will.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're building a Go CLI and only distributing via &lt;code&gt;go install&lt;/code&gt;, you're leaving users behind. The npm + Homebrew wrapper pattern takes an afternoon to set up and opens your tool to an entirely new audience.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Try it:&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;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @shinagawa-web/gomarklint
gomarklint &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Repository:&lt;/strong&gt; &lt;a href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;https://github.com/shinagawa-web/gomarklint&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;npm:&lt;/strong&gt; &lt;a href="https://www.npmjs.com/package/@shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/@shinagawa-web/gomarklint&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Documentation:&lt;/strong&gt; &lt;a href="https://shinagawa-web.github.io/gomarklint/" rel="noopener noreferrer"&gt;https://shinagawa-web.github.io/gomarklint/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cli</category>
      <category>github</category>
      <category>go</category>
      <category>npm</category>
    </item>
    <item>
      <title>Choosing a Markdown linter for your docs pipeline: what each tool actually covers</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 10 Mar 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/_402ccbd6e5cb02871506/i-built-a-markdown-linter-in-go-heres-how-it-stacks-up-against-markdownlint-and-remark-lint-4b6i</link>
      <guid>https://dev.to/_402ccbd6e5cb02871506/i-built-a-markdown-linter-in-go-heres-how-it-stacks-up-against-markdownlint-and-remark-lint-4b6i</guid>
      <description>&lt;p&gt;If you're setting up a documentation quality gate — for a Go service, a platform team's runbooks, or any repo where Markdown is the source of truth — you've probably hit the same decision point: four or five tools come up in search results, they all call themselves "Markdown linters," and it's genuinely unclear what each one actually catches versus what it quietly ignores.&lt;/p&gt;

&lt;p&gt;This is a practical breakdown of the major options, what each one covers, where each one stops, and which use case each one fits best.&lt;/p&gt;




&lt;h2&gt;
  
  
  The tools and what they actually do
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Language&lt;/th&gt;
&lt;th&gt;Stars&lt;/th&gt;
&lt;th&gt;Rules&lt;/th&gt;
&lt;th&gt;Nature&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;markdownlint (DavidAnson)&lt;/td&gt;
&lt;td&gt;JavaScript&lt;/td&gt;
&lt;td&gt;~5,900&lt;/td&gt;
&lt;td&gt;60 built-in&lt;/td&gt;
&lt;td&gt;Structural linter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;remark-lint&lt;/td&gt;
&lt;td&gt;JavaScript&lt;/td&gt;
&lt;td&gt;~1,000&lt;/td&gt;
&lt;td&gt;~80 packages&lt;/td&gt;
&lt;td&gt;Structural linter (AST-based)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;markdownlint (Ruby)&lt;/td&gt;
&lt;td&gt;Ruby&lt;/td&gt;
&lt;td&gt;~2,009&lt;/td&gt;
&lt;td&gt;~50&lt;/td&gt;
&lt;td&gt;Structural linter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mdformat&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;~726&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;Auto-formatter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gomarklint&lt;/td&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Structural linter + link checker&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The distinction that matters most isn't language — it's whether the tool &lt;strong&gt;reports violations&lt;/strong&gt; or &lt;strong&gt;silently rewrites files.&lt;/strong&gt; mdformat is an auto-formatter: it won't tell you a heading level is wrong; it will just fix it. If you want visibility into what's broken (for PR review, for CI blocking), you want one of the linters, not a formatter.&lt;/p&gt;




&lt;h2&gt;
  
  
  If you need the broadest rule coverage: markdownlint
&lt;/h2&gt;

&lt;p&gt;For teams that want thorough enforcement of documentation conventions — heading order, blank lines around elements, consistent list markers, trailing spaces, bare URLs — &lt;strong&gt;markdownlint (DavidAnson)&lt;/strong&gt; is the mature choice. 60 built-in rules, a VS Code extension with wide adoption, and a CLI that integrates into any CI runner.&lt;/p&gt;

&lt;p&gt;The cost: it requires Node.js. In a Go or Rust project where you've deliberately kept the toolchain lean, adding a JS runtime just for linting is a real friction point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick this if:&lt;/strong&gt; your team already runs Node.js in CI, or you need fine-grained rule configuration across many rule categories.&lt;/p&gt;




&lt;h2&gt;
  
  
  If you need AST-level extensibility: remark-lint
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;remark-lint&lt;/strong&gt; operates on the parsed AST rather than raw text, which makes it uniquely suited to writing custom rules that understand document structure — not just surface patterns. Around 80 rules available across packages. The pluggable architecture is its strength; the configuration surface area is also its steepest learning curve.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick this if:&lt;/strong&gt; you need to write project-specific rules, or you're already in the unified/remark ecosystem.&lt;/p&gt;




&lt;h2&gt;
  
  
  If you need format + dead-link validation in one binary, no runtime: gomarklint
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;gomarklint&lt;/a&gt;&lt;/strong&gt; is a Go binary with no runtime dependencies. Download it, run it. It covers structural linting and two checks the JS tools don't touch at all:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live external link validation&lt;/strong&gt; — most linters ignore whether &lt;code&gt;[see the RFC](https://...)&lt;/code&gt; actually resolves. gomarklint makes real HTTP requests, concurrently, and reports 404s and timeouts. ~2,000 links in under 10 seconds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gomarklint &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--enable-link-check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Unclosed code block detection&lt;/strong&gt; — tolerant AST parsers silently repair a missing closing fence. gomarklint uses a text-based pass that catches the raw break — the one that causes everything below it to render as code.&lt;/p&gt;

&lt;p&gt;The tradeoff is honest: 8 rules versus markdownlint's 60. If you need blanks-around-headings, no-bare-urls, or trailing-space enforcement today, gomarklint doesn't have them yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick this if:&lt;/strong&gt; your repo is Go or polyglot and you want zero runtime overhead, or dead external links and structural breaks (unclosed fences, heading skips) are your highest-priority catches.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the rules map across tools
&lt;/h2&gt;

&lt;p&gt;For teams evaluating overlap before switching or combining tools:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;gomarklint Rule&lt;/th&gt;
&lt;th&gt;markdownlint&lt;/th&gt;
&lt;th&gt;remark-lint&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;final-blank-line&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;MD047&lt;/td&gt;
&lt;td&gt;&lt;code&gt;final-newline&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;unclosed-code-block&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;— (unique)&lt;/td&gt;
&lt;td&gt;— (unique)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;empty-alt-text&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;MD045&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;heading-level&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;MD001 + MD041&lt;/td&gt;
&lt;td&gt;&lt;code&gt;heading-increment&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;duplicate-heading&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;MD024&lt;/td&gt;
&lt;td&gt;&lt;code&gt;no-duplicate-headings&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-multiple-blank-lines&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;MD012&lt;/td&gt;
&lt;td&gt;&lt;code&gt;no-consecutive-blank-lines&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;external-link&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;— (unique)&lt;/td&gt;
&lt;td&gt;— (unique)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-setext-headings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;MD003&lt;/td&gt;
&lt;td&gt;&lt;code&gt;heading-style&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;unclosed-code-block&lt;/code&gt; and &lt;code&gt;external-link&lt;/code&gt; have no equivalent in the major JS tools. Everything else in gomarklint's current set has a direct counterpart — so if you're already on markdownlint, you're not losing coverage by keeping it for the rules gomarklint doesn't yet implement.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick-reference decision guide
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Broadest rule set, VS Code integration&lt;/strong&gt; → markdownlint (DavidAnson)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom rules, AST access&lt;/strong&gt; → remark-lint&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-fix without review&lt;/strong&gt; → mdformat&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No runtime, dead-link detection, CI binary&lt;/strong&gt; → gomarklint&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maximum coverage today&lt;/strong&gt; → markdownlint + gomarklint's &lt;code&gt;--enable-link-check&lt;/code&gt; as a second pass&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Try gomarklint
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go &lt;span class="nb"&gt;install &lt;/span&gt;github.com/shinagawa-web/gomarklint@latest
gomarklint init
gomarklint &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full documentation: &lt;strong&gt;&lt;a href="https://shinagawa-web.github.io/gomarklint/" rel="noopener noreferrer"&gt;https://shinagawa-web.github.io/gomarklint/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;Which check has caught the most real issues in your docs pipeline — structural violations like heading order, or dead links that slipped through unnoticed? The answer tends to tell you which tool to reach for first.&lt;/p&gt;

</description>
      <category>go</category>
      <category>markdown</category>
      <category>opensource</category>
      <category>linter</category>
    </item>
  </channel>
</rss>
