<?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.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>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>
    <item>
      <title>Beyond Lines: Announcing "gosemdiff" – A Logic-Aware Diff Tool for Go</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Fri, 13 Feb 2026 14:50:00 +0000</pubDate>
      <link>https://dev.to/_402ccbd6e5cb02871506/beyond-lines-announcing-gosemdiff-a-logic-aware-diff-tool-for-go-403p</link>
      <guid>https://dev.to/_402ccbd6e5cb02871506/beyond-lines-announcing-gosemdiff-a-logic-aware-diff-tool-for-go-403p</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>Making Parallel HTTP Requests Stable in Go: Lessons from Building a Markdown Linter</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Thu, 15 Jan 2026 13:28:45 +0000</pubDate>
      <link>https://dev.to/_402ccbd6e5cb02871506/making-parallel-http-requests-stable-in-go-lessons-from-building-a-markdown-linter-1j36</link>
      <guid>https://dev.to/_402ccbd6e5cb02871506/making-parallel-http-requests-stable-in-go-lessons-from-building-a-markdown-linter-1j36</guid>
      <description>&lt;p&gt;When building &lt;a href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;gomarklint&lt;/a&gt;, a Go-based Markdown linter, I faced a challenge: checking 100,000+ lines of documentation for broken links. Parallelizing this with Goroutines seemed like a "no-brainer," but it immediately led to Flaky Tests in CI environments.&lt;/p&gt;

&lt;p&gt;Speed is easy in Go; stability is the real challenge. Here are the three patterns I implemented to achieve both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cache: Preventing 'Barrages'
&lt;/h2&gt;

&lt;p&gt;In a large docset, the same URL appears dozens of times. Naive concurrency sends a request for every single occurrence, which looks like a DoS attack to the host.&lt;/p&gt;

&lt;p&gt;Using sync.Map, I implemented a simple URL cache to ensure each unique URL is only checked once.&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;urlCache&lt;/span&gt; &lt;span class="n"&gt;sync&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Map&lt;/span&gt;

&lt;span class="c"&gt;// Check if we've seen this URL before&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;urlCache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;ok&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;val&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;checkResult&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Semaphore: Respecting Resource Limits
&lt;/h2&gt;

&lt;p&gt;Even with a cache, checking 1,000 unique URLs simultaneously can exhaust local file descriptors or trigger rate limits.&lt;br&gt;
I used a buffered channel as a semaphore to cap the number of active Goroutines.&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="n"&gt;maxConcurrency&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
&lt;span class="n"&gt;sem&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="k"&gt;chan&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;maxConcurrency&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;url&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;urls&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sem&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{}{}&lt;/span&gt; &lt;span class="c"&gt;// Acquire token&lt;/span&gt;
    &lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&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="k"&gt;defer&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;sem&lt;/span&gt; &lt;span class="p"&gt;}()&lt;/span&gt; &lt;span class="c"&gt;// Release token&lt;/span&gt;
        &lt;span class="n"&gt;checkURL&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="p"&gt;}(&lt;/span&gt;&lt;span class="n"&gt;url&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;
  
  
  The Retry: Tolerating Network 'Whims'
&lt;/h2&gt;

&lt;p&gt;Networks are inherently unreliable. A momentary blip shouldn't fail your entire CI build. I implemented Exponential Backoff to distinguish between permanent failures (404) and transient ones (5xx, timeouts).&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;// Only retry if it's a server error or network timeout&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;500&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retryDelay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="c"&gt;// retry...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The "Negative Caching" Trap
&lt;/h2&gt;

&lt;p&gt;The most elusive bug was caching only the status code. If a request failed with a timeout, I stored status: 0. Subsequent checks retrieved 0 but didn't know an error had occurred, leading to inconsistent logic.&lt;/p&gt;

&lt;p&gt;The Fix: Cache the entire result, including the error.&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;checkResult&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;status&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;err&lt;/span&gt;    &lt;span class="kt"&gt;error&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c"&gt;// Store the pointer to this struct in your cache&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion: Is it 100% Stable?
&lt;/h2&gt;

&lt;p&gt;Not quite. Even with these, "Cache Stampedes" (multiple Goroutines hitting the same uncached URL at the exact same millisecond) remain a concern.&lt;/p&gt;

&lt;p&gt;I'm currently exploring &lt;code&gt;golang.org/x/sync/singleflight&lt;/code&gt; to solve this. If you have experience tuning http.Client for massive parallel checks, I'd love to hear your thoughts in the comments or on &lt;a href="https://github.com/shinagawa-web/gomarklint/issues" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;!&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>go</category>
      <category>networking</category>
      <category>performance</category>
    </item>
    <item>
      <title>Catch broken links in your Markdown docs before they reach production</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 13 Jan 2026 07:49:10 +0000</pubDate>
      <link>https://dev.to/_402ccbd6e5cb02871506/super-fast-markdown-linting-for-go-developers-meet-gomarklint-3ikd</link>
      <guid>https://dev.to/_402ccbd6e5cb02871506/super-fast-markdown-linting-for-go-developers-meet-gomarklint-3ikd</guid>
      <description>&lt;p&gt;Your user clicked the "Getting Started" link in your README. They got a 404. You didn't know because the link had been broken for three weeks and no tool in your pipeline caught it.&lt;/p&gt;

&lt;p&gt;That's the problem. Dead links in Markdown documentation are silent failures — they don't break tests, don't fail builds, and don't show up in code review. They only surface when a real person hits them.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;gomarklint&lt;/a&gt; to close that gap. Here's what I learned running it across 180 Markdown files totaling 100,000+ lines.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Three Kinds of Broken Links
&lt;/h2&gt;

&lt;p&gt;Most link checkers treat all links the same. That's a mistake, because internal relative links, external HTTP links, and anchor fragment links fail for completely different reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Internal relative links&lt;/strong&gt; break when files get moved or renamed during a refactor. A link like &lt;code&gt;[setup](./docs/setup.md)&lt;/code&gt; is valid the day you write it and invalid the day someone renames the file without updating references.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;External HTTP links&lt;/strong&gt; break for reasons you don't control — a third-party API changes its URL structure, a service goes down, a library migrates its docs to a new domain. These are often the most embarrassing failures because they affect the first impression a new user has of your project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anchor fragment links&lt;/strong&gt; — links like &lt;code&gt;[see config](#configuration)&lt;/code&gt; — break silently when headings change. If someone renames "Configuration" to "Config Options," every anchor pointing to &lt;code&gt;#configuration&lt;/code&gt; is now dead, and no compiler will warn you.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gomarklint&lt;/code&gt; checks all three categories in a single pass. Enable link checking in your config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"enableLinkCheck"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"skipLinkPatterns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"localhost"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"example.com"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;skipLinkPatterns&lt;/code&gt; field lets you suppress false positives from placeholder URLs without disabling the check entirely.&lt;/p&gt;




&lt;h2&gt;
  
  
  The CI Hook: Failing the Build Before the PR Merges
&lt;/h2&gt;

&lt;p&gt;Detection only matters if it runs before code ships. A link checker you run manually is a link checker you forget to run.&lt;/p&gt;

&lt;p&gt;Here is a complete GitHub Actions workflow that checks every Markdown file on pull requests targeting &lt;code&gt;main&lt;/code&gt;:&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 Link Check&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;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&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;link-check&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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-go@v5&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;go-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.22"&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;Install gomarklint&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;go install github.com/shinagawa-web/gomarklint@latest&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;Check links&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;gomarklint --enable-link-check ./...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The total runtime across 180 files is under 50ms — faster than most network round-trips to fetch a single external URL. Because &lt;code&gt;gomarklint&lt;/code&gt; is a single compiled binary with no runtime dependency (no Node.js, no Ruby), the only meaningful CI time is the &lt;code&gt;go install&lt;/code&gt; step, which caches cleanly via &lt;code&gt;actions/setup-go&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The "Redirect Mask" Trap
&lt;/h2&gt;

&lt;p&gt;The most elusive bug I found during development: external links that return 301 or 302 redirects were being silently treated as valid.&lt;/p&gt;

&lt;p&gt;A link to &lt;code&gt;http://old-docs.example.com/api&lt;/code&gt; might redirect to &lt;code&gt;https://new-docs.example.com/api&lt;/code&gt; today. But in three months the redirect itself is removed, and your link goes from "redirects correctly" to "hard 404" overnight. The link check that passed six months ago is now wrong, and nothing told you the situation changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; treat 3xx responses as warnings, not passes. In &lt;code&gt;gomarklint&lt;/code&gt;, you can configure &lt;code&gt;followRedirects: false&lt;/code&gt; to surface these — which forces you to update the link to the final destination rather than relying on a redirect that may not be permanent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"enableLinkCheck"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"followRedirects"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is stricter than most teams want by default, but for docs you intend to maintain for years, it pays off.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Not Solved Yet
&lt;/h2&gt;

&lt;p&gt;Link checking against rate-limited external services is still genuinely hard. GitHub, npm, and PyPI all throttle rapid HEAD requests, which produces false 429 failures in CI. The current approach — exponential backoff with a configurable retry count — helps, but a long PR queue can still hit limits.&lt;/p&gt;

&lt;p&gt;The approach I'm exploring next is a two-tier strategy: fail the build on internal and anchor-fragment errors (always reliable, zero network cost), and report external link failures as warnings rather than hard errors, so they surface without blocking a merge.&lt;/p&gt;

&lt;p&gt;If you've dealt with flaky external link checks in CI — whether in this tool or another — I'd genuinely like to know what threshold or retry logic worked for your repository size.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check it out on GitHub:&lt;/strong&gt; &lt;a href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;shinagawa-web/gomarklint&lt;/a&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>performance</category>
      <category>showdev</category>
      <category>markdown</category>
    </item>
    <item>
      <title>Building a Culture of Documentation Quality in CI/CD</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 28 Oct 2025 23:55:59 +0000</pubDate>
      <link>https://dev.to/_402ccbd6e5cb02871506/building-a-culture-of-documentation-quality-in-cicd-352d</link>
      <guid>https://dev.to/_402ccbd6e5cb02871506/building-a-culture-of-documentation-quality-in-cicd-352d</guid>
      <description>&lt;h2&gt;
  
  
  Introduction: Documentation as a Cultural Signal
&lt;/h2&gt;

&lt;p&gt;In many engineering teams, documentation is treated as an afterthought—something we write when there is time, if there is time. Code reviews are systematic, CI pipelines are automated, testing culture is discussed and refined. Yet documentation, which shapes how teams share knowledge and coordinate decisions, is often left to individual discipline and goodwill.&lt;/p&gt;

&lt;p&gt;But documentation is not just a collection of text files. It is a cultural signal.&lt;/p&gt;

&lt;p&gt;How teams write (and maintain) documentation communicates what they value:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Whether knowledge is shared or siloed&lt;/li&gt;
&lt;li&gt;Whether onboarding is a guided process or a form of survival&lt;/li&gt;
&lt;li&gt;Whether decisions are remembered or rediscovered&lt;/li&gt;
&lt;li&gt;Whether the product’s reality matches its documentation—or not&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When documentation decays, trust decays with it. And trust is expensive to regain.&lt;/p&gt;

&lt;p&gt;The challenge, then, is not simply “writing more documentation,” but building a culture where documentation is treated as a part of the product itself—reviewed, maintained, and continuously improved alongside code.&lt;/p&gt;

&lt;p&gt;This is where treating documentation quality as a first-class citizen in CI/CD pipelines becomes transformative.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Broken Links Become Broken Trust
&lt;/h2&gt;

&lt;p&gt;Documentation is more than a reference—it is a promise.&lt;/p&gt;

&lt;p&gt;When we publish architecture diagrams, onboarding guides, operational runbooks, or product specifications, we are making a statement to our teammates:&lt;br&gt;
“This is how the system works. You can rely on this.”&lt;/p&gt;

&lt;p&gt;So what happens when a link is broken?&lt;br&gt;
Or when a README references outdated behavior?&lt;br&gt;
Or when a guide includes instructions that no longer reflect reality?&lt;/p&gt;

&lt;p&gt;People stop trusting the documentation.&lt;/p&gt;

&lt;p&gt;And once trust is gone, even perfectly correct documentation will be questioned.&lt;br&gt;
Teams start asking around instead of reading.&lt;br&gt;
Knowledge becomes interpersonal again.&lt;br&gt;
The organization quietly shifts from a shared knowledge model to a tribal knowledge model.&lt;/p&gt;

&lt;p&gt;This has real costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Onboarding slows down&lt;/li&gt;
&lt;li&gt;Decision-making becomes opaque&lt;/li&gt;
&lt;li&gt;Past lessons are lost and re-learned&lt;/li&gt;
&lt;li&gt;Review and collaboration rely on memory instead of artifacts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is why documentation quality is not a matter of neatness.&lt;br&gt;
It is a matter of organizational reliability.&lt;/p&gt;

&lt;p&gt;Broken links are not just broken links.&lt;br&gt;
Broken links are broken trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Documentation is Hard to Keep Healthy
&lt;/h2&gt;

&lt;p&gt;If we agree that documentation matters, the next question is:&lt;br&gt;
Why is it so hard to keep documentation healthy?&lt;/p&gt;

&lt;p&gt;Unlike code, documentation often has no single owner.&lt;br&gt;
It sits in a gray area between product, engineering, design, support, and operations.&lt;br&gt;
Everyone uses it.&lt;br&gt;
Everyone benefits from it.&lt;br&gt;
But no one is explicitly responsible for maintaining it.&lt;/p&gt;

&lt;p&gt;And unlike code, documentation does not fail loudly.&lt;br&gt;
There is no compiler complaining.&lt;br&gt;
No CI job turning red.&lt;br&gt;
No stack trace when the meaning shifts or when a sentence becomes outdated.&lt;/p&gt;

&lt;p&gt;Documentation decays silently.&lt;/p&gt;

&lt;p&gt;Because of this, teams tend to rely on good intentions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“Someone will update it.”&lt;/li&gt;
&lt;li&gt;“We’ll fix it during the next sprint.”&lt;/li&gt;
&lt;li&gt;“We’ll remember this decision anyway.”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But people don’t remember.&lt;br&gt;
Teams grow. People leave. Systems evolve.&lt;/p&gt;

&lt;p&gt;Without intentional mechanisms for maintenance, documentation inevitably drifts away from reality.&lt;/p&gt;

&lt;p&gt;The challenge is not that teams don’t care.&lt;br&gt;
The challenge is that documentation lacks the same structural safeguards that protect code quality.&lt;/p&gt;

&lt;p&gt;To keep documentation healthy, we need systems—not just effort.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat Documentation Like Code: Enter Linting
&lt;/h2&gt;

&lt;p&gt;If documentation decays because it lacks structural safeguards,&lt;br&gt;
then the solution is not “more effort,” but better infrastructure.&lt;/p&gt;

&lt;p&gt;We don’t maintain code quality through motivation or goodwill.&lt;br&gt;
We maintain it through systems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Code reviews&lt;/li&gt;
&lt;li&gt;Static analysis&lt;/li&gt;
&lt;li&gt;Typed contracts&lt;/li&gt;
&lt;li&gt;CI pipelines&lt;/li&gt;
&lt;li&gt;Test suites&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These mechanisms don’t replace human judgment—they support it.&lt;br&gt;
They make the healthy behavior the default behavior.&lt;/p&gt;

&lt;p&gt;We can apply the same thinking to documentation.&lt;/p&gt;

&lt;p&gt;If we want our documentation to stay reliable,&lt;br&gt;
we need tools that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Detect inconsistencies early&lt;/li&gt;
&lt;li&gt;Make issues visible, not silent&lt;/li&gt;
&lt;li&gt;Encourage correction at the moment of change&lt;/li&gt;
&lt;li&gt;Keep shared language stable across contributors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where &lt;strong&gt;documentation linting&lt;/strong&gt; comes in.&lt;/p&gt;

&lt;p&gt;Linting documentation is not about enforcing perfection.&lt;br&gt;
It’s about protecting shared understanding.&lt;/p&gt;

&lt;p&gt;It allows teams to shift from&lt;br&gt;
“we update documentation when we remember”&lt;br&gt;
to&lt;br&gt;
“documentation accuracy is continuously verified.”&lt;/p&gt;

&lt;p&gt;Not tighter control.&lt;br&gt;
Just better support for the culture we want to create.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introducing gomarklint: A Practical Linter for Markdown
&lt;/h2&gt;

&lt;p&gt;When we introduced linting into our documentation workflow, we didn’t want a tool that enforced aesthetics, personal style, or formatting fads.&lt;br&gt;
We wanted something simpler and more fundamental:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A tool that helps teams keep documentation trustworthy.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This led to gomarklint — a Markdown linter designed to protect the structural integrity of documentation, without dictating how people should write.&lt;/p&gt;

&lt;p&gt;gomarklint focuses on things that directly affect shared understanding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Detecting broken or missing links&lt;/li&gt;
&lt;li&gt;Ensuring heading levels follow a coherent structure&lt;/li&gt;
&lt;li&gt;Highlighting ambiguous references or placeholder text&lt;/li&gt;
&lt;li&gt;Surfacing subtle inconsistencies that accumulate over time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In other words, it focuses on the parts of documentation that erode trust when they decay.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It is intentionally minimal:&lt;/li&gt;
&lt;li&gt;No heavy configuration&lt;/li&gt;
&lt;li&gt;No stylistic judgment&lt;/li&gt;
&lt;li&gt;No unnecessary rules&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Just the core checks that preserve reliability and continuity across contributors.&lt;/p&gt;

&lt;p&gt;This matters because a tool influences culture.&lt;br&gt;
If the tool is heavy, people avoid using it.&lt;br&gt;
If it is lightweight and aligned with purpose, it becomes part of everyday practice.&lt;/p&gt;

&lt;p&gt;gomarklint was built to be that kind of tool:&lt;br&gt;
Not restrictive.&lt;br&gt;
Just supportive.&lt;br&gt;
Not opinionated.&lt;br&gt;
Just reliable.&lt;/p&gt;

&lt;p&gt;A small guardrail that keeps the path clear.&lt;/p&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;/p&gt;

&lt;h2&gt;
  
  
  How Our Review Culture Changed After Adoption
&lt;/h2&gt;

&lt;p&gt;When we added documentation linting to our workflow, the most meaningful change was not the number of issues caught.&lt;br&gt;
It was the shift in how the team talked about documentation.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Documentation updates were “nice to have”&lt;/li&gt;
&lt;li&gt;Reviewers focused almost entirely on code&lt;/li&gt;
&lt;li&gt;Outdated sections were noticed only when they caused confusion&lt;/li&gt;
&lt;li&gt;Small inconsistencies were tolerated because fixing them felt optional&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After introducing gomarklint:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Documentation changes became part of the review conversation&lt;/li&gt;
&lt;li&gt;Reviewers felt permission to point out unclear naming, missing references, or outdated instructions&lt;/li&gt;
&lt;li&gt;Updating documentation became a natural part of making changes—not an afterthought&lt;/li&gt;
&lt;li&gt;The threshold for “good enough” rose, but without adding pressure or judgment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The presence of linting changed the default expectation:&lt;br&gt;
Documentation should reflect reality.&lt;/p&gt;

&lt;p&gt;And because the tool handles detection, the emotional weight disappears.&lt;br&gt;
Feedback shifted from:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Why didn’t you update this?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;to&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“The linter caught this—let’s align it.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This matters.&lt;br&gt;
Not because it catches errors, but because it reframes ownership:&lt;br&gt;
Documentation is something the team maintains, together.&lt;/p&gt;

&lt;p&gt;Not heroic effort.&lt;br&gt;
Not personal responsibility.&lt;br&gt;
Just shared care, supported by lightweight automation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion: Documentation Quality is Organizational Health
&lt;/h2&gt;

&lt;p&gt;Documentation is not just a record of what we build.&lt;br&gt;
It is a reflection of &lt;strong&gt;how we work&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When documentation is reliable, knowledge is shared.&lt;br&gt;
Onboarding is smooth.&lt;br&gt;
Decisions have continuity.&lt;br&gt;
Teams move with confidence.&lt;/p&gt;

&lt;p&gt;When it decays, the opposite happens—slowly, quietly, and expensively.&lt;/p&gt;

&lt;p&gt;Treating documentation as part of the product is not about increasing process or control.&lt;br&gt;
It is about supporting the culture we want to have:&lt;/p&gt;

&lt;p&gt;A culture where knowledge is shared.&lt;br&gt;
Where change is understood.&lt;br&gt;
Where trust is maintained.&lt;/p&gt;

&lt;p&gt;Linting is not the solution by itself.&lt;br&gt;
But it is a small, powerful guardrail—one that keeps our shared understanding aligned with reality.&lt;/p&gt;

&lt;p&gt;Documentation quality is not a matter of neatness.&lt;br&gt;
It is a matter of organizational health.&lt;/p&gt;

&lt;p&gt;And when we care for it intentionally,&lt;br&gt;
the entire team feels the difference.&lt;/p&gt;




&lt;p&gt;If you'd like to explore the tool mentioned in this article:&lt;/p&gt;

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

</description>
      <category>markdown</category>
      <category>cicd</category>
      <category>documentation</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Inside gomarklint: Architecture, Rule Engine, and How to Extend It</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Mon, 13 Oct 2025 00:00:13 +0000</pubDate>
      <link>https://dev.to/_402ccbd6e5cb02871506/inside-gomarklint-architecture-rule-engine-and-how-to-extend-it-1377</link>
      <guid>https://dev.to/_402ccbd6e5cb02871506/inside-gomarklint-architecture-rule-engine-and-how-to-extend-it-1377</guid>
      <description>&lt;p&gt;When I released gomarklint, most people asked the same two questions:&lt;br&gt;
"How does it run so fast?" and "Can I add my own lint rules?"&lt;/p&gt;

&lt;p&gt;In this post, we'll dive into the internals of gomarklint — how it parses Markdown efficiently, how rules are defined and executed, and how you can extend it to fit your own documentation style guide.&lt;br&gt;
If you're a Go developer interested in building your own linter or contributing to gomarklint, this article is your roadmap inside the engine.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/shinagawa-web" rel="noopener noreferrer"&gt;
        shinagawa-web
      &lt;/a&gt; / &lt;a href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;
        gomarklint
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Catch broken links before your readers do.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;gomarklint&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/shinagawa-web/gomarklint/actions/workflows/test.yml/badge.svg"&gt;&lt;img src="https://github.com/shinagawa-web/gomarklint/actions/workflows/test.yml/badge.svg" alt="Test"&gt;&lt;/a&gt;
&lt;a href="https://codecov.io/gh/shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/3a62ea1c605d28ca8b76a989447c82d628955be97440820aa775fdaf031acd42/68747470733a2f2f636f6465636f762e696f2f67682f7368696e61676177612d7765622f676f6d61726b6c696e742f67726170682f62616467652e7376673f746f6b656e3d354d4743595a5a593753" alt="codecov"&gt;&lt;/a&gt;
&lt;a href="https://goreportcard.com/report/github.com/shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/5cc9b8570278e51d95dd927720684afeddb2594c78ae005a0886b29b2ca15039/68747470733a2f2f676f7265706f7274636172642e636f6d2f62616467652f6769746875622e636f6d2f7368696e61676177612d7765622f676f6d61726b6c696e74" alt="Go Report Card"&gt;&lt;/a&gt;
&lt;a href="https://pkg.go.dev/github.com/shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/6dfb8608e07cc6f5d15a43cf57ad1bcca3ab07f55909cc6b48380c79b1b4d277/68747470733a2f2f706b672e676f2e6465762f62616467652f6769746875622e636f6d2f7368696e61676177612d7765622f676f6d61726b6c696e742e737667" alt="Go Reference"&gt;&lt;/a&gt;
&lt;a href="https://github.com/shinagawa-web/gomarklint/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/fdf2982b9f5d7489dcf44570e714e3a15fce6253e0cc6b5aa61a075aac2ff71b/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4d49542d79656c6c6f772e737667" alt="License: MIT"&gt;&lt;/a&gt;
&lt;a href="https://securityscorecards.dev/viewer/?uri=github.com/shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/aa4a07c35236d812f2e7f06eded94a41cfedf3d96fce7c9a3264c1a861e4b7d4/68747470733a2f2f6170692e736563757269747973636f726563617264732e6465762f70726f6a656374732f6769746875622e636f6d2f7368696e61676177612d7765622f676f6d61726b6c696e742f6261646765" alt="OpenSSF Scorecard"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;English | &lt;a href="https://github.com/shinagawa-web/gomarklint/README.ja.md" rel="noopener noreferrer"&gt;日本語&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://gyazo.com/a5f8265a0865e5a37dc83733ca61069a" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/dc8248bad2ade7d64564ab378668adadad6a93db71abfd38ab080018193b2c4a/68747470733a2f2f692e6779617a6f2e636f6d2f61356638323635613038363565356133376463383337333363613631303639612e676966" width="800" alt="Demo"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Blazing-fast Markdown linter built in Go — &lt;strong&gt;100,000+ lines in ~170ms&lt;/strong&gt;. Single binary, no Node.js required, and built-in HTTP link validation.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;Quick install&lt;/strong&gt; (macOS / Linux):&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;curl -fsSL https://raw.githubusercontent.com/shinagawa-web/gomarklint/main/install.sh &lt;span class="pl-k"&gt;|&lt;/span&gt; sh&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Download binary&lt;/strong&gt; (no Go required):&lt;/p&gt;
&lt;p&gt;Download the latest binary for your platform from &lt;a href="https://github.com/shinagawa-web/gomarklint/releases/latest" rel="noopener noreferrer"&gt;GitHub Releases&lt;/a&gt;.&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; macOS / Linux&lt;/span&gt;
tar -xzf gomarklint_Darwin_x86_64.tar.gz
sudo mv gomarklint /usr/local/bin/
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; or install to user-local directory (no sudo required)&lt;/span&gt;
mkdir -p &lt;span class="pl-k"&gt;~&lt;/span&gt;/.local/bin &lt;span class="pl-k"&gt;&amp;amp;&amp;amp;&lt;/span&gt; mv gomarklint &lt;span class="pl-k"&gt;~&lt;/span&gt;/.local/bin/&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="highlight highlight-source-powershell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Windows (PowerShell)&lt;/span&gt;
&lt;span class="pl-c1"&gt;Expand-Archive&lt;/span&gt; &lt;span class="pl-k"&gt;-&lt;/span&gt;Path gomarklint_Windows_x86_64.zip &lt;span class="pl-k"&gt;-&lt;/span&gt;DestinationPath &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$&lt;span class="pl-c1"&gt;env:&lt;/span&gt;LOCALAPPDATA&lt;/span&gt;\Programs\gomarklint&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Add to PATH (run once)&lt;/span&gt;
[&lt;span class="pl-k"&gt;Environment&lt;/span&gt;]::SetEnvironmentVariable(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;PATH&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-k"&gt;,&lt;/span&gt; &lt;span class="pl-smi"&gt;$&lt;span class="pl-c1"&gt;env:&lt;/span&gt;PATH&lt;/span&gt; &lt;span class="pl-k"&gt;+&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;;&lt;span class="pl-smi"&gt;$&lt;span class="pl-c1"&gt;env:&lt;/span&gt;LOCALAPPDATA&lt;/span&gt;\Programs\gomarklint&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-k"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;User&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;)&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Via Homebrew:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;brew install shinagawa-web/tap/gomarklint&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Via npm:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;npm install -g @shinagawa-web/gomarklint&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Via &lt;code&gt;go install&lt;/code&gt;:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;go install github.com/shinagawa-web/gomarklint/v3@latest&lt;/pre&gt;

&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;100,000+ lines in ~170ms&lt;/strong&gt; — single binary…&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  Introduction: Why Architecture Matters
&lt;/h2&gt;

&lt;p&gt;When I released gomarklint, most people asked me the same two questions:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"How does it run so fast?"&lt;br&gt;
"Can I add my own lint rules?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Speed and simplicity don't happen by accident — they come from design.&lt;br&gt;
gomarklint was built around three principles:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Performance first: it should scan tens of thousands of lines in milliseconds.&lt;/li&gt;
&lt;li&gt;Simplicity: configuration and execution must stay minimal.&lt;/li&gt;
&lt;li&gt;Extensibility: rules should be easy to read, add, and maintain.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In this post, I'll open the hood of gomarklint — showing how it parses Markdown efficiently, how rules are executed, and how you can extend it to match your documentation style guide.&lt;/p&gt;
&lt;h2&gt;
  
  
  Parsing Markdown Efficiently
&lt;/h2&gt;

&lt;p&gt;At the heart of gomarklint is an extremely lightweight file parser.&lt;br&gt;
It doesn't try to build a full Markdown AST. Instead, it performs structured text scanning that's optimized for speed and memory locality.&lt;/p&gt;

&lt;p&gt;The typical flow looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;File Discovery: gomarklint walks through your repository recursively, ignoring hidden directories (.git, .vscode) and symbolic links.&lt;/li&gt;
&lt;li&gt;Line Reader: files are streamed line-by-line instead of loaded entirely into memory.&lt;/li&gt;
&lt;li&gt;Frontmatter Skipping: YAML frontmatters (--- ... ---) are automatically ignored to prevent false positives.&lt;/li&gt;
&lt;li&gt;Rule Evaluation: each active rule receives a slice of lines and emits lint errors.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This minimal I/O footprint lets gomarklint handle 50,000+ lines in under 50ms — even on modest CI runners.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Rule Engine: Anatomy of a Lint Rule
&lt;/h2&gt;

&lt;p&gt;Rules in gomarklint are intentionally simple.&lt;br&gt;
Each rule is just a Go function that accepts file content and returns a list of &lt;code&gt;LintError&lt;/code&gt;s:&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;LintError&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;File&lt;/span&gt;    &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Line&lt;/span&gt;    &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;Message&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;type&lt;/span&gt; &lt;span class="n"&gt;RuleFunc&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filePath&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="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;LintError&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;A small example — detecting duplicate headings:&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;CheckDuplicateHeadings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filePath&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="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="n"&gt;seen&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="k"&gt;map&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="kt"&gt;int&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="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;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;line&lt;/span&gt;&lt;span class="p"&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;if&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;seen&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;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;errs&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;errs&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="n"&gt;File&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;filePath&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="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;Message&lt;/span&gt;&lt;span class="o"&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;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"duplicate heading: %s"&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="p"&gt;})&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;seen&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;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;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;This pattern repeats across all built-in rules — headings, code blocks, blank lines, and more.&lt;br&gt;
gomarklint aggregates rule results concurrently, merges them, sorts by file/line, and prints them in a deterministic order.&lt;/p&gt;
&lt;h2&gt;
  
  
  Adding Your Own Rules
&lt;/h2&gt;

&lt;p&gt;Creating a custom rule is straightforward.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a new file under &lt;code&gt;internal/rule/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Implement your rule function — for example, forbid "TODO" comments:
&lt;/li&gt;
&lt;/ol&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;NoTODO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filePath&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="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="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;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;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"TODO"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;errs&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;errs&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="n"&gt;File&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;filePath&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="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;Message&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"found TODO comment"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;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;ol&gt;
&lt;li&gt;Register it in the rule set (usually in &lt;code&gt;rule/register.go&lt;/code&gt;):
&lt;/li&gt;
&lt;/ol&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;Rules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;RuleFunc&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;CheckDuplicateHeadings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;NoTODO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c"&gt;// ...other rules&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;Rebuild and test:
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Run all tests&lt;/span&gt;
go &lt;span class="nb"&gt;test&lt;/span&gt; ./...

&lt;span class="c"&gt;# Show CLI help from local source&lt;/span&gt;
go run &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--help&lt;/span&gt;

&lt;span class="c"&gt;# Generate a default .gomarklint.json (from your local build)&lt;/span&gt;
go run &lt;span class="nb"&gt;.&lt;/span&gt; init

&lt;span class="c"&gt;# Lint the included sample files in ./testdata&lt;/span&gt;
go run &lt;span class="nb"&gt;.&lt;/span&gt; testdata
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;You can immediately see your new rule in action.&lt;br&gt;
If it's useful for others, open a Pull Request — gomarklint's rule library welcomes contributions.&lt;/p&gt;
&lt;h2&gt;
  
  
  Configuration and Extensibility
&lt;/h2&gt;

&lt;p&gt;All configuration lives in .gomarklint.json, allowing teams to share consistent rules:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ignore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"CHANGELOG.md"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"CheckDuplicateHeadings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"NoTODO"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ExternalLinks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Each rule can be toggled on/off, and some accept parameters (like link checking patterns or minimum heading depth).&lt;/p&gt;

&lt;p&gt;Future versions of gomarklint will likely support pluggable external rules, where Go plugins or YAML-based configuration can load custom logic at runtime — without forking the repo.&lt;/p&gt;
&lt;h2&gt;
  
  
  Lessons from Designing for Extensibility
&lt;/h2&gt;

&lt;p&gt;Building gomarklint taught me that simplicity scales better than abstraction.&lt;/p&gt;

&lt;p&gt;Many linters become slower or harder to extend as they grow, because every rule depends on a shared complex framework.&lt;br&gt;
gomarklint took the opposite path — keep each rule isolated, independent, and stateless.&lt;/p&gt;

&lt;p&gt;That design choice allowed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Linear scalability with repository size&lt;/li&gt;
&lt;li&gt;Easier testing and debugging&lt;/li&gt;
&lt;li&gt;Low entry barrier for contributors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Extensibility doesn't always mean adding layers.&lt;br&gt;
Sometimes, it's about not adding them.&lt;/p&gt;
&lt;h2&gt;
  
  
  Conclusion: Build Your Own Linter, or Improve This One
&lt;/h2&gt;

&lt;p&gt;gomarklint isn't just a tool — it's a lightweight foundation for building your own Markdown analysis workflows.&lt;/p&gt;

&lt;p&gt;Whether you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;want to enforce internal documentation standards,&lt;/li&gt;
&lt;li&gt;prototype your own linter ideas, or&lt;/li&gt;
&lt;li&gt;contribute a new rule back to the community —&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;the architecture is open, simple, and waiting for you.&lt;/p&gt;

&lt;p&gt;Repository:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/shinagawa-web" rel="noopener noreferrer"&gt;
        shinagawa-web
      &lt;/a&gt; / &lt;a href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;
        gomarklint
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Catch broken links before your readers do.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;gomarklint&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/shinagawa-web/gomarklint/actions/workflows/test.yml/badge.svg"&gt;&lt;img src="https://github.com/shinagawa-web/gomarklint/actions/workflows/test.yml/badge.svg" alt="Test"&gt;&lt;/a&gt;
&lt;a href="https://codecov.io/gh/shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/3a62ea1c605d28ca8b76a989447c82d628955be97440820aa775fdaf031acd42/68747470733a2f2f636f6465636f762e696f2f67682f7368696e61676177612d7765622f676f6d61726b6c696e742f67726170682f62616467652e7376673f746f6b656e3d354d4743595a5a593753" alt="codecov"&gt;&lt;/a&gt;
&lt;a href="https://goreportcard.com/report/github.com/shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/5cc9b8570278e51d95dd927720684afeddb2594c78ae005a0886b29b2ca15039/68747470733a2f2f676f7265706f7274636172642e636f6d2f62616467652f6769746875622e636f6d2f7368696e61676177612d7765622f676f6d61726b6c696e74" alt="Go Report Card"&gt;&lt;/a&gt;
&lt;a href="https://pkg.go.dev/github.com/shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/6dfb8608e07cc6f5d15a43cf57ad1bcca3ab07f55909cc6b48380c79b1b4d277/68747470733a2f2f706b672e676f2e6465762f62616467652f6769746875622e636f6d2f7368696e61676177612d7765622f676f6d61726b6c696e742e737667" alt="Go Reference"&gt;&lt;/a&gt;
&lt;a href="https://github.com/shinagawa-web/gomarklint/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/fdf2982b9f5d7489dcf44570e714e3a15fce6253e0cc6b5aa61a075aac2ff71b/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4d49542d79656c6c6f772e737667" alt="License: MIT"&gt;&lt;/a&gt;
&lt;a href="https://securityscorecards.dev/viewer/?uri=github.com/shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/aa4a07c35236d812f2e7f06eded94a41cfedf3d96fce7c9a3264c1a861e4b7d4/68747470733a2f2f6170692e736563757269747973636f726563617264732e6465762f70726f6a656374732f6769746875622e636f6d2f7368696e61676177612d7765622f676f6d61726b6c696e742f6261646765" alt="OpenSSF Scorecard"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;English | &lt;a href="https://github.com/shinagawa-web/gomarklint/README.ja.md" rel="noopener noreferrer"&gt;日本語&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://gyazo.com/a5f8265a0865e5a37dc83733ca61069a" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/dc8248bad2ade7d64564ab378668adadad6a93db71abfd38ab080018193b2c4a/68747470733a2f2f692e6779617a6f2e636f6d2f61356638323635613038363565356133376463383337333363613631303639612e676966" width="800" alt="Demo"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Blazing-fast Markdown linter built in Go — &lt;strong&gt;100,000+ lines in ~170ms&lt;/strong&gt;. Single binary, no Node.js required, and built-in HTTP link validation.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;Quick install&lt;/strong&gt; (macOS / Linux):&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;curl -fsSL https://raw.githubusercontent.com/shinagawa-web/gomarklint/main/install.sh &lt;span class="pl-k"&gt;|&lt;/span&gt; sh&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Download binary&lt;/strong&gt; (no Go required):&lt;/p&gt;
&lt;p&gt;Download the latest binary for your platform from &lt;a href="https://github.com/shinagawa-web/gomarklint/releases/latest" rel="noopener noreferrer"&gt;GitHub Releases&lt;/a&gt;.&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; macOS / Linux&lt;/span&gt;
tar -xzf gomarklint_Darwin_x86_64.tar.gz
sudo mv gomarklint /usr/local/bin/
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; or install to user-local directory (no sudo required)&lt;/span&gt;
mkdir -p &lt;span class="pl-k"&gt;~&lt;/span&gt;/.local/bin &lt;span class="pl-k"&gt;&amp;amp;&amp;amp;&lt;/span&gt; mv gomarklint &lt;span class="pl-k"&gt;~&lt;/span&gt;/.local/bin/&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="highlight highlight-source-powershell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Windows (PowerShell)&lt;/span&gt;
&lt;span class="pl-c1"&gt;Expand-Archive&lt;/span&gt; &lt;span class="pl-k"&gt;-&lt;/span&gt;Path gomarklint_Windows_x86_64.zip &lt;span class="pl-k"&gt;-&lt;/span&gt;DestinationPath &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$&lt;span class="pl-c1"&gt;env:&lt;/span&gt;LOCALAPPDATA&lt;/span&gt;\Programs\gomarklint&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Add to PATH (run once)&lt;/span&gt;
[&lt;span class="pl-k"&gt;Environment&lt;/span&gt;]::SetEnvironmentVariable(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;PATH&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-k"&gt;,&lt;/span&gt; &lt;span class="pl-smi"&gt;$&lt;span class="pl-c1"&gt;env:&lt;/span&gt;PATH&lt;/span&gt; &lt;span class="pl-k"&gt;+&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;;&lt;span class="pl-smi"&gt;$&lt;span class="pl-c1"&gt;env:&lt;/span&gt;LOCALAPPDATA&lt;/span&gt;\Programs\gomarklint&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-k"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;User&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;)&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Via Homebrew:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;brew install shinagawa-web/tap/gomarklint&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Via npm:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;npm install -g @shinagawa-web/gomarklint&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Via &lt;code&gt;go install&lt;/code&gt;:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;go install github.com/shinagawa-web/gomarklint/v3@latest&lt;/pre&gt;

&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;100,000+ lines in ~170ms&lt;/strong&gt; — single binary…&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>programming</category>
      <category>go</category>
      <category>markdown</category>
    </item>
    <item>
      <title>Inside gomarklint: Building a High-Performance Markdown Linter in Go</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Sat, 09 Aug 2025 03:56:27 +0000</pubDate>
      <link>https://dev.to/_402ccbd6e5cb02871506/inside-gomarklint-building-a-high-performance-markdown-linter-in-go-2am1</link>
      <guid>https://dev.to/_402ccbd6e5cb02871506/inside-gomarklint-building-a-high-performance-markdown-linter-in-go-2am1</guid>
      <description>&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/shinagawa-web" rel="noopener noreferrer"&gt;
        shinagawa-web
      &lt;/a&gt; / &lt;a href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;
        gomarklint
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A fast and configurable Markdown linter written in Go
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;gomarklint&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/shinagawa-web/gomarklint/actions/workflows/test.yml/badge.svg"&gt;&lt;img src="https://github.com/shinagawa-web/gomarklint/actions/workflows/test.yml/badge.svg" alt="Test"&gt;&lt;/a&gt;
&lt;a href="https://codecov.io/gh/shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/3a62ea1c605d28ca8b76a989447c82d628955be97440820aa775fdaf031acd42/68747470733a2f2f636f6465636f762e696f2f67682f7368696e61676177612d7765622f676f6d61726b6c696e742f67726170682f62616467652e7376673f746f6b656e3d354d4743595a5a593753" alt="codecov"&gt;&lt;/a&gt;
&lt;a href="https://goreportcard.com/report/github.com/shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/5cc9b8570278e51d95dd927720684afeddb2594c78ae005a0886b29b2ca15039/68747470733a2f2f676f7265706f7274636172642e636f6d2f62616467652f6769746875622e636f6d2f7368696e61676177612d7765622f676f6d61726b6c696e74" alt="Go Report Card"&gt;&lt;/a&gt;
&lt;a href="https://pkg.go.dev/github.com/shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/6dfb8608e07cc6f5d15a43cf57ad1bcca3ab07f55909cc6b48380c79b1b4d277/68747470733a2f2f706b672e676f2e6465762f62616467652f6769746875622e636f6d2f7368696e61676177612d7765622f676f6d61726b6c696e742e737667" alt="Go Reference"&gt;&lt;/a&gt;
&lt;a href="https://github.com/shinagawa-web/gomarklint/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/fdf2982b9f5d7489dcf44570e714e3a15fce6253e0cc6b5aa61a075aac2ff71b/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4d49542d79656c6c6f772e737667" alt="License: MIT"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;English | &lt;a href="https://github.com/shinagawa-web/gomarklint/README.ja.md" rel="noopener noreferrer"&gt;日本語&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A fast, opinionated Markdown linter for engineering teams. Built in Go, designed for CI.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;Download binary&lt;/strong&gt; (no Go required):&lt;/p&gt;
&lt;p&gt;Download the latest binary for your platform from &lt;a href="https://github.com/shinagawa-web/gomarklint/releases/latest" rel="noopener noreferrer"&gt;GitHub Releases&lt;/a&gt;.&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; macOS / Linux&lt;/span&gt;
tar -xzf gomarklint_Darwin_x86_64.tar.gz
sudo mv gomarklint /usr/local/bin/
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; or install to user-local directory (no sudo required)&lt;/span&gt;
mkdir -p &lt;span class="pl-k"&gt;~&lt;/span&gt;/.local/bin &lt;span class="pl-k"&gt;&amp;amp;&amp;amp;&lt;/span&gt; mv gomarklint &lt;span class="pl-k"&gt;~&lt;/span&gt;/.local/bin/&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="highlight highlight-source-powershell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Windows (PowerShell)&lt;/span&gt;
&lt;span class="pl-c1"&gt;Expand-Archive&lt;/span&gt; &lt;span class="pl-k"&gt;-&lt;/span&gt;Path gomarklint_Windows_x86_64.zip &lt;span class="pl-k"&gt;-&lt;/span&gt;DestinationPath &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$&lt;span class="pl-c1"&gt;env:&lt;/span&gt;LOCALAPPDATA&lt;/span&gt;\Programs\gomarklint&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Add to PATH (run once)&lt;/span&gt;
[&lt;span class="pl-k"&gt;Environment&lt;/span&gt;]::SetEnvironmentVariable(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;PATH&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-k"&gt;,&lt;/span&gt; &lt;span class="pl-smi"&gt;$&lt;span class="pl-c1"&gt;env:&lt;/span&gt;PATH&lt;/span&gt; &lt;span class="pl-k"&gt;+&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;;&lt;span class="pl-smi"&gt;$&lt;span class="pl-c1"&gt;env:&lt;/span&gt;LOCALAPPDATA&lt;/span&gt;\Programs\gomarklint&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-k"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;User&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;)&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Via &lt;code&gt;go install&lt;/code&gt;:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;go install github.com/shinagawa-web/gomarklint@latest&lt;/pre&gt;

&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;Catch broken links and headings before your docs ship.&lt;/li&gt;
&lt;li&gt;Enforce predictable structure (no more "why is this H4 under H2?").&lt;/li&gt;
&lt;li&gt;Output that's friendly for both humans and machines (JSON).&lt;/li&gt;
&lt;li&gt;Process &lt;strong&gt;100,000+ lines in ~170ms&lt;/strong&gt; — fast…&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h1&gt;
  
  
  Intro
&lt;/h1&gt;

&lt;p&gt;Most Markdown linters focus on correctness, but often at the expense of speed, flexibility, or ease of integration. When you’re working in a large repository with thousands of Markdown files, even a small slowdown in linting can make every CI run feel sluggish.&lt;/p&gt;

&lt;p&gt;gomarklint is my answer to that problem — a fast, minimal, CI-friendly Markdown linter written in Go. It checks for common issues like inconsistent heading levels, duplicate headings, unclosed code blocks, and optional external link validation. It’s designed to scan tens of thousands of lines in under 50ms, while staying lightweight enough to configure in seconds.&lt;/p&gt;

&lt;p&gt;In this article, we’ll go under the hood of gomarklint: how it’s structured, how it parses Markdown efficiently, how the rules engine works, and which optimizations make it fast. But before we dive into the internals, let’s take a quick look at how to use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Start — Using gomarklint
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Install
&lt;/h3&gt;

&lt;p&gt;You can install via Go:&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/gomarklint@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Initialize Config
&lt;/h3&gt;

&lt;p&gt;This creates a &lt;code&gt;.gomarklint.json&lt;/code&gt; file with default rules and file patterns.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gomarklint init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Run Linting
&lt;/h3&gt;

&lt;p&gt;By default, it checks for heading consistency, duplicate headings, unclosed code blocks, and missing trailing blank lines.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gomarklint ./docs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Optional — GitHub Actions Integration
&lt;/h3&gt;

&lt;p&gt;Use the &lt;a href="https://github.com/marketplace/actions/gomarklint-markdown-linter" rel="noopener noreferrer"&gt;official GitHub Action&lt;/a&gt; to run linting automatically on every PR.&lt;/p&gt;
&lt;h2&gt;
  
  
  Overview of the Architecture
&lt;/h2&gt;

&lt;p&gt;gomarklint’s structure is intentionally simple. The &lt;code&gt;cmd/&lt;/code&gt; package contains the CLI entry point, built with Cobra for easy flag handling. Core logic lives in &lt;code&gt;internal/&lt;/code&gt;, which is split into &lt;code&gt;rule/&lt;/code&gt; for linting rules and &lt;code&gt;parser/&lt;/code&gt; for file handling. This separation makes it easy to add new rules without touching CLI code.&lt;/p&gt;
&lt;h2&gt;
  
  
  Parsing Markdown Files Efficiently
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;parser.ExpandPaths()&lt;/code&gt; function recursively finds &lt;code&gt;.md&lt;/code&gt; files while ignoring hidden directories and symlinks. It also applies &lt;code&gt;include&lt;/code&gt; and &lt;code&gt;ignore&lt;/code&gt; patterns from the config file. YAML frontmatter is detected and skipped so that headings inside it don’t trigger false positives.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rule Engine Design
&lt;/h2&gt;

&lt;p&gt;Each rule is a self-contained function that returns a list of &lt;code&gt;LintError&lt;/code&gt; structs, each containing the file name, line number, and message. Errors are sorted by line number before output. This keeps the codebase predictable and easy to extend.&lt;/p&gt;
&lt;h2&gt;
  
  
  Output &amp;amp; CI Integration
&lt;/h2&gt;

&lt;p&gt;gomarklint supports both text and JSON output. JSON mode is especially useful for CI pipelines, where structured output can be parsed by other tools. The program exits with a non-zero status if any errors are found, but only when running in CI (&lt;code&gt;GITHUB_ACTIONS=true&lt;/code&gt;), so local runs don’t break your workflow unnecessarily.&lt;/p&gt;
&lt;h2&gt;
  
  
  Performance Considerations
&lt;/h2&gt;

&lt;p&gt;Because performance was a priority from the start, file reading and rule checks are optimized to minimize allocations. In most cases, link checking is the only operation that significantly impacts runtime, and it’s disabled by default for speed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Try It on Your Project
&lt;/h2&gt;

&lt;p&gt;If you want to keep your Markdown clean and consistent without slowing down your workflow, try gomarklint today:&lt;/p&gt;

&lt;p&gt;Repository:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/shinagawa-web" rel="noopener noreferrer"&gt;
        shinagawa-web
      &lt;/a&gt; / &lt;a href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;
        gomarklint
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A fast and configurable Markdown linter written in Go
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;gomarklint&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/shinagawa-web/gomarklint/actions/workflows/test.yml/badge.svg"&gt;&lt;img src="https://github.com/shinagawa-web/gomarklint/actions/workflows/test.yml/badge.svg" alt="Test"&gt;&lt;/a&gt;
&lt;a href="https://codecov.io/gh/shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/3a62ea1c605d28ca8b76a989447c82d628955be97440820aa775fdaf031acd42/68747470733a2f2f636f6465636f762e696f2f67682f7368696e61676177612d7765622f676f6d61726b6c696e742f67726170682f62616467652e7376673f746f6b656e3d354d4743595a5a593753" alt="codecov"&gt;&lt;/a&gt;
&lt;a href="https://goreportcard.com/report/github.com/shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/5cc9b8570278e51d95dd927720684afeddb2594c78ae005a0886b29b2ca15039/68747470733a2f2f676f7265706f7274636172642e636f6d2f62616467652f6769746875622e636f6d2f7368696e61676177612d7765622f676f6d61726b6c696e74" alt="Go Report Card"&gt;&lt;/a&gt;
&lt;a href="https://pkg.go.dev/github.com/shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/6dfb8608e07cc6f5d15a43cf57ad1bcca3ab07f55909cc6b48380c79b1b4d277/68747470733a2f2f706b672e676f2e6465762f62616467652f6769746875622e636f6d2f7368696e61676177612d7765622f676f6d61726b6c696e742e737667" alt="Go Reference"&gt;&lt;/a&gt;
&lt;a href="https://github.com/shinagawa-web/gomarklint/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/fdf2982b9f5d7489dcf44570e714e3a15fce6253e0cc6b5aa61a075aac2ff71b/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4d49542d79656c6c6f772e737667" alt="License: MIT"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;English | &lt;a href="https://github.com/shinagawa-web/gomarklint/README.ja.md" rel="noopener noreferrer"&gt;日本語&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A fast, opinionated Markdown linter for engineering teams. Built in Go, designed for CI.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;Download binary&lt;/strong&gt; (no Go required):&lt;/p&gt;
&lt;p&gt;Download the latest binary for your platform from &lt;a href="https://github.com/shinagawa-web/gomarklint/releases/latest" rel="noopener noreferrer"&gt;GitHub Releases&lt;/a&gt;.&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; macOS / Linux&lt;/span&gt;
tar -xzf gomarklint_Darwin_x86_64.tar.gz
sudo mv gomarklint /usr/local/bin/
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; or install to user-local directory (no sudo required)&lt;/span&gt;
mkdir -p &lt;span class="pl-k"&gt;~&lt;/span&gt;/.local/bin &lt;span class="pl-k"&gt;&amp;amp;&amp;amp;&lt;/span&gt; mv gomarklint &lt;span class="pl-k"&gt;~&lt;/span&gt;/.local/bin/&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="highlight highlight-source-powershell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Windows (PowerShell)&lt;/span&gt;
&lt;span class="pl-c1"&gt;Expand-Archive&lt;/span&gt; &lt;span class="pl-k"&gt;-&lt;/span&gt;Path gomarklint_Windows_x86_64.zip &lt;span class="pl-k"&gt;-&lt;/span&gt;DestinationPath &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$&lt;span class="pl-c1"&gt;env:&lt;/span&gt;LOCALAPPDATA&lt;/span&gt;\Programs\gomarklint&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Add to PATH (run once)&lt;/span&gt;
[&lt;span class="pl-k"&gt;Environment&lt;/span&gt;]::SetEnvironmentVariable(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;PATH&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-k"&gt;,&lt;/span&gt; &lt;span class="pl-smi"&gt;$&lt;span class="pl-c1"&gt;env:&lt;/span&gt;PATH&lt;/span&gt; &lt;span class="pl-k"&gt;+&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;;&lt;span class="pl-smi"&gt;$&lt;span class="pl-c1"&gt;env:&lt;/span&gt;LOCALAPPDATA&lt;/span&gt;\Programs\gomarklint&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-k"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;User&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;)&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Via &lt;code&gt;go install&lt;/code&gt;:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;go install github.com/shinagawa-web/gomarklint@latest&lt;/pre&gt;

&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;Catch broken links and headings before your docs ship.&lt;/li&gt;
&lt;li&gt;Enforce predictable structure (no more "why is this H4 under H2?").&lt;/li&gt;
&lt;li&gt;Output that's friendly for both humans and machines (JSON).&lt;/li&gt;
&lt;li&gt;Process &lt;strong&gt;100,000+ lines in ~170ms&lt;/strong&gt; — fast…&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;GitHub Action:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/marketplace/actions/gomarklint-markdown-linter" rel="noopener noreferrer"&gt;https://github.com/marketplace/actions/gomarklint-markdown-linter&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Clone the repo, install it, and run gomarklint init — you’ll be linting your docs in under a minute.&lt;/p&gt;

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