<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>模組九：Go 做工具鏈與靜態分析 on Tarragon</title><link>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/</link><description>Recent content in 模組九：Go 做工具鏈與靜態分析 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 24 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/go/09-tooling-and-analysis/index.xml" rel="self" type="application/rss+xml"/><item><title>9.1 用 stdlib flag 寫 subcommand CLI</title><link>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/stdlib-flag-subcommands/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/stdlib-flag-subcommands/</guid><description>&lt;p>Subcommand CLI 的核心結構是 &lt;code>&amp;lt;tool&amp;gt; &amp;lt;sub&amp;gt; [flags] [args]&lt;/code>，每層各自承擔獨立決策：dispatcher 決定走到哪個子命令、flag parser 只認該子命令的旗標命名空間、positional args 交給業務邏輯。&lt;code>flag.NewFlagSet&lt;/code> 為每個子命令建立獨立 flag 命名空間，讓三層以內的 CLI 用 stdlib 就能乾淨解析；cobra 的說服點在 tab completion、generated help、hierarchical commands 等&lt;strong>超出 flag 解析本身&lt;/strong>的領域，三層內走 stdlib 成本最低。&lt;/p>
&lt;p>本章以 &lt;code>scripts/mdtools&lt;/code>（blog 自己的 markdown 工具鏈，repo 內檔案）作為 concrete instance。讀者不需要事先熟悉 mdtools — 每段會先講通用 pattern，再用對應 code 示範一種可行實作。&lt;/p>
&lt;h2 id="基礎為什麼需要-flagnewflagset-而非-flagparse">基礎：為什麼需要 &lt;code>flag.NewFlagSet&lt;/code> 而非 &lt;code>flag.Parse()&lt;/code>&lt;/h2>
&lt;p>&lt;code>flag.Parse()&lt;/code> 只解析一次全域 flag set。對只有一個命令的小工具（如 &lt;code>tool --input foo&lt;/code>）夠用；但一旦進入 &lt;code>tool fmt --fix&lt;/code> 這種 &lt;code>&amp;lt;tool&amp;gt; &amp;lt;subcommand&amp;gt; [flags]&lt;/code> 結構，全域 flag set 就擋路：&lt;/p>
&lt;ul>
&lt;li>&lt;code>--fix&lt;/code> 對 &lt;code>fmt&lt;/code> 命令有意義，對 &lt;code>lint&lt;/code> 命令沒有。&lt;/li>
&lt;li>各子命令可能共享 name（例如 &lt;code>--verbose&lt;/code>）但預設值或語意不同。&lt;/li>
&lt;li>help 輸出需要分子命令各自列自己的 flags。&lt;/li>
&lt;/ul>
&lt;p>&lt;code>flag.NewFlagSet&lt;/code> 讓每個子命令擁有&lt;strong>獨立的 flag 命名空間&lt;/strong>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">fs&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">flag&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewFlagSet&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;fmt&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">flag&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ExitOnError&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nx">fix&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">fs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Bool&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;fix&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;apply fixes in place&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nx">check&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">fs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Bool&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;check&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;report-only&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">fs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Parse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">args&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1">// args = os.Args[2:]，已經跳過了子命令本身&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>fs.Parse(args)&lt;/code> 只看傳進去的片段，不碰 &lt;code>os.Args&lt;/code> 全域。這是撐起 subcommand CLI 的核心 API。&lt;/p>
&lt;h2 id="專案-layoutmain--cmd--internal">專案 Layout：main → cmd/ → internal/&lt;/h2>
&lt;p>Go 慣例的 CLI 專案結構是三層，對應三種責任：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">scripts/mdtools/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">├── main.go ← 層 1：dispatcher，只做「看第一個參數分派到哪裡」
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">├── cmd/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">│ ├── fmt.go ← 層 2：每個子命令一個檔案，負責 flag 解析與呼叫 internal
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">│ ├── lint.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">│ ├── cards.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">│ └── migrate.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">└── internal/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> ├── mdfmt/ ← 層 3：純邏輯，不碰 flag、os.Args、os.Exit
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> ├── mdlint/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> └── mdcards/&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>分層的目的是支援每層獨立的測試策略：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>layer 1&lt;/strong>：幾乎不測，因為只是 &lt;code>switch&lt;/code>。&lt;/li>
&lt;li>&lt;strong>layer 2&lt;/strong>：integration test（給定 argv、確認 exit code 與 stdout）。&lt;/li>
&lt;li>&lt;strong>layer 3&lt;/strong>：unit test，純函式輸入輸出。後續模組的所有實作技術 — &lt;a href="https://tarrragon.github.io/blog/go/09-tooling-and-analysis/goldmark-ast-basics/" data-link-title="9.2 第三方 parser 整合：goldmark AST 入門" data-link-desc="用 goldmark 把 markdown 解析成 AST，掌握 ast.Walk visitor 模式、block 與 inline 節點的判讀、byte offset 如何定位到行號">AST 整合&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go/09-tooling-and-analysis/ast-idempotent-rewriting/" data-link-title="9.3 AST 驅動的 idempotent 文字改寫" data-link-desc="用 AST 定位位置、用 line-based 或 byte-level 改寫；設計多條 rule 的執行順序；--check 跟 --fix 如何共用邏輯">idempotent 改寫&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go/09-tooling-and-analysis/cross-file-graph-analysis/" data-link-title="9.4 跨檔案圖分析：從 lint 走到 static analysis" data-link-desc="Single-file 規則用 AST 搞定；跨檔 orphan 偵測、broken link、backlink 完整性需要把整個 repo 建成圖再走訪。用 mdtools cards 為例">graph 分析&lt;/a> — 都落在這層。&lt;/li>
&lt;/ul>
&lt;p>把 &lt;code>os.Exit&lt;/code> / &lt;code>os.Args&lt;/code> / &lt;code>os.Stderr&lt;/code> 都擋在 layer 1-2，layer 3 就能用一般 table-driven test 測，不用起 subprocess。&lt;/p></description><content:encoded><![CDATA[<p>Subcommand CLI 的核心結構是 <code>&lt;tool&gt; &lt;sub&gt; [flags] [args]</code>，每層各自承擔獨立決策：dispatcher 決定走到哪個子命令、flag parser 只認該子命令的旗標命名空間、positional args 交給業務邏輯。<code>flag.NewFlagSet</code> 為每個子命令建立獨立 flag 命名空間，讓三層以內的 CLI 用 stdlib 就能乾淨解析；cobra 的說服點在 tab completion、generated help、hierarchical commands 等<strong>超出 flag 解析本身</strong>的領域，三層內走 stdlib 成本最低。</p>
<p>本章以 <code>scripts/mdtools</code>（blog 自己的 markdown 工具鏈，repo 內檔案）作為 concrete instance。讀者不需要事先熟悉 mdtools — 每段會先講通用 pattern，再用對應 code 示範一種可行實作。</p>
<h2 id="基礎為什麼需要-flagnewflagset-而非-flagparse">基礎：為什麼需要 <code>flag.NewFlagSet</code> 而非 <code>flag.Parse()</code></h2>
<p><code>flag.Parse()</code> 只解析一次全域 flag set。對只有一個命令的小工具（如 <code>tool --input foo</code>）夠用；但一旦進入 <code>tool fmt --fix</code> 這種 <code>&lt;tool&gt; &lt;subcommand&gt; [flags]</code> 結構，全域 flag set 就擋路：</p>
<ul>
<li><code>--fix</code> 對 <code>fmt</code> 命令有意義，對 <code>lint</code> 命令沒有。</li>
<li>各子命令可能共享 name（例如 <code>--verbose</code>）但預設值或語意不同。</li>
<li>help 輸出需要分子命令各自列自己的 flags。</li>
</ul>
<p><code>flag.NewFlagSet</code> 讓每個子命令擁有<strong>獨立的 flag 命名空間</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">fs</span> <span class="o">:=</span> <span class="nx">flag</span><span class="p">.</span><span class="nf">NewFlagSet</span><span class="p">(</span><span class="s">&#34;fmt&#34;</span><span class="p">,</span> <span class="nx">flag</span><span class="p">.</span><span class="nx">ExitOnError</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">fix</span> <span class="o">:=</span> <span class="nx">fs</span><span class="p">.</span><span class="nf">Bool</span><span class="p">(</span><span class="s">&#34;fix&#34;</span><span class="p">,</span> <span class="kc">false</span><span class="p">,</span> <span class="s">&#34;apply fixes in place&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">check</span> <span class="o">:=</span> <span class="nx">fs</span><span class="p">.</span><span class="nf">Bool</span><span class="p">(</span><span class="s">&#34;check&#34;</span><span class="p">,</span> <span class="kc">false</span><span class="p">,</span> <span class="s">&#34;report-only&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">_</span> <span class="p">=</span> <span class="nx">fs</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="nx">args</span><span class="p">)</span> <span class="c1">// args = os.Args[2:]，已經跳過了子命令本身</span></span></span></code></pre></div><p><code>fs.Parse(args)</code> 只看傳進去的片段，不碰 <code>os.Args</code> 全域。這是撐起 subcommand CLI 的核心 API。</p>
<h2 id="專案-layoutmain--cmd--internal">專案 Layout：main → cmd/ → internal/</h2>
<p>Go 慣例的 CLI 專案結構是三層，對應三種責任：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">scripts/mdtools/
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── main.go             ← 層 1：dispatcher，只做「看第一個參數分派到哪裡」
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">├── cmd/
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">│   ├── fmt.go          ← 層 2：每個子命令一個檔案，負責 flag 解析與呼叫 internal
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">│   ├── lint.go
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">│   ├── cards.go
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">│   └── migrate.go
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">└── internal/
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    ├── mdfmt/          ← 層 3：純邏輯，不碰 flag、os.Args、os.Exit
</span></span><span class="line"><span class="ln">10</span><span class="cl">    ├── mdlint/
</span></span><span class="line"><span class="ln">11</span><span class="cl">    └── mdcards/</span></span></code></pre></div><p>分層的目的是支援每層獨立的測試策略：</p>
<ul>
<li><strong>layer 1</strong>：幾乎不測，因為只是 <code>switch</code>。</li>
<li><strong>layer 2</strong>：integration test（給定 argv、確認 exit code 與 stdout）。</li>
<li><strong>layer 3</strong>：unit test，純函式輸入輸出。後續模組的所有實作技術 — <a href="/blog/go/09-tooling-and-analysis/goldmark-ast-basics/" data-link-title="9.2 第三方 parser 整合：goldmark AST 入門" data-link-desc="用 goldmark 把 markdown 解析成 AST，掌握 ast.Walk visitor 模式、block 與 inline 節點的判讀、byte offset 如何定位到行號">AST 整合</a>、<a href="/blog/go/09-tooling-and-analysis/ast-idempotent-rewriting/" data-link-title="9.3 AST 驅動的 idempotent 文字改寫" data-link-desc="用 AST 定位位置、用 line-based 或 byte-level 改寫；設計多條 rule 的執行順序；--check 跟 --fix 如何共用邏輯">idempotent 改寫</a>、<a href="/blog/go/09-tooling-and-analysis/cross-file-graph-analysis/" data-link-title="9.4 跨檔案圖分析：從 lint 走到 static analysis" data-link-desc="Single-file 規則用 AST 搞定；跨檔 orphan 偵測、broken link、backlink 完整性需要把整個 repo 建成圖再走訪。用 mdtools cards 為例">graph 分析</a> — 都落在這層。</li>
</ul>
<p>把 <code>os.Exit</code> / <code>os.Args</code> / <code>os.Stderr</code> 都擋在 layer 1-2，layer 3 就能用一般 table-driven test 測，不用起 subprocess。</p>
<h2 id="layer-1maingo-dispatcher">Layer 1：main.go dispatcher</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// scripts/mdtools/main.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">package</span> <span class="nx">main</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="kn">import</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">	<span class="s">&#34;fmt&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	<span class="s">&#34;os&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">	<span class="s">&#34;blog/scripts/mdtools/cmd&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="kd">func</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">	<span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">os</span><span class="p">.</span><span class="nx">Args</span><span class="p">)</span> <span class="p">&lt;</span> <span class="mi">2</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">		<span class="nf">usage</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">		<span class="nx">os</span><span class="p">.</span><span class="nf">Exit</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">	<span class="nx">sub</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nx">Args</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">	<span class="nx">args</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nx">Args</span><span class="p">[</span><span class="mi">2</span><span class="p">:]</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl">	<span class="kd">var</span> <span class="nx">exitCode</span> <span class="kt">int</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">	<span class="k">switch</span> <span class="nx">sub</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">	<span class="k">case</span> <span class="s">&#34;fmt&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">		<span class="nx">exitCode</span> <span class="p">=</span> <span class="nx">cmd</span><span class="p">.</span><span class="nf">Fmt</span><span class="p">(</span><span class="nx">args</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">	<span class="k">case</span> <span class="s">&#34;lint&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">		<span class="nx">exitCode</span> <span class="p">=</span> <span class="nx">cmd</span><span class="p">.</span><span class="nf">Lint</span><span class="p">(</span><span class="nx">args</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">	<span class="k">case</span> <span class="s">&#34;cards&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">		<span class="nx">exitCode</span> <span class="p">=</span> <span class="nx">cmd</span><span class="p">.</span><span class="nf">Cards</span><span class="p">(</span><span class="nx">args</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">	<span class="k">case</span> <span class="s">&#34;migrate&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">		<span class="nx">exitCode</span> <span class="p">=</span> <span class="nx">cmd</span><span class="p">.</span><span class="nf">Migrate</span><span class="p">(</span><span class="nx">args</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">	<span class="k">case</span> <span class="s">&#34;-h&#34;</span><span class="p">,</span> <span class="s">&#34;--help&#34;</span><span class="p">,</span> <span class="s">&#34;help&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">		<span class="nf">usage</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">	<span class="k">case</span> <span class="s">&#34;version&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">		<span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;mdtools 0.1.0-dev&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">	<span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">		<span class="nx">fmt</span><span class="p">.</span><span class="nf">Fprintf</span><span class="p">(</span><span class="nx">os</span><span class="p">.</span><span class="nx">Stderr</span><span class="p">,</span> <span class="s">&#34;unknown subcommand: %q\n\n&#34;</span><span class="p">,</span> <span class="nx">sub</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">		<span class="nf">usage</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">		<span class="nx">exitCode</span> <span class="p">=</span> <span class="mi">2</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">
</span></span><span class="line"><span class="ln">40</span><span class="cl">	<span class="nx">os</span><span class="p">.</span><span class="nf">Exit</span><span class="p">(</span><span class="nx">exitCode</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>注意幾個 pattern：</p>
<ul>
<li><strong>dispatcher 不做 flag 解析</strong>。<code>args := os.Args[2:]</code> 把剩下交給子命令。</li>
<li><strong>每個子命令回傳 <code>int</code>，dispatcher 統一呼叫 <code>os.Exit</code></strong>。這讓子命令本身容易測（不會直接 kill 測試 process）。</li>
<li><strong><code>-h</code> / <code>--help</code> / <code>help</code> 三種寫法都接受</strong>。Unix 慣例。</li>
<li><strong>unknown subcommand 進 exit code 2</strong>，保留 exit 1 給「有違規」的語義。</li>
</ul>
<h2 id="layer-2子命令入口">Layer 2：子命令入口</h2>
<p>每個子命令一個檔案，結構類似：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// scripts/mdtools/cmd/fmt.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">package</span> <span class="nx">cmd</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="kn">import</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">	<span class="s">&#34;flag&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	<span class="s">&#34;fmt&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">	<span class="s">&#34;os&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">	<span class="s">&#34;blog/scripts/mdtools/internal/files&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">	<span class="s">&#34;blog/scripts/mdtools/internal/mdfmt&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">	<span class="s">&#34;blog/scripts/mdtools/internal/rules&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="kd">func</span> <span class="nf">Fmt</span><span class="p">(</span><span class="nx">args</span> <span class="p">[]</span><span class="kt">string</span><span class="p">)</span> <span class="kt">int</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">	<span class="nx">fs</span> <span class="o">:=</span> <span class="nx">flag</span><span class="p">.</span><span class="nf">NewFlagSet</span><span class="p">(</span><span class="s">&#34;fmt&#34;</span><span class="p">,</span> <span class="nx">flag</span><span class="p">.</span><span class="nx">ExitOnError</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">	<span class="nx">fix</span> <span class="o">:=</span> <span class="nx">fs</span><span class="p">.</span><span class="nf">Bool</span><span class="p">(</span><span class="s">&#34;fix&#34;</span><span class="p">,</span> <span class="kc">false</span><span class="p">,</span> <span class="s">&#34;apply fixes in place&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">	<span class="nx">check</span> <span class="o">:=</span> <span class="nx">fs</span><span class="p">.</span><span class="nf">Bool</span><span class="p">(</span><span class="s">&#34;check&#34;</span><span class="p">,</span> <span class="kc">false</span><span class="p">,</span> <span class="s">&#34;report-only; non-zero on pending changes&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">	<span class="nx">_</span> <span class="p">=</span> <span class="nx">fs</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="nx">args</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl">	<span class="k">if</span> <span class="o">*</span><span class="nx">check</span> <span class="o">&amp;&amp;</span> <span class="o">*</span><span class="nx">fix</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">		<span class="nx">fmt</span><span class="p">.</span><span class="nf">Fprintln</span><span class="p">(</span><span class="nx">os</span><span class="p">.</span><span class="nx">Stderr</span><span class="p">,</span> <span class="s">&#34;mdtools fmt: --fix and --check are mutually exclusive&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">		<span class="k">return</span> <span class="mi">2</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">	<span class="k">if</span> <span class="p">!</span><span class="o">*</span><span class="nx">check</span> <span class="o">&amp;&amp;</span> <span class="p">!</span><span class="o">*</span><span class="nx">fix</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">		<span class="o">*</span><span class="nx">check</span> <span class="p">=</span> <span class="kc">true</span> <span class="c1">// safe default</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">
</span></span><span class="line"><span class="ln">28</span><span class="cl">	<span class="nx">paths</span> <span class="o">:=</span> <span class="nx">fs</span><span class="p">.</span><span class="nf">Args</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">	<span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">paths</span><span class="p">)</span> <span class="o">==</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">		<span class="nx">paths</span> <span class="p">=</span> <span class="p">[]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;content&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">
</span></span><span class="line"><span class="ln">33</span><span class="cl">	<span class="nx">cfg</span> <span class="o">:=</span> <span class="nx">rules</span><span class="p">.</span><span class="nf">Default</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">	<span class="nx">mdFiles</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">files</span><span class="p">.</span><span class="nf">WalkMarkdown</span><span class="p">(</span><span class="nx">paths</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">	<span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">		<span class="nx">fmt</span><span class="p">.</span><span class="nf">Fprintf</span><span class="p">(</span><span class="nx">os</span><span class="p">.</span><span class="nx">Stderr</span><span class="p">,</span> <span class="s">&#34;mdtools fmt: walk error: %v\n&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">		<span class="k">return</span> <span class="mi">2</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">
</span></span><span class="line"><span class="ln">40</span><span class="cl">	<span class="nx">changed</span> <span class="o">:=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">	<span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">path</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">mdFiles</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl">		<span class="nx">result</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">mdfmt</span><span class="p">.</span><span class="nf">FormatFile</span><span class="p">(</span><span class="nx">path</span><span class="p">,</span> <span class="nx">cfg</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl">		<span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">			<span class="nx">fmt</span><span class="p">.</span><span class="nf">Fprintf</span><span class="p">(</span><span class="nx">os</span><span class="p">.</span><span class="nx">Stderr</span><span class="p">,</span> <span class="s">&#34;mdtools fmt: %s: %v\n&#34;</span><span class="p">,</span> <span class="nx">path</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">45</span><span class="cl">			<span class="k">return</span> <span class="mi">2</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">47</span><span class="cl">		<span class="k">if</span> <span class="p">!</span><span class="nx">result</span><span class="p">.</span><span class="nf">Changed</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">			<span class="k">continue</span>
</span></span><span class="line"><span class="ln">49</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">50</span><span class="cl">		<span class="nx">changed</span><span class="o">++</span>
</span></span><span class="line"><span class="ln">51</span><span class="cl">		<span class="k">if</span> <span class="o">*</span><span class="nx">fix</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">52</span><span class="cl">			<span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">WriteFile</span><span class="p">(</span><span class="nx">path</span><span class="p">,</span> <span class="nx">result</span><span class="p">.</span><span class="nx">Fixed</span><span class="p">,</span> <span class="mi">0</span><span class="nx">o644</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">53</span><span class="cl">				<span class="nx">fmt</span><span class="p">.</span><span class="nf">Fprintf</span><span class="p">(</span><span class="nx">os</span><span class="p">.</span><span class="nx">Stderr</span><span class="p">,</span> <span class="s">&#34;write %s: %v\n&#34;</span><span class="p">,</span> <span class="nx">path</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">54</span><span class="cl">				<span class="k">return</span> <span class="mi">2</span>
</span></span><span class="line"><span class="ln">55</span><span class="cl">			<span class="p">}</span>
</span></span><span class="line"><span class="ln">56</span><span class="cl">			<span class="nx">fmt</span><span class="p">.</span><span class="nf">Printf</span><span class="p">(</span><span class="s">&#34;fixed: %s\n&#34;</span><span class="p">,</span> <span class="nx">path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">57</span><span class="cl">		<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">58</span><span class="cl">			<span class="nx">fmt</span><span class="p">.</span><span class="nf">Printf</span><span class="p">(</span><span class="s">&#34;would fix: %s\n&#34;</span><span class="p">,</span> <span class="nx">path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">59</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">60</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">61</span><span class="cl">
</span></span><span class="line"><span class="ln">62</span><span class="cl">	<span class="k">if</span> <span class="o">*</span><span class="nx">check</span> <span class="o">&amp;&amp;</span> <span class="nx">changed</span> <span class="p">&gt;</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">63</span><span class="cl">		<span class="k">return</span> <span class="mi">1</span> <span class="c1">// CI-friendly: exit 1 means &#34;things need fixing&#34;</span>
</span></span><span class="line"><span class="ln">64</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">65</span><span class="cl">	<span class="k">return</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">66</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>要注意幾個設計決策：</p>
<ul>
<li><strong>flag 定義就在入口函式裡</strong>，不抽成 package 常數。每個子命令的 flag 獨立演化。</li>
<li><strong><code>ExitOnError</code></strong> 讓 <code>fs.Parse</code> 遇到不合法 flag 直接 exit — 對 CLI 工具 OK，因為 parse 失敗本來就無法繼續。測試時要用 <code>ContinueOnError</code> 避免殺測試。</li>
<li><strong>positional args 從 <code>fs.Args()</code> 取，不是 <code>os.Args</code></strong>。<code>fs.Parse</code> 會把非 flag 的留在 fs.Args()。</li>
<li><strong>預設值走安全側</strong>（<code>*check = true</code> when neither given）— 防止使用者意外執行破壞性動作。</li>
<li><strong>exit code 分層語意</strong>：0 = 成功、1 = 有違規、2 = 工具本身失敗。CI script 能用 <code>[[ $? -eq 1 ]]</code> 區分。</li>
</ul>
<h2 id="layer-3internal-實作">Layer 3：internal 實作</h2>
<p>Layer 3 是純邏輯，不知道任何 <code>os</code> / <code>flag</code> 的存在。這讓它能被 layer 2 呼叫、被 test 呼叫、也能在未來被其他 binary 或 library 重用：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// scripts/mdtools/internal/mdfmt/fixer.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">package</span> <span class="nx">mdfmt</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="kn">import</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">	<span class="s">&#34;bytes&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	<span class="s">&#34;os&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">	<span class="s">&#34;blog/scripts/mdtools/internal/rules&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="kd">type</span> <span class="nx">FixResult</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">	<span class="nx">Path</span>     <span class="kt">string</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">	<span class="nx">Original</span> <span class="p">[]</span><span class="kt">byte</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">	<span class="nx">Fixed</span>    <span class="p">[]</span><span class="kt">byte</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="nx">FixResult</span><span class="p">)</span> <span class="nf">Changed</span><span class="p">()</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">	<span class="k">return</span> <span class="p">!</span><span class="nx">bytes</span><span class="p">.</span><span class="nf">Equal</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Original</span><span class="p">,</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Fixed</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="kd">func</span> <span class="nf">FormatFile</span><span class="p">(</span><span class="nx">path</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">cfg</span> <span class="nx">rules</span><span class="p">.</span><span class="nx">Config</span><span class="p">)</span> <span class="p">(</span><span class="nx">FixResult</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">	<span class="nx">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">ReadFile</span><span class="p">(</span><span class="nx">path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">	<span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">		<span class="k">return</span> <span class="nx">FixResult</span><span class="p">{},</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">	<span class="nx">fixed</span> <span class="o">:=</span> <span class="nf">applyAll</span><span class="p">(</span><span class="nx">data</span><span class="p">,</span> <span class="nx">cfg</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">	<span class="k">return</span> <span class="nx">FixResult</span><span class="p">{</span><span class="nx">Path</span><span class="p">:</span> <span class="nx">path</span><span class="p">,</span> <span class="nx">Original</span><span class="p">:</span> <span class="nx">data</span><span class="p">,</span> <span class="nx">Fixed</span><span class="p">:</span> <span class="nx">fixed</span><span class="p">},</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>FormatFile</code> 回傳 <code>(FixResult, error)</code>，不 <code>os.Exit</code>、不印訊息、不碰全域狀態。Test 可以直接給一個記憶體 <code>[]byte</code> 跑 <code>applyAll</code> 驗結果。</p>
<h2 id="什麼時候該上-cobra">什麼時候該上 cobra</h2>
<p>升級到 cobra 的判準是<strong>stdlib 能處理的負面複雜度已經超過 cobra 的學習成本</strong>。下表列五個實際觸發過團隊升級的訊號，每個都附展開說明。</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>為什麼 stdlib 處理不好</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>命令層級超過 3 層（<code>tool sub1 sub2 sub3 --flag</code>）</td>
          <td>dispatcher 變成多層 nested switch，flag 繼承需要手動維護</td>
      </tr>
      <tr>
          <td>需要自動 shell completion（bash / zsh / fish）</td>
          <td>手寫 completion 腳本成本高；cobra / urfave-cli 有 generator</td>
      </tr>
      <tr>
          <td>需要 markdown / man-page 形式的 help 輸出</td>
          <td>stdlib 只有基本 <code>flag.Usage</code>；cobra 有 <code>doc</code> package 能渲染</td>
      </tr>
      <tr>
          <td>有多個 end-user 要閱讀 help（非開發者）</td>
          <td>stdlib 的 <code>flag.Usage</code> 格式樸素，降低使用者可讀性</td>
      </tr>
      <tr>
          <td>大量共用 flag（&ndash;verbose / &ndash;log-level 每個命令都要）</td>
          <td>cobra 的 PersistentFlags 比手工在每個子命令重複宣告乾淨</td>
      </tr>
  </tbody>
</table>
<p><strong>命令層級超過 3 層</strong>：<code>kubectl get pods</code> 只有兩層還撐得住；到 <code>gh api repos owner/repo/pulls list --limit 10</code> 就是四層（含 <code>api</code> 這個 namespace），dispatcher 裡巢狀 switch 開始難讀。信號：dispatcher 的 switch case 超過十個，或 case 裡面又呼叫另一個 switch。反例：即使只有兩層，若每層未來會繼續加，早上 cobra 可省後來重構。</p>
<p><strong>需要自動 shell completion</strong>：end-user 會反覆打命令、需要 tab 補齊子命令與 flag 名稱時，這功能差很多。手寫 completion 腳本要處理三種 shell 的語法差異，成本高；cobra 一行 <code>cobra.GenBashCompletion</code> 就產生。信號：工具有外部使用者、或團隊已經裝 shell completion。反例：只在 CI 跑、人不會互動輸入。</p>
<p><strong>man-page 形式的 help 輸出</strong>：Unix 社群期待工具有 <code>man tool</code> 級的文件。stdlib 只輸出簡單的 usage 字串，排版樸素；cobra 的 <code>doc</code> package 能生成 markdown / reStructuredText / man。信號：工具要 package 進系統（Homebrew、apt），或對外發佈。反例：公司內部用、README 夠用。</p>
<p><strong>多 end-user 讀 help</strong>：工程師忍受樸素的 <code>-h</code> 輸出，但產品經理、SRE on-call 看不下。cobra 有明確的 long description、example 欄位，排版比 stdlib 好。信號：使用者包含非程式設計角色。反例：user 是同團隊工程師。</p>
<p><strong>大量共用 flag</strong>：<code>--verbose</code>、<code>--log-level</code>、<code>--config</code> 這類 flag 每個子命令都要用。stdlib 要在每個子命令重複 <code>fs.Bool(&quot;verbose&quot;, ...)</code>；cobra 的 PersistentFlags 能繼承到所有 subcommand。信號：重複 flag 超過三個、或要 enforce 某個 flag 在所有 subcommand 都有。反例：flag 在每個子命令語意不同，共用反而製造混淆。</p>
<p>以上五個訊號在 mdtools 都沒命中（內部工具、單層 subcommand、工程師使用者），所以繼續走 stdlib。若未來 mdtools 對外釋出給讀者下載，就值得重新評估。<strong>判讀時機是設計當下，不是感覺「stdlib 開始髒」時</strong> — 髒時通常已經晚。</p>
<h2 id="常見陷阱">常見陷阱</h2>
<h3 id="在-layer-3-直接呼叫-osexit">在 layer 3 直接呼叫 <code>os.Exit</code></h3>
<p>會破壞 test：test runner 呼叫 <code>TestXxx</code> 時，如果 subject code 裡 <code>os.Exit(1)</code>，整個 test process 退出，其他 test 不跑。Layer 3 應回傳 error，讓 layer 2 決定怎麼退出。</p>
<h3 id="用全域-var-fs--flagnewflagset-宣告-flag">用全域 <code>var fs = flag.NewFlagSet(...)</code> 宣告 flag</h3>
<p>每次呼叫會累積狀態（flag 已經被定義過會 panic），並且兩個 test 同時跑會 race。定義 flag 要在函式裡。</p>
<h3 id="忘記-continueonerror-就跑-test">忘記 <code>ContinueOnError</code> 就跑 test</h3>
<p><code>ExitOnError</code> 是 production 預設，但測試時會讓測試 process 整個退出。Table-driven test 要用：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">fs</span> <span class="o">:=</span> <span class="nx">flag</span><span class="p">.</span><span class="nf">NewFlagSet</span><span class="p">(</span><span class="nx">name</span><span class="p">,</span> <span class="nx">flag</span><span class="p">.</span><span class="nx">ContinueOnError</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">fs</span><span class="p">.</span><span class="nf">SetOutput</span><span class="p">(</span><span class="nx">io</span><span class="p">.</span><span class="nx">Discard</span><span class="p">)</span> <span class="c1">// 測試時不要印 usage 到 stderr</span></span></span></code></pre></div><h3 id="太早抽出所有子命令共用的-flag">太早抽出「所有子命令共用的 flag」</h3>
<p>PersistentFlags 概念在 stdlib 沒有，手動在每個子命令重複 <code>fs.Bool(&quot;verbose&quot;, false, ...)</code> 看似重複但其實可讀。一旦抽成共用 helper，就開始維護一個小框架 — 這時候用 cobra 反而更乾淨。</p>
<h2 id="擴充路徑">擴充路徑</h2>
<ul>
<li><strong>命令太多時分組</strong>：<code>tool fmt check</code>、<code>tool fmt fix</code> 的兩層 subcommand 可以用「每層一個 switch」展開，main → cmd.Fmt → cmd.FmtCheck。mdtools 的 <code>migrate fix-links</code> 就是這個模式（見 <code>cmd/migrate.go</code>）。</li>
<li><strong>共用 config loading</strong>：<code>rules.Default()</code> 這類邏輯放在 internal 裡，每個子命令呼叫；不要每個子命令自己 parse 配置檔。</li>
<li><strong>測試 layer 2</strong>：用 <code>buffer</code> 捕獲 stdout/stderr，傳入自定 args。參考 Go stdlib 的 <code>testing/iotest</code> 跟 <code>bytes.Buffer</code>。</li>
</ul>
<h2 id="下一步">下一步</h2>
<p><a href="/blog/go/09-tooling-and-analysis/goldmark-ast-basics/" data-link-title="9.2 第三方 parser 整合：goldmark AST 入門" data-link-desc="用 goldmark 把 markdown 解析成 AST，掌握 ast.Walk visitor 模式、block 與 inline 節點的判讀、byte offset 如何定位到行號">9.2 goldmark AST 入門</a> 會看 mdtools 怎麼把 markdown 解析成可操作的結構，layer 3 內部怎麼組織 parser 整合。</p>
]]></content:encoded></item><item><title>9.2 第三方 parser 整合：goldmark AST 入門</title><link>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/goldmark-ast-basics/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/goldmark-ast-basics/</guid><description>&lt;p>第三方 parser 整合的核心責任是&lt;strong>把外部格式的語法細節封裝成可走訪的結構化樹&lt;/strong>，讓上層業務邏輯脫離字串處理，直接在 AST 節點上判讀。對 markdown 這類格式，成熟 parser（如 goldmark）提供完整 CommonMark 解析、GFM 擴充、位置資訊；上層工具透過 &lt;a href="https://tarrragon.github.io/blog/go/glossary/#ast-walker" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">AST walker&lt;/a> 接住 AST 後再決定要做 lint、&lt;a href="https://tarrragon.github.io/blog/go/09-tooling-and-analysis/ast-idempotent-rewriting/" data-link-title="9.3 AST 驅動的 idempotent 文字改寫" data-link-desc="用 AST 定位位置、用 line-based 或 byte-level 改寫；設計多條 rule 的執行順序；--check 跟 --fix 如何共用邏輯">rewrite&lt;/a>、render 或 &lt;a href="https://tarrragon.github.io/blog/go/09-tooling-and-analysis/cross-file-graph-analysis/" data-link-title="9.4 跨檔案圖分析：從 lint 走到 static analysis" data-link-desc="Single-file 規則用 AST 搞定；跨檔 orphan 偵測、broken link、backlink 完整性需要把整個 repo 建成圖再走訪。用 mdtools cards 為例">graph 分析&lt;/a>。&lt;/p>
&lt;p>Go 的慣例是&lt;strong>封一層薄 wrapper&lt;/strong> — 不讓呼叫端直接看到第三方 API 的完整型別空間，保留未來換 parser 的彈性。加上 Go 的 AST 節點通常區分 &lt;strong>block&lt;/strong> 跟 &lt;strong>inline&lt;/strong> 兩種型別（對應到 CommonMark spec），走訪時需要配合型別判讀，以免呼叫到只存在於 block 節點的 method（&lt;code>Lines()&lt;/code> 就是典型例子，對 inline 節點呼叫會 panic）。&lt;/p>
&lt;p>本章以 &lt;code>scripts/mdtools/internal/astutil&lt;/code> 跟 &lt;code>internal/mdcards/graph.go&lt;/code> 為 concrete instance 示範整合流程。更廣泛的 AST 概念背景在 &lt;a href="https://tarrragon.github.io/blog/posts/%E4%BB%80%E9%BA%BC%E6%98%AF-ast-%E5%BE%9E%E5%AD%97%E4%B8%B2%E5%88%B0%E8%AA%9E%E6%B3%95%E6%A8%B9%E7%9A%84%E8%A6%96%E8%A7%92%E8%BD%89%E6%8F%9B/" data-link-title="什麼是 AST — 從字串到語法樹的視角轉換" data-link-desc="AST 與 regex 的差異判準：規則需要知道文字處在什麼結構中時 regex 就不夠。附 regex 誤判的具體 case。">什麼是 AST&lt;/a>；本章聚焦 Go 層面的整合 pattern。&lt;/p>
&lt;h2 id="為什麼選-goldmark">為什麼選 goldmark&lt;/h2>
&lt;p>Markdown parser 在 Go 有多個選項。選 goldmark 的理由：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Hugo 內建用它&lt;/strong> — 同一個 parser 解析，lint 結果跟 render 結果一定一致。其他 parser 可能判讀差異導致「lint 過了但 Hugo render 壞」的長尾 bug。&lt;/li>
&lt;li>&lt;strong>完整 CommonMark 支援 + GFM 擴充&lt;/strong>。table、strikethrough、task list 都在。&lt;/li>
&lt;li>&lt;strong>AST 節點設計貼近 CommonMark spec&lt;/strong>：心智負擔小，節點型別直接對應 spec 用語。&lt;/li>
&lt;li>&lt;strong>純 Go、零 CGO、穩定&lt;/strong>。build 不會踩奇怪的 C 依賴。&lt;/li>
&lt;/ul>
&lt;p>類似選擇邏輯可套用到其他格式：Go 原始碼用 &lt;code>go/parser&lt;/code>，YAML 用 &lt;code>gopkg.in/yaml.v3&lt;/code>，protobuf 用 &lt;code>google.golang.org/protobuf/encoding/prototext&lt;/code>。&lt;/p>
&lt;h2 id="最小整合parse-一份-markdown">最小整合：parse 一份 markdown&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// scripts/mdtools/internal/astutil/parser.go&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="kn">package&lt;/span> &lt;span class="nx">astutil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">	&lt;span class="s">&amp;#34;github.com/yuin/goldmark&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">	&lt;span class="s">&amp;#34;github.com/yuin/goldmark/ast&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">	&lt;span class="s">&amp;#34;github.com/yuin/goldmark/extension&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">	&lt;span class="s">&amp;#34;github.com/yuin/goldmark/parser&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">	&lt;span class="s">&amp;#34;github.com/yuin/goldmark/text&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Parser&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">	&lt;span class="nx">md&lt;/span> &lt;span class="nx">goldmark&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Markdown&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">NewParser&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Parser&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">	&lt;span class="nx">md&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">goldmark&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">New&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">		&lt;span class="nx">goldmark&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WithExtensions&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">			&lt;span class="nx">extension&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">GFM&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// Table, Strikethrough, Linkify, TaskList&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">		&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">	&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">	&lt;span class="k">return&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">Parser&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">md&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">md&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">p&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Parser&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Parse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">src&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">byte&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">ast&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Node&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl">	&lt;span class="nx">reader&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">text&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewReader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">src&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl">	&lt;span class="k">return&lt;/span> &lt;span class="nx">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">md&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Parser&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">Parse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">reader&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">parser&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WithContext&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">parser&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewContext&lt;/span>&lt;span class="p">()))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>為什麼包一層 &lt;code>Parser&lt;/code> 而不是直接呼叫 &lt;code>goldmark.New(...).Parser().Parse(...)&lt;/code>：&lt;/p></description><content:encoded><![CDATA[<p>第三方 parser 整合的核心責任是<strong>把外部格式的語法細節封裝成可走訪的結構化樹</strong>，讓上層業務邏輯脫離字串處理，直接在 AST 節點上判讀。對 markdown 這類格式，成熟 parser（如 goldmark）提供完整 CommonMark 解析、GFM 擴充、位置資訊；上層工具透過 <a href="/blog/go/glossary/#ast-walker" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">AST walker</a> 接住 AST 後再決定要做 lint、<a href="/blog/go/09-tooling-and-analysis/ast-idempotent-rewriting/" data-link-title="9.3 AST 驅動的 idempotent 文字改寫" data-link-desc="用 AST 定位位置、用 line-based 或 byte-level 改寫；設計多條 rule 的執行順序；--check 跟 --fix 如何共用邏輯">rewrite</a>、render 或 <a href="/blog/go/09-tooling-and-analysis/cross-file-graph-analysis/" data-link-title="9.4 跨檔案圖分析：從 lint 走到 static analysis" data-link-desc="Single-file 規則用 AST 搞定；跨檔 orphan 偵測、broken link、backlink 完整性需要把整個 repo 建成圖再走訪。用 mdtools cards 為例">graph 分析</a>。</p>
<p>Go 的慣例是<strong>封一層薄 wrapper</strong> — 不讓呼叫端直接看到第三方 API 的完整型別空間，保留未來換 parser 的彈性。加上 Go 的 AST 節點通常區分 <strong>block</strong> 跟 <strong>inline</strong> 兩種型別（對應到 CommonMark spec），走訪時需要配合型別判讀，以免呼叫到只存在於 block 節點的 method（<code>Lines()</code> 就是典型例子，對 inline 節點呼叫會 panic）。</p>
<p>本章以 <code>scripts/mdtools/internal/astutil</code> 跟 <code>internal/mdcards/graph.go</code> 為 concrete instance 示範整合流程。更廣泛的 AST 概念背景在 <a href="/blog/posts/%E4%BB%80%E9%BA%BC%E6%98%AF-ast-%E5%BE%9E%E5%AD%97%E4%B8%B2%E5%88%B0%E8%AA%9E%E6%B3%95%E6%A8%B9%E7%9A%84%E8%A6%96%E8%A7%92%E8%BD%89%E6%8F%9B/" data-link-title="什麼是 AST — 從字串到語法樹的視角轉換" data-link-desc="AST 與 regex 的差異判準：規則需要知道文字處在什麼結構中時 regex 就不夠。附 regex 誤判的具體 case。">什麼是 AST</a>；本章聚焦 Go 層面的整合 pattern。</p>
<h2 id="為什麼選-goldmark">為什麼選 goldmark</h2>
<p>Markdown parser 在 Go 有多個選項。選 goldmark 的理由：</p>
<ul>
<li><strong>Hugo 內建用它</strong> — 同一個 parser 解析，lint 結果跟 render 結果一定一致。其他 parser 可能判讀差異導致「lint 過了但 Hugo render 壞」的長尾 bug。</li>
<li><strong>完整 CommonMark 支援 + GFM 擴充</strong>。table、strikethrough、task list 都在。</li>
<li><strong>AST 節點設計貼近 CommonMark spec</strong>：心智負擔小，節點型別直接對應 spec 用語。</li>
<li><strong>純 Go、零 CGO、穩定</strong>。build 不會踩奇怪的 C 依賴。</li>
</ul>
<p>類似選擇邏輯可套用到其他格式：Go 原始碼用 <code>go/parser</code>，YAML 用 <code>gopkg.in/yaml.v3</code>，protobuf 用 <code>google.golang.org/protobuf/encoding/prototext</code>。</p>
<h2 id="最小整合parse-一份-markdown">最小整合：parse 一份 markdown</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// scripts/mdtools/internal/astutil/parser.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">package</span> <span class="nx">astutil</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="kn">import</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">	<span class="s">&#34;github.com/yuin/goldmark&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	<span class="s">&#34;github.com/yuin/goldmark/ast&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">	<span class="s">&#34;github.com/yuin/goldmark/extension&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">	<span class="s">&#34;github.com/yuin/goldmark/parser&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">	<span class="s">&#34;github.com/yuin/goldmark/text&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="kd">type</span> <span class="nx">Parser</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">	<span class="nx">md</span> <span class="nx">goldmark</span><span class="p">.</span><span class="nx">Markdown</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="kd">func</span> <span class="nf">NewParser</span><span class="p">()</span> <span class="o">*</span><span class="nx">Parser</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">	<span class="nx">md</span> <span class="o">:=</span> <span class="nx">goldmark</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">		<span class="nx">goldmark</span><span class="p">.</span><span class="nf">WithExtensions</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">			<span class="nx">extension</span><span class="p">.</span><span class="nx">GFM</span><span class="p">,</span> <span class="c1">// Table, Strikethrough, Linkify, TaskList</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">		<span class="p">),</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">	<span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">	<span class="k">return</span> <span class="o">&amp;</span><span class="nx">Parser</span><span class="p">{</span><span class="nx">md</span><span class="p">:</span> <span class="nx">md</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">p</span> <span class="o">*</span><span class="nx">Parser</span><span class="p">)</span> <span class="nf">Parse</span><span class="p">(</span><span class="nx">src</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">Node</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">	<span class="nx">reader</span> <span class="o">:=</span> <span class="nx">text</span><span class="p">.</span><span class="nf">NewReader</span><span class="p">(</span><span class="nx">src</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">	<span class="k">return</span> <span class="nx">p</span><span class="p">.</span><span class="nx">md</span><span class="p">.</span><span class="nf">Parser</span><span class="p">().</span><span class="nf">Parse</span><span class="p">(</span><span class="nx">reader</span><span class="p">,</span> <span class="nx">parser</span><span class="p">.</span><span class="nf">WithContext</span><span class="p">(</span><span class="nx">parser</span><span class="p">.</span><span class="nf">NewContext</span><span class="p">()))</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>為什麼包一層 <code>Parser</code> 而不是直接呼叫 <code>goldmark.New(...).Parser().Parse(...)</code>：</p>
<ul>
<li><strong>第三方 API 面積大</strong>，工具只需要其中一部分。封裝讓呼叫端看不到 <code>goldmark.Markdown</code>、<code>parser.NewContext</code> 這些細節。</li>
<li><strong>未來換 parser 成本低</strong>：如果有天換 mistune-for-go 或自寫 parser，呼叫端的 <code>astutil.NewParser().Parse(src)</code> 不用改。</li>
<li><strong>測試替身容易</strong>：unit test 可以 mock <code>Parser</code> interface。</li>
</ul>
<p>三個 struct / package / extension 配置的預設值：</p>
<ul>
<li><strong>Extensions</strong>：<code>extension.GFM</code> 涵蓋 blog 需要的全部；只啟用實際用到的 extension 讓 parser 行為可預測。</li>
<li><strong>Context</strong>：每次 <code>Parse</code> 都建新 context — goldmark context 儲存 parse 狀態，不能跨 parse 共用。</li>
</ul>
<h2 id="ast-節點階層block-跟-inline-的分野">AST 節點階層：Block 跟 Inline 的分野</h2>
<p>goldmark 的 AST 節點有兩大類，型別系統直接區分：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// goldmark/ast/ast.go</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">type</span> <span class="nx">NodeType</span> <span class="kt">int</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">	<span class="nx">TypeDocument</span> <span class="nx">NodeType</span> <span class="p">=</span> <span class="kc">iota</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">	<span class="nx">TypeBlock</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">	<span class="nx">TypeInline</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p><strong>Block 節點</strong>：段落、heading、list、table、blockquote、fenced code block — 在來源檔案中占據完整的行。這類節點帶有 source line segments，能用 <code>n.Lines()</code> 取得起訖位置。</p>
<p><strong>Inline 節點</strong>：link、emphasis、text、code span、image — 存在於 block 節點內部。Inline 節點<strong>沒有獨立的 line segments</strong>；它們的位置由父 block 管理。</p>
<p>這個區分有個實戰後果。第一次寫 AST 走訪的人經常這樣寫：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// WRONG: 對 inline 節點呼叫 Lines() 會 panic</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">ast</span><span class="p">.</span><span class="nf">Walk</span><span class="p">(</span><span class="nx">doc</span><span class="p">,</span> <span class="kd">func</span><span class="p">(</span><span class="nx">n</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">Node</span><span class="p">,</span> <span class="nx">entering</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">(</span><span class="nx">ast</span><span class="p">.</span><span class="nx">WalkStatus</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">	<span class="nx">link</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">n</span><span class="p">.(</span><span class="o">*</span><span class="nx">ast</span><span class="p">.</span><span class="nx">Link</span><span class="p">)</span> <span class="c1">// Link 是 inline</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">	<span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">		<span class="k">return</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">WalkContinue</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">	<span class="nx">segs</span> <span class="o">:=</span> <span class="nx">link</span><span class="p">.</span><span class="nf">Lines</span><span class="p">()</span> <span class="c1">// panic: &#34;can not call with inline nodes&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">	<span class="o">...</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><p>Link 節點沒有 Lines()。正確做法是<strong>走上去找最近的 block 節點</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// scripts/mdtools/internal/mdcards/graph.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">nodeLine</span><span class="p">(</span><span class="nx">n</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">Node</span><span class="p">,</span> <span class="nx">src</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="kt">int</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="k">for</span> <span class="nx">p</span> <span class="o">:=</span> <span class="nx">n</span><span class="p">;</span> <span class="nx">p</span> <span class="o">!=</span> <span class="kc">nil</span><span class="p">;</span> <span class="nx">p</span> <span class="p">=</span> <span class="nx">p</span><span class="p">.</span><span class="nf">Parent</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">		<span class="k">if</span> <span class="nx">p</span><span class="p">.</span><span class="nf">Type</span><span class="p">()</span> <span class="o">!=</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">TypeBlock</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">			<span class="k">continue</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">		<span class="nx">segs</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nf">Lines</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">		<span class="k">if</span> <span class="nx">segs</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="o">&amp;&amp;</span> <span class="nx">segs</span><span class="p">.</span><span class="nf">Len</span><span class="p">()</span> <span class="p">&gt;</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">			<span class="k">return</span> <span class="nf">lineNumber</span><span class="p">(</span><span class="nx">src</span><span class="p">,</span> <span class="nx">segs</span><span class="p">.</span><span class="nf">At</span><span class="p">(</span><span class="mi">0</span><span class="p">).</span><span class="nx">Start</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">	<span class="k">return</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 walk-up-to-block 模式在每個會操作 inline 節點的工具裡都會出現。<strong>初學者的第一個 goldmark panic 幾乎必然是這個</strong>。</p>
<h2 id="astwalk-visitor-模式"><code>ast.Walk</code> visitor 模式</h2>
<p>goldmark 用標準 visitor pattern 走 AST：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">ast</span><span class="p">.</span><span class="nf">Walk</span><span class="p">(</span><span class="nx">doc</span><span class="p">,</span> <span class="kd">func</span><span class="p">(</span><span class="nx">n</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">Node</span><span class="p">,</span> <span class="nx">entering</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">(</span><span class="nx">ast</span><span class="p">.</span><span class="nx">WalkStatus</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">	<span class="c1">// entering == true：進入節點（DFS 下行）</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">	<span class="c1">// entering == false：離開節點（DFS 回溯）</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">	<span class="k">return</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">WalkContinue</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><p>Walk status 三個常見值：</p>
<ul>
<li><code>ast.WalkContinue</code> — 繼續深度優先走訪。</li>
<li><code>ast.WalkSkipChildren</code> — 跳過子樹，繼續走同層。適合當「處理完整個 Paragraph 就不用再進去找子 Link」。</li>
<li><code>ast.WalkStop</code> — 整個走訪中止。適合「找到第一個就結束」。</li>
</ul>
<p>實戰中幾乎只處理 <code>entering == true</code> 的情境 — DFS 下行足以覆蓋多數規則。<code>entering == false</code> 的 post-order 位置保留給需要聚合子樹資訊的場景（例如計算子樹裡的 link 數量）。</p>
<h2 id="實戰抽出所有-link-節點並計算位置">實戰：抽出所有 Link 節點並計算位置</h2>
<p><code>mdtools cards</code> 要找所有相對連結。這是一個完整的 <code>ast.Walk</code> 應用：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// scripts/mdtools/internal/mdcards/graph.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">g</span> <span class="o">*</span><span class="nx">Graph</span><span class="p">)</span> <span class="nf">extractEdges</span><span class="p">(</span><span class="nx">fn</span> <span class="nx">FileNode</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="nx">ast</span><span class="p">.</span><span class="nf">Walk</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">AST</span><span class="p">,</span> <span class="kd">func</span><span class="p">(</span><span class="nx">n</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">Node</span><span class="p">,</span> <span class="nx">entering</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">(</span><span class="nx">ast</span><span class="p">.</span><span class="nx">WalkStatus</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">		<span class="k">if</span> <span class="p">!</span><span class="nx">entering</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">			<span class="k">return</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">WalkContinue</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">		<span class="nx">link</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">n</span><span class="p">.(</span><span class="o">*</span><span class="nx">ast</span><span class="p">.</span><span class="nx">Link</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">		<span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">			<span class="k">return</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">WalkContinue</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">		<span class="nx">dest</span> <span class="o">:=</span> <span class="nb">string</span><span class="p">(</span><span class="nx">link</span><span class="p">.</span><span class="nx">Destination</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">		<span class="k">if</span> <span class="nf">isExternalOrAnchor</span><span class="p">(</span><span class="nx">dest</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">			<span class="k">return</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">WalkContinue</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">		<span class="nx">target</span> <span class="o">:=</span> <span class="nf">resolveTarget</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">,</span> <span class="nx">dest</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">		<span class="k">if</span> <span class="nx">target</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">			<span class="k">return</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">WalkContinue</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">		<span class="nx">g</span><span class="p">.</span><span class="nx">Edges</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">g</span><span class="p">.</span><span class="nx">Edges</span><span class="p">,</span> <span class="nx">Edge</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">			<span class="nx">SourcePath</span><span class="p">:</span>  <span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">			<span class="nx">SourceLine</span><span class="p">:</span>  <span class="nf">nodeLine</span><span class="p">(</span><span class="nx">n</span><span class="p">,</span> <span class="nx">fn</span><span class="p">.</span><span class="nx">Src</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">			<span class="nx">Destination</span><span class="p">:</span> <span class="nx">dest</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">			<span class="nx">Target</span><span class="p">:</span>      <span class="nx">target</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">			<span class="nx">DisplayText</span><span class="p">:</span> <span class="nb">string</span><span class="p">(</span><span class="nx">link</span><span class="p">.</span><span class="nf">Text</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">Src</span><span class="p">)),</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">		<span class="p">})</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">		<span class="k">return</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">WalkContinue</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">	<span class="p">})</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>關鍵操作：</p>
<ul>
<li><strong>Type assertion 提前 filter</strong>：<code>link, ok := n.(*ast.Link)</code>。不是 Link 就直接 continue，不做無用工。</li>
<li><strong>判讀早退</strong>：<code>isExternalOrAnchor(dest)</code> 先過濾 <code>http://</code> 與 <code>#anchor</code> 這類不屬於 graph 的邊。</li>
<li><strong>對 inline 節點取行號走 walk-up</strong>（上節講的 <code>nodeLine</code>）。</li>
<li><strong>text 要透過 <code>link.Text(fn.Src)</code> 取</strong> — inline 節點的文字儲存為 source 的 byte segment，不是 string。<code>link.Text()</code> 需要帶 src 才能反推。</li>
</ul>
<h2 id="byte-offset-定位到行號">Byte offset 定位到行號</h2>
<p>goldmark 的 source segment 用 byte offset 標註起訖。要轉成 1-based line number：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// scripts/mdtools/internal/mdcards/graph.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">lineNumber</span><span class="p">(</span><span class="nx">src</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">,</span> <span class="nx">offset</span> <span class="kt">int</span><span class="p">)</span> <span class="kt">int</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="k">if</span> <span class="nx">offset</span> <span class="p">&lt;</span> <span class="mi">0</span> <span class="o">||</span> <span class="nx">offset</span> <span class="p">&gt;</span> <span class="nb">len</span><span class="p">(</span><span class="nx">src</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">		<span class="k">return</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	<span class="nx">line</span> <span class="o">:=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">	<span class="k">for</span> <span class="nx">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="nx">offset</span> <span class="o">&amp;&amp;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="nx">src</span><span class="p">);</span> <span class="nx">i</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">		<span class="k">if</span> <span class="nx">src</span><span class="p">[</span><span class="nx">i</span><span class="p">]</span> <span class="o">==</span> <span class="sc">&#39;\n&#39;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">			<span class="nx">line</span><span class="o">++</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">	<span class="k">return</span> <span class="nx">line</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>O(offset) scan。對 300-檔 / 每檔 500 行的 blog repo 夠快；若是幾千萬行的 codebase 才需要預建 line-offset table。</p>
<h2 id="textsegment-跟-byte-slice-的對應">text.Segment 跟 byte slice 的對應</h2>
<p>每個 block 節點的 <code>Lines()</code> 回傳 <code>*text.Segments</code>，裡面是多個 <code>text.Segment{Start, Stop int}</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 取段落第一行的原始 byte 內容</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">segs</span> <span class="o">:=</span> <span class="nx">paragraph</span><span class="p">.</span><span class="nf">Lines</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">firstSeg</span> <span class="o">:=</span> <span class="nx">segs</span><span class="p">.</span><span class="nf">At</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">lineBytes</span> <span class="o">:=</span> <span class="nx">src</span><span class="p">[</span><span class="nx">firstSeg</span><span class="p">.</span><span class="nx">Start</span><span class="p">:</span><span class="nx">firstSeg</span><span class="p">.</span><span class="nx">Stop</span><span class="p">]</span></span></span></code></pre></div><p>這個 API 讓你能回頭看原始 source，而不是透過 AST 重新渲染。對 lint 工具（要報告精確位置、甚至 rewrite）很重要。</p>
<h2 id="常見陷阱">常見陷阱</h2>
<h3 id="對-inline-節點呼叫-lines">對 inline 節點呼叫 <code>Lines()</code></h3>
<p>已經講過，補一句：不只 Link，還有 Text、CodeSpan、Emphasis、Image — 凡是 <code>n.Type() == ast.TypeInline</code> 都不能 <code>Lines()</code>。寫 rule 時永遠用 <code>nodeLine</code> helper。</p>
<h3 id="忘記-gfm-extensiontable-節點會少">忘記 GFM extension，Table 節點會少</h3>
<p>預設 <code>goldmark.New()</code> 沒開 GFM。content 裡的表格會被當成普通段落 parse，<code>ast.Walk</code> 根本找不到 <code>*extension.ast.Table</code> 節點。永遠在 <code>goldmark.WithExtensions(extension.GFM)</code>。</p>
<h3 id="用-stringsrc-當作可變字串操作">用 <code>string(src)</code> 當作可變字串操作</h3>
<p>goldmark 預期 src 在 Parse 過程中 <strong>保持不變</strong>。若要改動，應先讀 <code>src</code>、parse、收集位置、<strong>產生新 byte slice</strong>；以不可變輸入 + 新輸出替代 in-place mutation。</p>
<h3 id="astwalk-忘記回傳-continue"><code>ast.Walk</code> 忘記回傳 continue</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">ast</span><span class="p">.</span><span class="nf">Walk</span><span class="p">(</span><span class="nx">doc</span><span class="p">,</span> <span class="kd">func</span><span class="p">(</span><span class="nx">n</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">Node</span><span class="p">,</span> <span class="nx">entering</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">(</span><span class="nx">ast</span><span class="p">.</span><span class="nx">WalkStatus</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">	<span class="k">if</span> <span class="nx">someCondition</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">		<span class="nf">processNode</span><span class="p">(</span><span class="nx">n</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">		<span class="c1">// 忘記 return，編譯失敗；但加 return 0 會提前終止</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">	<span class="k">return</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">WalkContinue</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><p>預設要回 <code>ast.WalkContinue, nil</code>。早退用 <code>ast.WalkSkipChildren</code> 或 <code>ast.WalkStop</code>，別用 bare <code>return</code>。</p>
<h2 id="擴充路徑">擴充路徑</h2>
<ul>
<li><strong>解析自己的 Go 原始碼</strong>：改用 <code>go/parser</code> + <code>go/ast</code>。語法樹更複雜（型別、scope、import），但 visitor pattern 本質一樣。參考 gopls 或 stringer 的原始碼。</li>
<li><strong>寫自訂 extension</strong>：goldmark 允許註冊 parse-time 與 render-time 的 extension（自己的 block / inline 語法、或接管某個節點的 render 行為）。但除非你的 markdown 有特殊語法（Hugo shortcode 之類），大多數工具不用走這層。</li>
<li><strong>AST 快照比對測試</strong>：用 <code>go-cmp</code> 比對 <code>ast.Walk</code> 抓出的節點序列；新版 goldmark 升級時能快速發現相容性問題。</li>
</ul>
<h2 id="下一步">下一步</h2>
<p><a href="/blog/go/09-tooling-and-analysis/ast-idempotent-rewriting/" data-link-title="9.3 AST 驅動的 idempotent 文字改寫" data-link-desc="用 AST 定位位置、用 line-based 或 byte-level 改寫；設計多條 rule 的執行順序；--check 跟 --fix 如何共用邏輯">9.3 AST 驅動的 idempotent 文字改寫</a> 會接著看怎麼從「讀 AST」走到「改原檔案」— 這是 <code>mdtools fmt --fix</code> 的核心。</p>
]]></content:encoded></item><item><title>9.3 AST 驅動的 idempotent 文字改寫</title><link>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/ast-idempotent-rewriting/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/ast-idempotent-rewriting/</guid><description>&lt;p>AST 驅動文字改寫的核心契約是 &lt;strong>&lt;a href="https://tarrragon.github.io/blog/go/glossary/#idempotent-%e6%96%87%e5%ad%97%e6%94%b9%e5%af%ab" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">idempotent&lt;/a>&lt;/strong>：對同一輸入跑一次或十次結果相同。這個契約讓工具能安全地接到 &lt;a href="https://tarrragon.github.io/blog/go/glossary/#pre-commit-hook-%e5%ae%9a%e4%bd%8d" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">pre-commit hook&lt;/a>（每次 commit 都跑不會累積漂移）、能分段除錯（改一條 rule 不會破壞其他 rule 的輸出）、能用 &lt;code>--check&lt;/code> 跟 &lt;code>--fix&lt;/code> 共用同一套邏輯（差別只在要不要寫檔）。&lt;code>gofmt&lt;/code>、&lt;code>prettier&lt;/code>、&lt;code>ruff fix&lt;/code> 這類工具在工程界立信譽的基礎就是冪等。&lt;/p>
&lt;p>設計一個冪等的改寫流水線有三個配合層：&lt;strong>策略選擇&lt;/strong>（AST round-trip / byte surgical / 混合）、&lt;strong>rule 鏈順序&lt;/strong>（每條 rule 的輸出要是下一條的合法輸入）、&lt;strong>context 重算紀律&lt;/strong>（行數變動後索引要重建）。本章依序展開這三層，並以 &lt;code>mdtools fmt --fix&lt;/code> 的 &lt;code>applyAll&lt;/code> 為 concrete instance。&lt;/p>
&lt;h2 id="改檔的三種策略">改檔的三種策略&lt;/h2>
&lt;p>把 AST 資訊轉成檔案修改有三條路：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>策略&lt;/th>
 &lt;th>做法&lt;/th>
 &lt;th>優點&lt;/th>
 &lt;th>缺點&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>AST 重新 serialize&lt;/td>
 &lt;td>parse → 在 AST 上改 → 用 renderer 寫回 markdown&lt;/td>
 &lt;td>概念乾淨；不會遺漏結構&lt;/td>
 &lt;td>goldmark 的 renderer &lt;strong>不保證 round-trip 精確&lt;/strong>；diff 會爆炸&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>位置導向 byte 改寫&lt;/td>
 &lt;td>用 AST 找違規節點的 offset，外科手術式字串編輯&lt;/td>
 &lt;td>diff 只影響違規處，保留原格式細節&lt;/td>
 &lt;td>byte offset 要嚴謹管理；規則越多越囉嗦&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>混合：line-based + AST-guided&lt;/td>
 &lt;td>行內能解決的（空行、trailing newline）用逐行處理；複雜的（URL 縮短、結構重構）用 AST 找位置&lt;/td>
 &lt;td>取兩者之長；簡單規則簡單寫&lt;/td>
 &lt;td>要明確劃分哪條 rule 用哪種策略&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>mdtools 選第三條。純 AST round-trip 在長尾場景（nested fence、特殊 escape）會產出跟 source 不等價的 markdown；純 byte-offset 讓 MD047（trailing newline）這類微瑣事變得囉嗦。混合讓每條 rule 在它最自然的層級解決。&lt;/p>
&lt;p>設計原則：&lt;strong>能用 line-based 就用，AST 只在真的需要語意判讀時才上&lt;/strong>。&lt;/p>
&lt;h2 id="rule-鏈的結構">Rule 鏈的結構&lt;/h2>
&lt;p>&lt;code>mdtools fmt&lt;/code> 的 &lt;code>applyAll&lt;/code> 是條流水線：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// scripts/mdtools/internal/mdfmt/fixer.go&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">applyAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">data&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">byte&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">cfg&lt;/span> &lt;span class="nx">rules&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Config&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">byte&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">	&lt;span class="nx">lines&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">splitLines&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">	&lt;span class="c1">// MD026 — 標題結尾標點，line-preserving&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">	&lt;span class="k">if&lt;/span> &lt;span class="nx">cfg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Headings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ForbidTrailingPunct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">		&lt;span class="nx">ctx&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">AnalyzeLines&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">		&lt;span class="nx">lines&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nf">FixHeadingTrailingPunct&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">cfg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Headings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ForbiddenTrailingPunct&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">	&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">	&lt;span class="c1">// MD022 — 標題前後空行，line-count changing&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">	&lt;span class="k">if&lt;/span> &lt;span class="nx">cfg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Headings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RequireBlankLines&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">		&lt;span class="nx">ctx&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">AnalyzeLines&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">		&lt;span class="nx">lines&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nf">FixHeadingBlankLines&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ctx&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">	&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">	&lt;span class="c1">// MD031 — fenced code block 前後空行&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">	&lt;span class="k">if&lt;/span> &lt;span class="nx">cfg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">CodeBlocks&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RequireBlankLinesAround&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">		&lt;span class="nx">ctx&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">AnalyzeLines&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">		&lt;span class="nx">lines&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nf">FixFencedCodeBlankLines&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ctx&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">	&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">	&lt;span class="c1">// MD032 — 列表前後空行&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">	&lt;span class="nx">ctx&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">AnalyzeLines&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">	&lt;span class="nx">lines&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nf">FixListBlankLines&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ctx&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl">	&lt;span class="c1">// MD060 — 表格對齊&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl">	&lt;span class="nx">ctx&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nf">AnalyzeLines&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl">	&lt;span class="nx">lines&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nf">FixTables&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">cfg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Tables&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl">	&lt;span class="c1">// MD034 — 裸 URL 縮短&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&lt;/span>&lt;span class="cl">	&lt;span class="nx">ctx&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nf">AnalyzeLines&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">33&lt;/span>&lt;span class="cl">	&lt;span class="nx">lines&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nf">FixBareURLs&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">cfg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">URLs&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">34&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">35&lt;/span>&lt;span class="cl">	&lt;span class="nx">out&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">joinLines&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">36&lt;/span>&lt;span class="cl">	&lt;span class="nx">out&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nf">EnsureTrailingNewline&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">out&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1">// MD047&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">37&lt;/span>&lt;span class="cl">	&lt;span class="k">return&lt;/span> &lt;span class="nx">out&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">38&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>幾個&lt;strong>設計決策&lt;/strong>值得拆開看。&lt;/p>
&lt;h3 id="順序決定結果">順序決定結果&lt;/h3>
&lt;p>Rule 順序有明確的依賴判準：&lt;strong>每條 rule 的輸出應該是下一條 rule 的合法輸入&lt;/strong>。&lt;/p></description><content:encoded><![CDATA[<p>AST 驅動文字改寫的核心契約是 <strong><a href="/blog/go/glossary/#idempotent-%e6%96%87%e5%ad%97%e6%94%b9%e5%af%ab" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">idempotent</a></strong>：對同一輸入跑一次或十次結果相同。這個契約讓工具能安全地接到 <a href="/blog/go/glossary/#pre-commit-hook-%e5%ae%9a%e4%bd%8d" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">pre-commit hook</a>（每次 commit 都跑不會累積漂移）、能分段除錯（改一條 rule 不會破壞其他 rule 的輸出）、能用 <code>--check</code> 跟 <code>--fix</code> 共用同一套邏輯（差別只在要不要寫檔）。<code>gofmt</code>、<code>prettier</code>、<code>ruff fix</code> 這類工具在工程界立信譽的基礎就是冪等。</p>
<p>設計一個冪等的改寫流水線有三個配合層：<strong>策略選擇</strong>（AST round-trip / byte surgical / 混合）、<strong>rule 鏈順序</strong>（每條 rule 的輸出要是下一條的合法輸入）、<strong>context 重算紀律</strong>（行數變動後索引要重建）。本章依序展開這三層，並以 <code>mdtools fmt --fix</code> 的 <code>applyAll</code> 為 concrete instance。</p>
<h2 id="改檔的三種策略">改檔的三種策略</h2>
<p>把 AST 資訊轉成檔案修改有三條路：</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>做法</th>
          <th>優點</th>
          <th>缺點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>AST 重新 serialize</td>
          <td>parse → 在 AST 上改 → 用 renderer 寫回 markdown</td>
          <td>概念乾淨；不會遺漏結構</td>
          <td>goldmark 的 renderer <strong>不保證 round-trip 精確</strong>；diff 會爆炸</td>
      </tr>
      <tr>
          <td>位置導向 byte 改寫</td>
          <td>用 AST 找違規節點的 offset，外科手術式字串編輯</td>
          <td>diff 只影響違規處，保留原格式細節</td>
          <td>byte offset 要嚴謹管理；規則越多越囉嗦</td>
      </tr>
      <tr>
          <td>混合：line-based + AST-guided</td>
          <td>行內能解決的（空行、trailing newline）用逐行處理；複雜的（URL 縮短、結構重構）用 AST 找位置</td>
          <td>取兩者之長；簡單規則簡單寫</td>
          <td>要明確劃分哪條 rule 用哪種策略</td>
      </tr>
  </tbody>
</table>
<p>mdtools 選第三條。純 AST round-trip 在長尾場景（nested fence、特殊 escape）會產出跟 source 不等價的 markdown；純 byte-offset 讓 MD047（trailing newline）這類微瑣事變得囉嗦。混合讓每條 rule 在它最自然的層級解決。</p>
<p>設計原則：<strong>能用 line-based 就用，AST 只在真的需要語意判讀時才上</strong>。</p>
<h2 id="rule-鏈的結構">Rule 鏈的結構</h2>
<p><code>mdtools fmt</code> 的 <code>applyAll</code> 是條流水線：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// scripts/mdtools/internal/mdfmt/fixer.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">applyAll</span><span class="p">(</span><span class="nx">data</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">,</span> <span class="nx">cfg</span> <span class="nx">rules</span><span class="p">.</span><span class="nx">Config</span><span class="p">)</span> <span class="p">[]</span><span class="kt">byte</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="nx">lines</span> <span class="o">:=</span> <span class="nf">splitLines</span><span class="p">(</span><span class="nx">data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">	<span class="c1">// MD026 — 標題結尾標點，line-preserving</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	<span class="k">if</span> <span class="nx">cfg</span><span class="p">.</span><span class="nx">Headings</span><span class="p">.</span><span class="nx">ForbidTrailingPunct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">		<span class="nx">ctx</span> <span class="o">:=</span> <span class="nf">AnalyzeLines</span><span class="p">(</span><span class="nx">lines</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">		<span class="nx">lines</span> <span class="p">=</span> <span class="nf">FixHeadingTrailingPunct</span><span class="p">(</span><span class="nx">lines</span><span class="p">,</span> <span class="nx">ctx</span><span class="p">,</span> <span class="nx">cfg</span><span class="p">.</span><span class="nx">Headings</span><span class="p">.</span><span class="nx">ForbiddenTrailingPunct</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">	<span class="c1">// MD022 — 標題前後空行，line-count changing</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">	<span class="k">if</span> <span class="nx">cfg</span><span class="p">.</span><span class="nx">Headings</span><span class="p">.</span><span class="nx">RequireBlankLines</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">		<span class="nx">ctx</span> <span class="o">:=</span> <span class="nf">AnalyzeLines</span><span class="p">(</span><span class="nx">lines</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">		<span class="nx">lines</span> <span class="p">=</span> <span class="nf">FixHeadingBlankLines</span><span class="p">(</span><span class="nx">lines</span><span class="p">,</span> <span class="nx">ctx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">	<span class="c1">// MD031 — fenced code block 前後空行</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">	<span class="k">if</span> <span class="nx">cfg</span><span class="p">.</span><span class="nx">CodeBlocks</span><span class="p">.</span><span class="nx">RequireBlankLinesAround</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">		<span class="nx">ctx</span> <span class="o">:=</span> <span class="nf">AnalyzeLines</span><span class="p">(</span><span class="nx">lines</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">		<span class="nx">lines</span> <span class="p">=</span> <span class="nf">FixFencedCodeBlankLines</span><span class="p">(</span><span class="nx">lines</span><span class="p">,</span> <span class="nx">ctx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">
</span></span><span class="line"><span class="ln">23</span><span class="cl">	<span class="c1">// MD032 — 列表前後空行</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">	<span class="nx">ctx</span> <span class="o">:=</span> <span class="nf">AnalyzeLines</span><span class="p">(</span><span class="nx">lines</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">	<span class="nx">lines</span> <span class="p">=</span> <span class="nf">FixListBlankLines</span><span class="p">(</span><span class="nx">lines</span><span class="p">,</span> <span class="nx">ctx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">
</span></span><span class="line"><span class="ln">27</span><span class="cl">	<span class="c1">// MD060 — 表格對齊</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">	<span class="nx">ctx</span> <span class="p">=</span> <span class="nf">AnalyzeLines</span><span class="p">(</span><span class="nx">lines</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">	<span class="nx">lines</span> <span class="p">=</span> <span class="nf">FixTables</span><span class="p">(</span><span class="nx">lines</span><span class="p">,</span> <span class="nx">ctx</span><span class="p">,</span> <span class="nx">cfg</span><span class="p">.</span><span class="nx">Tables</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">
</span></span><span class="line"><span class="ln">31</span><span class="cl">	<span class="c1">// MD034 — 裸 URL 縮短</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">	<span class="nx">ctx</span> <span class="p">=</span> <span class="nf">AnalyzeLines</span><span class="p">(</span><span class="nx">lines</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">	<span class="nx">lines</span> <span class="p">=</span> <span class="nf">FixBareURLs</span><span class="p">(</span><span class="nx">lines</span><span class="p">,</span> <span class="nx">ctx</span><span class="p">,</span> <span class="nx">cfg</span><span class="p">.</span><span class="nx">URLs</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">
</span></span><span class="line"><span class="ln">35</span><span class="cl">	<span class="nx">out</span> <span class="o">:=</span> <span class="nf">joinLines</span><span class="p">(</span><span class="nx">lines</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">	<span class="nx">out</span> <span class="p">=</span> <span class="nf">EnsureTrailingNewline</span><span class="p">(</span><span class="nx">out</span><span class="p">)</span> <span class="c1">// MD047</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">	<span class="k">return</span> <span class="nx">out</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>幾個<strong>設計決策</strong>值得拆開看。</p>
<h3 id="順序決定結果">順序決定結果</h3>
<p>Rule 順序有明確的依賴判準：<strong>每條 rule 的輸出應該是下一條 rule 的合法輸入</strong>。</p>
<ul>
<li>MD026 先跑：它改標題內容、不改行數，後面 rule 的行號不會位移。</li>
<li>MD022 / MD031 / MD032 緊接著：這些都 insert blank lines，會改行數；但它們彼此之間不衝突（heading ≠ fence ≠ list）。</li>
<li>MD060 表格對齊在 URL 縮短之前：讓表格先成為可解析結構，URL rule 才能正確判斷「這個 URL 在表格 cell 內」。</li>
<li>MD034 URL 縮短最後：URL 變短會讓表格欄寬變化；但因為 MD060 已經做過對齊，後續工具會再跑一次 fmt &ndash;fix 重新對齊。這個「跑兩次才穩定」的特性是可接受的，因為 fmt &ndash;fix 本來就冪等。</li>
<li>MD047 trailing newline 在 byte 層做，最後一步。</li>
</ul>
<h3 id="每條-rule-重新-analyze-context">每條 rule 重新 analyze context</h3>
<p><code>AnalyzeLines(lines)</code> 在每個會變行數的 rule 之前重跑。為什麼：</p>
<ul>
<li>上一條 rule 可能把 fence 或 front matter 位置推後。</li>
<li>Context 裡的 <code>Skip[]</code>、<code>FenceOpen[]</code>、<code>FenceClose[]</code> 都是按行索引儲存。</li>
<li>行數改變 → 索引失效 → 必須重算。</li>
</ul>
<p>成本是 O(N)，對 500-行檔案微秒級。在整體 pipeline 中可忽略。</p>
<h3 id="line-based-rule-本體範例">Line-based rule 本體範例</h3>
<p>以 MD022（標題前後空行）為例：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// scripts/mdtools/internal/mdfmt/rules.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">FixHeadingBlankLines</span><span class="p">(</span><span class="nx">lines</span> <span class="p">[]</span><span class="kt">string</span><span class="p">,</span> <span class="nx">ctx</span> <span class="nx">LineContext</span><span class="p">)</span> <span class="p">[]</span><span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">lines</span><span class="p">)</span> <span class="o">==</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">		<span class="k">return</span> <span class="nx">lines</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	<span class="nx">out</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="kt">string</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">lines</span><span class="p">)</span><span class="o">+</span><span class="mi">8</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">	<span class="k">for</span> <span class="nx">i</span><span class="p">,</span> <span class="nx">line</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">lines</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">		<span class="nx">isHdr</span> <span class="o">:=</span> <span class="p">!</span><span class="nx">ctx</span><span class="p">.</span><span class="nx">Skip</span><span class="p">[</span><span class="nx">i</span><span class="p">]</span> <span class="o">&amp;&amp;</span> <span class="nf">isHeadingLine</span><span class="p">(</span><span class="nx">line</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">		<span class="k">if</span> <span class="nx">isHdr</span> <span class="o">&amp;&amp;</span> <span class="nb">len</span><span class="p">(</span><span class="nx">out</span><span class="p">)</span> <span class="p">&gt;</span> <span class="mi">0</span> <span class="o">&amp;&amp;</span> <span class="p">!</span><span class="nf">isBlank</span><span class="p">(</span><span class="nx">out</span><span class="p">[</span><span class="nb">len</span><span class="p">(</span><span class="nx">out</span><span class="p">)</span><span class="o">-</span><span class="mi">1</span><span class="p">])</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">			<span class="nx">out</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">out</span><span class="p">,</span> <span class="s">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">		<span class="nx">out</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">out</span><span class="p">,</span> <span class="nx">line</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">		<span class="k">if</span> <span class="nx">isHdr</span> <span class="o">&amp;&amp;</span> <span class="nx">i</span><span class="o">+</span><span class="mi">1</span> <span class="p">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="nx">lines</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="p">!</span><span class="nf">isBlank</span><span class="p">(</span><span class="nx">lines</span><span class="p">[</span><span class="nx">i</span><span class="o">+</span><span class="mi">1</span><span class="p">])</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">			<span class="nx">out</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">out</span><span class="p">,</span> <span class="s">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">	<span class="k">return</span> <span class="nx">out</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>關鍵 idempotent 技巧：</p>
<ul>
<li><strong>判斷「上一行不是 blank 就插」而非「永遠插」</strong>。已經插過 blank 的情況下，第二次跑會看到 blank，跳過，結果相同。</li>
<li><strong>out 是新 slice</strong>，不改動原 lines。函式純粹。</li>
<li><strong>look-ahead 看原 lines</strong>，避免剛插的 blank 讓邏輯誤判下一輪。</li>
</ul>
<h2 id="ast-guided-rule-範例md034-url-縮短">AST-guided rule 範例：MD034 URL 縮短</h2>
<p>這條 rule 用 AST 找「哪些 text 是 link 之外的」，行內用 regex + mask 處理：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// scripts/mdtools/internal/mdfmt/urls.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">rewriteBareURLsInLine</span><span class="p">(</span><span class="nx">line</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">cfg</span> <span class="nx">rules</span><span class="p">.</span><span class="nx">URLRules</span><span class="p">,</span> <span class="nx">idPatterns</span> <span class="p">[]</span><span class="o">*</span><span class="nx">regexp</span><span class="p">.</span><span class="nx">Regexp</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="nx">masked</span> <span class="o">:=</span> <span class="nf">collectMaskedRanges</span><span class="p">(</span><span class="nx">line</span><span class="p">)</span> <span class="c1">// [...](/go/09-tooling-and-analysis/ast-idempotent-rewriting/...) / &lt;...&gt; / `...` 的位置</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="nx">matches</span> <span class="o">:=</span> <span class="nx">bareURLRe</span><span class="p">.</span><span class="nf">FindAllStringIndex</span><span class="p">(</span><span class="nx">line</span><span class="p">,</span> <span class="o">-</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">	<span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">matches</span><span class="p">)</span> <span class="o">==</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">		<span class="k">return</span> <span class="nx">line</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">	<span class="kd">var</span> <span class="nx">b</span> <span class="nx">strings</span><span class="p">.</span><span class="nx">Builder</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">	<span class="nx">cursor</span> <span class="o">:=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">	<span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">m</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">matches</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">		<span class="nx">start</span><span class="p">,</span> <span class="nx">end</span> <span class="o">:=</span> <span class="nx">m</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="nx">m</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">		<span class="nx">end</span> <span class="p">=</span> <span class="nf">trimURLTrailPunct</span><span class="p">(</span><span class="nx">line</span><span class="p">,</span> <span class="nx">start</span><span class="p">,</span> <span class="nx">end</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">		<span class="nx">b</span><span class="p">.</span><span class="nf">WriteString</span><span class="p">(</span><span class="nx">line</span><span class="p">[</span><span class="nx">cursor</span><span class="p">:</span><span class="nx">start</span><span class="p">])</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">		<span class="k">if</span> <span class="nf">inMasked</span><span class="p">(</span><span class="nx">masked</span><span class="p">,</span> <span class="nx">start</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">			<span class="nx">b</span><span class="p">.</span><span class="nf">WriteString</span><span class="p">(</span><span class="nx">line</span><span class="p">[</span><span class="nx">start</span><span class="p">:</span><span class="nx">end</span><span class="p">])</span> <span class="c1">// 已在 link / code span / 角括號內</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">		<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">			<span class="nx">rawURL</span> <span class="o">:=</span> <span class="nx">line</span><span class="p">[</span><span class="nx">start</span><span class="p">:</span><span class="nx">end</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">			<span class="nx">display</span> <span class="o">:=</span> <span class="nf">shortenURL</span><span class="p">(</span><span class="nx">rawURL</span><span class="p">,</span> <span class="nx">cfg</span><span class="p">,</span> <span class="nx">idPatterns</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">			<span class="nx">fmt</span><span class="p">.</span><span class="nf">Fprintf</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">b</span><span class="p">,</span> <span class="s">&#34;[%s](/go/09-tooling-and-analysis/ast-idempotent-rewriting/%s)&#34;</span><span class="p">,</span> <span class="nx">display</span><span class="p">,</span> <span class="nx">rawURL</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">		<span class="nx">cursor</span> <span class="p">=</span> <span class="nx">end</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">	<span class="nx">b</span><span class="p">.</span><span class="nf">WriteString</span><span class="p">(</span><span class="nx">line</span><span class="p">[</span><span class="nx">cursor</span><span class="p">:])</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">	<span class="k">return</span> <span class="nx">b</span><span class="p">.</span><span class="nf">String</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這裡的 <strong>混合精神</strong>：</p>
<ul>
<li>AST 不直接改檔，只提供「這行的哪些 byte 範圍是 existing link / code span」的判讀（實際上這段用了 regex 模擬；真正嚴謹時會改用 AST 定位）。</li>
<li>真的改寫走字串層級，保留原格式。</li>
<li>已在 link 內的 URL 不再包第二層 — <code>inMasked</code> 檢查防止 double-wrap，這也是 <strong>idempotent 關鍵</strong>：第二次跑，所有 URL 都已經在 masked range 裡，跳過。</li>
</ul>
<h2 id="--check-跟---fix-共用邏輯"><code>--check</code> 跟 <code>--fix</code> 共用邏輯</h2>
<p>一個常見反 pattern 是：<code>check</code> 模式重寫一次邏輯「看會不會改」，<code>fix</code> 模式真的改。兩套邏輯一旦漂移，誤報或漏報就出現。</p>
<p>正確做法是<strong>共用同一個 FormatFile，然後比對結果</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// scripts/mdtools/internal/mdfmt/fixer.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">FormatFile</span><span class="p">(</span><span class="nx">path</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">cfg</span> <span class="nx">rules</span><span class="p">.</span><span class="nx">Config</span><span class="p">)</span> <span class="p">(</span><span class="nx">FixResult</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="nx">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">ReadFile</span><span class="p">(</span><span class="nx">path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">		<span class="k">return</span> <span class="nx">FixResult</span><span class="p">{},</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">	<span class="nx">fixed</span> <span class="o">:=</span> <span class="nf">applyAll</span><span class="p">(</span><span class="nx">data</span><span class="p">,</span> <span class="nx">cfg</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">	<span class="k">return</span> <span class="nx">FixResult</span><span class="p">{</span><span class="nx">Path</span><span class="p">:</span> <span class="nx">path</span><span class="p">,</span> <span class="nx">Original</span><span class="p">:</span> <span class="nx">data</span><span class="p">,</span> <span class="nx">Fixed</span><span class="p">:</span> <span class="nx">fixed</span><span class="p">},</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">// check / fix 都呼叫 FormatFile，只差在怎麼處理結果</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="nx">FixResult</span><span class="p">)</span> <span class="nf">Changed</span><span class="p">()</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">	<span class="k">return</span> <span class="p">!</span><span class="nx">bytes</span><span class="p">.</span><span class="nf">Equal</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Original</span><span class="p">,</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Fixed</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>子命令層處理差異：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// cmd/fmt.go 簡化版</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nx">result</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">mdfmt</span><span class="p">.</span><span class="nf">FormatFile</span><span class="p">(</span><span class="nx">path</span><span class="p">,</span> <span class="nx">cfg</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="k">if</span> <span class="p">!</span><span class="nx">result</span><span class="p">.</span><span class="nf">Changed</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="k">return</span> <span class="c1">// 沒改動，跳過</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="k">if</span> <span class="o">*</span><span class="nx">fix</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">	<span class="nx">os</span><span class="p">.</span><span class="nf">WriteFile</span><span class="p">(</span><span class="nx">path</span><span class="p">,</span> <span class="nx">result</span><span class="p">.</span><span class="nx">Fixed</span><span class="p">,</span> <span class="mi">0</span><span class="nx">o644</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">	<span class="nx">fmt</span><span class="p">.</span><span class="nf">Printf</span><span class="p">(</span><span class="s">&#34;would fix: %s\n&#34;</span><span class="p">,</span> <span class="nx">path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><strong>check 跟 fix 跑一模一樣的 rule chain，只是其中一個寫檔、另一個回報</strong>。這個結構讓兩個模式的行為<strong>保證一致</strong>。</p>
<h2 id="idempotent-的驗證方式">Idempotent 的驗證方式</h2>
<p>工具宣稱冪等，測試要驗證：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestFormatIdempotent</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">	<span class="nx">inputs</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">filepath</span><span class="p">.</span><span class="nf">Glob</span><span class="p">(</span><span class="s">&#34;testdata/*.md&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="nx">cfg</span> <span class="o">:=</span> <span class="nx">rules</span><span class="p">.</span><span class="nf">Default</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">in</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">inputs</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">		<span class="nx">data</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">ReadFile</span><span class="p">(</span><span class="nx">in</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">		<span class="nx">once</span> <span class="o">:=</span> <span class="nf">applyAll</span><span class="p">(</span><span class="nx">data</span><span class="p">,</span> <span class="nx">cfg</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">		<span class="nx">twice</span> <span class="o">:=</span> <span class="nf">applyAll</span><span class="p">(</span><span class="nx">once</span><span class="p">,</span> <span class="nx">cfg</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">		<span class="k">if</span> <span class="p">!</span><span class="nx">bytes</span><span class="p">.</span><span class="nf">Equal</span><span class="p">(</span><span class="nx">once</span><span class="p">,</span> <span class="nx">twice</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">			<span class="nx">t</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;%s: not idempotent (applied twice != once)&#34;</span><span class="p">,</span> <span class="nx">in</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>生產環境的 pre-commit hook 本質上每次 commit 都在驗證冪等：</p>
<ol>
<li>作者寫 commit → <code>fmt --fix</code> 跑過 → re-stage</li>
<li>如果邏輯不冪等，下次作者改同檔案，可能又會被改回/改去</li>
<li>使用者很快會發現「為什麼這個工具一直來回改我的檔案」</li>
</ol>
<p>冪等是 pre-commit 的信譽基礎。</p>
<h2 id="常見陷阱">常見陷阱</h2>
<h3 id="rule-之間互相抵消">Rule 之間互相抵消</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Rule A: 移除行尾空白
</span></span><span class="line"><span class="ln">2</span><span class="cl">Rule B: 把每個 heading 後面補空格到 60 欄</span></span></code></pre></div><p>A 跟 B 串起來會永遠改來改去。寫 rule 時要想「其他 rule 會對我的輸出做什麼」。</p>
<h3 id="讀-src-跟改-src-用不同的-byte-slice">讀 src 跟改 src 用不同的 byte slice</h3>
<p>在迴圈中一邊掃 <code>data</code>、一邊 append 到 <code>out</code>，中間忘了切換視角。建議永遠遵循 <code>(原 lines, 新 out)</code> 兩個名字，迴圈體只 look-back 到 <code>out[len(out)-1]</code> 或 look-ahead 到 <code>lines[i+1]</code>，絕不在同一時段既讀又寫同一 slice。</p>
<h3 id="trailing-newline-的邊界">Trailing newline 的邊界</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">bytes</span><span class="p">.</span><span class="nf">TrimRight</span><span class="p">(</span><span class="nx">data</span><span class="p">,</span> <span class="s">&#34;\r\n&#34;</span><span class="p">)</span>      <span class="c1">// 去掉全部</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">return</span> <span class="nb">append</span><span class="p">(</span><span class="nx">data</span><span class="p">,</span> <span class="sc">&#39;\n&#39;</span><span class="p">)</span>           <span class="c1">// 加一個</span></span></span></code></pre></div><p>空檔案要特別處理 — 加了 <code>\n</code> 就變非空。mdtools 的作法是：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">data</span><span class="p">)</span> <span class="o">==</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">	<span class="k">return</span> <span class="nx">data</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>空檔保持空。</p>
<h3 id="regex-based-url-偵測的邊界">Regex-based URL 偵測的邊界</h3>
<p>Go 的 RE2 沒有 lookbehind，無法用 regex 直接寫「URL 不在 <code>](</code> 後面」。解法是先掃 mask（link span、angle bracket、code span），再跑 URL regex，match 結果對照 mask 決定是否替換。<code>collectMaskedRanges</code> 就是這個模式。</p>
<h2 id="擴充路徑">擴充路徑</h2>
<ul>
<li><strong>Rule dry-run diff</strong>：把每條 rule 單獨跑一遍，輸出每條 rule 改了哪幾個檔案。debug 為什麼某檔案被改時用得到。</li>
<li><strong>Configurable rule disabling</strong>：把 rule 開關改成 front matter 級別（<code>mdtools-disable: MD026</code>），讓個別檔案能 opt-out。</li>
<li><strong>Rule 可程式化插入</strong>：把 <code>applyAll</code> 改成「讀 config → 產生 rule list → iterate」，讓新 rule 不用改 fixer.go 而是註冊進來。</li>
</ul>
<h2 id="下一步">下一步</h2>
<p><a href="/blog/go/09-tooling-and-analysis/cross-file-graph-analysis/" data-link-title="9.4 跨檔案圖分析：從 lint 走到 static analysis" data-link-desc="Single-file 規則用 AST 搞定；跨檔 orphan 偵測、broken link、backlink 完整性需要把整個 repo 建成圖再走訪。用 mdtools cards 為例">9.4 跨檔案圖分析</a> 離開 single-file 世界，看 <code>mdtools cards</code> 怎麼建整個 repo 的 link graph 跑反向查詢。</p>
]]></content:encoded></item><item><title>9.4 跨檔案圖分析：從 lint 走到 static analysis</title><link>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/cross-file-graph-analysis/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/cross-file-graph-analysis/</guid><description>&lt;p>跨檔案靜態分析的核心責任是&lt;strong>把整個 repo 結構化成可查詢的 &lt;a href="https://tarrragon.github.io/blog/go/glossary/#%e8%b7%a8%e6%aa%94%e6%a1%88-link-graph" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">link graph&lt;/a>&lt;/strong>，讓「這張卡片有沒有被引用」「這個連結指的目標存在嗎」「這個 section 是否被孤立」這類反向/跨檔問題能在 O(1) 或 O(log n) 的 graph lookup 內回答，而不是每次查詢都重 parse 全部檔案。圖的節點是檔案、邊是檔案間的連結 / 引用 / 依賴關係；一次 parse（用 &lt;a href="https://tarrragon.github.io/blog/go/glossary/#ast-walker" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">AST walker&lt;/a> 掃過）之後，所有跨檔 query 都在 in-memory map 上做。&lt;/p>
&lt;p>這類分析的典型觸發點是需求已經離開 single-file 層：&lt;strong>orphan 偵測&lt;/strong>（某個檔案是否被引用）、&lt;strong>backlink 完整性&lt;/strong>（連結目標是否存在）、&lt;strong>dependency cycle 檢測&lt;/strong>（import graph 是否有環）、&lt;strong>unused export 偵測&lt;/strong>（某個 symbol 是否被使用）。每個都是圖論問題，需要先把 repo 結構化，單檔 walker 看不見跨檔 edge。本章以 &lt;code>mdtools cards&lt;/code>（L1 連結有效性、L2 orphan 卡片、L4 卡片 K4 合規）作為 concrete instance。&lt;/p>
&lt;h2 id="為什麼要預先建圖而非每次-lint-都現查">為什麼要預先建圖而非每次 lint 都現查&lt;/h2>
&lt;p>直覺會說：對每個 link，直接 &lt;code>os.Stat(target)&lt;/code> 看存在不存在，就能驗證 L1。&lt;/p>
&lt;p>這個做法在 100 個檔案、每檔 10 個 link 時 OK — 1000 次 stat、每次 &amp;lt; 1ms，總計 1 秒內。但一旦要做 L2 「每張卡片至少被一篇正文引用」，問題就變成：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">for each card file C:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> for each other file F:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> parse F
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> for each link L in F:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> if L points to C: mark C as referenced&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>N² parse，每次 parse 又走 AST。1000 檔 × 1000 檔 = 100 萬次 parse，每次 50ms，總計 14 小時。&lt;/p>
&lt;p>解法是 &lt;strong>parse 一次、存下所有 edge、在圖上查&lt;/strong>。Parse 是 O(N) 一次；所有後續 query 都在 in-memory map 上做，microseconds。&lt;/p>
&lt;h2 id="graph-的資料結構">Graph 的資料結構&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// scripts/mdtools/internal/mdcards/graph.go&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Edge&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">	&lt;span class="nx">SourcePath&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="c1">// 包含連結的檔案&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">	&lt;span class="nx">SourceLine&lt;/span> &lt;span class="kt">int&lt;/span> &lt;span class="c1">// 連結所在行&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">	&lt;span class="nx">Destination&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="c1">// link 目的地（原文）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">	&lt;span class="nx">Target&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="c1">// 解析後的檔案路徑（可能不存在）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">	&lt;span class="nx">DisplayText&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="c1">// 顯示文字（反釣魚用）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">FileNode&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">	&lt;span class="nx">Path&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">	&lt;span class="nx">AST&lt;/span> &lt;span class="nx">ast&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Node&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">	&lt;span class="nx">Src&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">byte&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Graph&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">	&lt;span class="nx">Files&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nx">FileNode&lt;/span> &lt;span class="c1">// 所有 .md 檔&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">	&lt;span class="nx">Edges&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nx">Edge&lt;/span> &lt;span class="c1">// 所有相對連結&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">	&lt;span class="nx">byPath&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="nx">FileNode&lt;/span> &lt;span class="c1">// path → FileNode&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">	&lt;span class="nx">inbound&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">][]&lt;/span>&lt;span class="kt">int&lt;/span> &lt;span class="c1">// target path → edge indices&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">	&lt;span class="nx">outbound&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">][]&lt;/span>&lt;span class="kt">int&lt;/span> &lt;span class="c1">// source path → edge indices&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>設計重點：&lt;/p></description><content:encoded><![CDATA[<p>跨檔案靜態分析的核心責任是<strong>把整個 repo 結構化成可查詢的 <a href="/blog/go/glossary/#%e8%b7%a8%e6%aa%94%e6%a1%88-link-graph" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">link graph</a></strong>，讓「這張卡片有沒有被引用」「這個連結指的目標存在嗎」「這個 section 是否被孤立」這類反向/跨檔問題能在 O(1) 或 O(log n) 的 graph lookup 內回答，而不是每次查詢都重 parse 全部檔案。圖的節點是檔案、邊是檔案間的連結 / 引用 / 依賴關係；一次 parse（用 <a href="/blog/go/glossary/#ast-walker" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">AST walker</a> 掃過）之後，所有跨檔 query 都在 in-memory map 上做。</p>
<p>這類分析的典型觸發點是需求已經離開 single-file 層：<strong>orphan 偵測</strong>（某個檔案是否被引用）、<strong>backlink 完整性</strong>（連結目標是否存在）、<strong>dependency cycle 檢測</strong>（import graph 是否有環）、<strong>unused export 偵測</strong>（某個 symbol 是否被使用）。每個都是圖論問題，需要先把 repo 結構化，單檔 walker 看不見跨檔 edge。本章以 <code>mdtools cards</code>（L1 連結有效性、L2 orphan 卡片、L4 卡片 K4 合規）作為 concrete instance。</p>
<h2 id="為什麼要預先建圖而非每次-lint-都現查">為什麼要預先建圖而非每次 lint 都現查</h2>
<p>直覺會說：對每個 link，直接 <code>os.Stat(target)</code> 看存在不存在，就能驗證 L1。</p>
<p>這個做法在 100 個檔案、每檔 10 個 link 時 OK — 1000 次 stat、每次 &lt; 1ms，總計 1 秒內。但一旦要做 L2 「每張卡片至少被一篇正文引用」，問題就變成：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">for each card file C:
</span></span><span class="line"><span class="ln">2</span><span class="cl">  for each other file F:
</span></span><span class="line"><span class="ln">3</span><span class="cl">    parse F
</span></span><span class="line"><span class="ln">4</span><span class="cl">    for each link L in F:
</span></span><span class="line"><span class="ln">5</span><span class="cl">      if L points to C: mark C as referenced</span></span></code></pre></div><p>N² parse，每次 parse 又走 AST。1000 檔 × 1000 檔 = 100 萬次 parse，每次 50ms，總計 14 小時。</p>
<p>解法是 <strong>parse 一次、存下所有 edge、在圖上查</strong>。Parse 是 O(N) 一次；所有後續 query 都在 in-memory map 上做，microseconds。</p>
<h2 id="graph-的資料結構">Graph 的資料結構</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// scripts/mdtools/internal/mdcards/graph.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">type</span> <span class="nx">Edge</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="nx">SourcePath</span>  <span class="kt">string</span> <span class="c1">// 包含連結的檔案</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="nx">SourceLine</span>  <span class="kt">int</span>    <span class="c1">// 連結所在行</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">	<span class="nx">Destination</span> <span class="kt">string</span> <span class="c1">// link 目的地（原文）</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	<span class="nx">Target</span>      <span class="kt">string</span> <span class="c1">// 解析後的檔案路徑（可能不存在）</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">	<span class="nx">DisplayText</span> <span class="kt">string</span> <span class="c1">// 顯示文字（反釣魚用）</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="kd">type</span> <span class="nx">FileNode</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">	<span class="nx">Path</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">	<span class="nx">AST</span>  <span class="nx">ast</span><span class="p">.</span><span class="nx">Node</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">	<span class="nx">Src</span>  <span class="p">[]</span><span class="kt">byte</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="kd">type</span> <span class="nx">Graph</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">	<span class="nx">Files</span> <span class="p">[]</span><span class="nx">FileNode</span>  <span class="c1">// 所有 .md 檔</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">	<span class="nx">Edges</span> <span class="p">[]</span><span class="nx">Edge</span>      <span class="c1">// 所有相對連結</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl">	<span class="nx">byPath</span>   <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="o">*</span><span class="nx">FileNode</span> <span class="c1">// path → FileNode</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">	<span class="nx">inbound</span>  <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">][]</span><span class="kt">int</span>     <span class="c1">// target path → edge indices</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">	<span class="nx">outbound</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">][]</span><span class="kt">int</span>     <span class="c1">// source path → edge indices</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>設計重點：</p>
<ul>
<li><strong>FileNode 保留 AST + src</strong>：後面 L4 檢查（卡片首段是否有鄰卡連結）需要重讀 AST，不想再 parse 一次。</li>
<li><strong>Edge 用 slice 儲存，index 走 map</strong>：比起直接用 <code>map[string][]Edge</code>，這個 layout allocation 少、GC 友善，也容易 sort 輸出。</li>
<li><strong>inbound / outbound 都預先建</strong>：L1 靠 outbound，L2 靠 inbound。一次 parse 把兩邊都填好。</li>
</ul>
<h2 id="parse-pipeline兩段式確保指標穩定">Parse pipeline：兩段式確保指標穩定</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// scripts/mdtools/internal/mdcards/graph.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">BuildGraph</span><span class="p">(</span><span class="nx">roots</span> <span class="p">[]</span><span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="nx">Graph</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="nx">mdFiles</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">files</span><span class="p">.</span><span class="nf">WalkMarkdown</span><span class="p">(</span><span class="nx">roots</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">		<span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">	<span class="nx">parser</span> <span class="o">:=</span> <span class="nx">astutil</span><span class="p">.</span><span class="nf">NewParser</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">	<span class="nx">g</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">Graph</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">		<span class="nx">byPath</span><span class="p">:</span>   <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="o">*</span><span class="nx">FileNode</span><span class="p">{},</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">		<span class="nx">inbound</span><span class="p">:</span>  <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">][]</span><span class="kt">int</span><span class="p">{},</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">		<span class="nx">outbound</span><span class="p">:</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">][]</span><span class="kt">int</span><span class="p">{},</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">	<span class="c1">// 第一段：parse 全部檔案，填 g.Files</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">	<span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">path</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">mdFiles</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">		<span class="nx">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">ReadFile</span><span class="p">(</span><span class="nx">path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">		<span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">			<span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">		<span class="nx">doc</span> <span class="o">:=</span> <span class="nx">parser</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="nx">data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">		<span class="nx">node</span> <span class="o">:=</span> <span class="nx">FileNode</span><span class="p">{</span><span class="nx">Path</span><span class="p">:</span> <span class="nx">path</span><span class="p">,</span> <span class="nx">AST</span><span class="p">:</span> <span class="nx">doc</span><span class="p">,</span> <span class="nx">Src</span><span class="p">:</span> <span class="nx">data</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">		<span class="nx">g</span><span class="p">.</span><span class="nx">Files</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">g</span><span class="p">.</span><span class="nx">Files</span><span class="p">,</span> <span class="nx">node</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">		<span class="nx">g</span><span class="p">.</span><span class="nx">byPath</span><span class="p">[</span><span class="nx">path</span><span class="p">]</span> <span class="p">=</span> <span class="o">&amp;</span><span class="nx">g</span><span class="p">.</span><span class="nx">Files</span><span class="p">[</span><span class="nb">len</span><span class="p">(</span><span class="nx">g</span><span class="p">.</span><span class="nx">Files</span><span class="p">)</span><span class="o">-</span><span class="mi">1</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">
</span></span><span class="line"><span class="ln">27</span><span class="cl">	<span class="c1">// 第二段：抽出 edge（此時 g.Files 大小已固定，指標穩定）</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">	<span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">node</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">g</span><span class="p">.</span><span class="nx">Files</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">		<span class="nx">g</span><span class="p">.</span><span class="nf">extractEdges</span><span class="p">(</span><span class="nx">node</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">
</span></span><span class="line"><span class="ln">32</span><span class="cl">	<span class="k">return</span> <span class="nx">g</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><strong>兩段式的理由</strong>：第一段邊 parse 邊 append，slice 可能重新分配 underlying array，之前取的 <code>*FileNode</code> 指標會失效。第一段收斂後才取指標，保證穩定。這是 Go slice 常見的事故。</p>
<p>如果用 <code>[]*FileNode</code>（指標 slice）就沒這問題，但對這個情境 <code>[]FileNode</code> 空間效率較好。兩種都 OK，選一種就要注意對應的陷阱。</p>
<h2 id="l1連結有效性outbound-走訪">L1：連結有效性（outbound 走訪）</h2>
<p>最簡單的 graph query：對每個 edge，檢查 target 是否存在。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// scripts/mdtools/internal/mdcards/l1.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">checkL1LinkValidity</span><span class="p">(</span><span class="nx">g</span> <span class="o">*</span><span class="nx">Graph</span><span class="p">)</span> <span class="p">[]</span><span class="nx">report</span><span class="p">.</span><span class="nx">Violation</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="kd">var</span> <span class="nx">out</span> <span class="p">[]</span><span class="nx">report</span><span class="p">.</span><span class="nx">Violation</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">edge</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">g</span><span class="p">.</span><span class="nx">Edges</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">		<span class="k">if</span> <span class="nf">TargetExists</span><span class="p">(</span><span class="nx">edge</span><span class="p">.</span><span class="nx">Target</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">			<span class="k">continue</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">		<span class="nx">out</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">out</span><span class="p">,</span> <span class="nx">report</span><span class="p">.</span><span class="nx">Violation</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">			<span class="nx">Path</span><span class="p">:</span>  <span class="nx">edge</span><span class="p">.</span><span class="nx">SourcePath</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">			<span class="nx">Line</span><span class="p">:</span>  <span class="nx">edge</span><span class="p">.</span><span class="nx">SourceLine</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">			<span class="nx">Rule</span><span class="p">:</span>  <span class="s">&#34;L1-broken-link&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">			<span class="nx">Level</span><span class="p">:</span> <span class="nx">report</span><span class="p">.</span><span class="nx">LevelError</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">			<span class="nx">Message</span><span class="p">:</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Sprintf</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">				<span class="s">&#34;broken link %q: target not found&#34;</span><span class="p">,</span> <span class="nx">edge</span><span class="p">.</span><span class="nx">Destination</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">			<span class="p">),</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">		<span class="p">})</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">	<span class="k">return</span> <span class="nx">out</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>TargetExists</code> 試兩個候選路徑 — <code>{target}.md</code> 跟 <code>{target}/_index.md</code>，因為 Hugo 的 URL routing 對 content page 跟 section page 一視同仁：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">TargetExists</span><span class="p">(</span><span class="nx">target</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">	<span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">cand</span> <span class="o">:=</span> <span class="k">range</span> <span class="p">[]</span><span class="kt">string</span><span class="p">{</span><span class="nx">target</span> <span class="o">+</span> <span class="s">&#34;.md&#34;</span><span class="p">,</span> <span class="nx">filepath</span><span class="p">.</span><span class="nf">Join</span><span class="p">(</span><span class="nx">target</span><span class="p">,</span> <span class="s">&#34;_index.md&#34;</span><span class="p">)}</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">		<span class="k">if</span> <span class="nx">info</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">Stat</span><span class="p">(</span><span class="nx">cand</span><span class="p">);</span> <span class="nx">err</span> <span class="o">==</span> <span class="kc">nil</span> <span class="o">&amp;&amp;</span> <span class="p">!</span><span class="nx">info</span><span class="p">.</span><span class="nf">IsDir</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">			<span class="k">return</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">	<span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h2 id="l2orphan-偵測inbound-反查">L2：orphan 偵測（inbound 反查）</h2>
<p>「每張卡片至少被一篇非卡片文章引用」— 這是反向查詢：對每張卡片，看它的 <code>inbound</code> 有沒有來自「非卡片檔」的 edge。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// scripts/mdtools/internal/mdcards/l2.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">checkL2Orphans</span><span class="p">(</span><span class="nx">g</span> <span class="o">*</span><span class="nx">Graph</span><span class="p">,</span> <span class="nx">cardsRoot</span> <span class="kt">string</span><span class="p">)</span> <span class="p">[]</span><span class="nx">report</span><span class="p">.</span><span class="nx">Violation</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="nx">inboundNonCard</span> <span class="o">:=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">int</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">edge</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">g</span><span class="p">.</span><span class="nx">Edges</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">		<span class="nx">targetMd</span> <span class="o">:=</span> <span class="nx">edge</span><span class="p">.</span><span class="nx">Target</span> <span class="o">+</span> <span class="s">&#34;.md&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">		<span class="k">if</span> <span class="p">!</span><span class="nf">isCardPath</span><span class="p">(</span><span class="nx">targetMd</span><span class="p">,</span> <span class="nx">cardsRoot</span><span class="p">)</span> <span class="o">||</span> <span class="nf">isSectionIndex</span><span class="p">(</span><span class="nx">targetMd</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">			<span class="k">continue</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">		<span class="k">if</span> <span class="nf">isCardPath</span><span class="p">(</span><span class="nx">edge</span><span class="p">.</span><span class="nx">SourcePath</span><span class="p">,</span> <span class="nx">cardsRoot</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">			<span class="k">continue</span> <span class="c1">// card-to-card 不算</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">		<span class="nx">inboundNonCard</span><span class="p">[</span><span class="nx">targetMd</span><span class="p">]</span><span class="o">++</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">	<span class="kd">var</span> <span class="nx">out</span> <span class="p">[]</span><span class="nx">report</span><span class="p">.</span><span class="nx">Violation</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">	<span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">fn</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">g</span><span class="p">.</span><span class="nx">Files</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">		<span class="k">if</span> <span class="p">!</span><span class="nf">isCardPath</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">,</span> <span class="nx">cardsRoot</span><span class="p">)</span> <span class="o">||</span> <span class="nf">isSectionIndex</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">			<span class="k">continue</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">		<span class="k">if</span> <span class="nx">inboundNonCard</span><span class="p">[</span><span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">]</span> <span class="p">&gt;</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">			<span class="k">continue</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">		<span class="nx">out</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">out</span><span class="p">,</span> <span class="nx">report</span><span class="p">.</span><span class="nx">Violation</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">			<span class="nx">Path</span><span class="p">:</span>    <span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">			<span class="nx">Rule</span><span class="p">:</span>    <span class="s">&#34;L2-orphan-card&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">			<span class="nx">Level</span><span class="p">:</span>   <span class="nx">report</span><span class="p">.</span><span class="nx">LevelWarn</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">			<span class="nx">Message</span><span class="p">:</span> <span class="s">&#34;card has no inbound link from non-card content&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">		<span class="p">})</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">	<span class="k">return</span> <span class="nx">out</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><strong>設計取捨</strong>：</p>
<ul>
<li><strong>Card-to-card 不算 inbound</strong>：依 spec，卡片應被「教學文章」引用證明使用場景；卡片互連只證明概念網路，不證明實用。</li>
<li><strong>Warn 而非 error</strong>：orphan 是<strong>內容覆蓋率訊號</strong>，不是格式錯誤；新卡片剛建時不該被擋 commit。</li>
<li><strong>_index.md 排除</strong>：section page 不是 card。</li>
</ul>
<h2 id="l4ast-跟-graph-混用">L4：AST 跟 Graph 混用</h2>
<p>這條規則複雜：「卡片的首段跟『概念位置』section 都要各有至少一個鄰卡連結」。要用 graph 抓候選，再用 AST 精確判讀在哪個 paragraph / section 內。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// scripts/mdtools/internal/mdcards/l4.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">checkL4K4Structure</span><span class="p">(</span><span class="nx">g</span> <span class="o">*</span><span class="nx">Graph</span><span class="p">,</span> <span class="nx">cardsRoot</span><span class="p">,</span> <span class="nx">conceptHeadingTitle</span> <span class="kt">string</span><span class="p">)</span> <span class="p">[]</span><span class="nx">report</span><span class="p">.</span><span class="nx">Violation</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="kd">var</span> <span class="nx">out</span> <span class="p">[]</span><span class="nx">report</span><span class="p">.</span><span class="nx">Violation</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">fn</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">g</span><span class="p">.</span><span class="nx">Files</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">		<span class="k">if</span> <span class="p">!</span><span class="nf">isCardPath</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">,</span> <span class="nx">cardsRoot</span><span class="p">)</span> <span class="o">||</span> <span class="nf">isSectionIndex</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">			<span class="k">continue</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">		<span class="nx">firstPara</span> <span class="o">:=</span> <span class="nf">firstBodyParagraph</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">AST</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">		<span class="k">if</span> <span class="p">!</span><span class="nf">subtreeHasCardLink</span><span class="p">(</span><span class="nx">firstPara</span><span class="p">,</span> <span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">,</span> <span class="nx">cardsRoot</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">			<span class="nx">out</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">out</span><span class="p">,</span> <span class="nx">report</span><span class="p">.</span><span class="nx">Violation</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">				<span class="nx">Path</span><span class="p">:</span>    <span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">				<span class="nx">Rule</span><span class="p">:</span>    <span class="s">&#34;L4-first-paragraph-no-card-link&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">				<span class="nx">Level</span><span class="p">:</span>   <span class="nx">report</span><span class="p">.</span><span class="nx">LevelWarn</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">				<span class="nx">Message</span><span class="p">:</span> <span class="s">&#34;opening paragraph should link to an adjacent card&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">			<span class="p">})</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl">		<span class="nx">section</span> <span class="o">:=</span> <span class="nf">headingSection</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">AST</span><span class="p">,</span> <span class="nx">conceptHeadingTitle</span><span class="p">,</span> <span class="nx">fn</span><span class="p">.</span><span class="nx">Src</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">		<span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">section</span><span class="p">)</span> <span class="o">==</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">			<span class="nx">out</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">out</span><span class="p">,</span> <span class="nx">report</span><span class="p">.</span><span class="nx">Violation</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">				<span class="nx">Path</span><span class="p">:</span>    <span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">				<span class="nx">Rule</span><span class="p">:</span>    <span class="s">&#34;L4-missing-concept-position&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">				<span class="nx">Level</span><span class="p">:</span>   <span class="nx">report</span><span class="p">.</span><span class="nx">LevelWarn</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">				<span class="nx">Message</span><span class="p">:</span> <span class="s">&#34;card missing 概念位置 section&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">			<span class="p">})</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">			<span class="k">continue</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">		<span class="k">if</span> <span class="p">!</span><span class="nf">sectionHasCardLink</span><span class="p">(</span><span class="nx">section</span><span class="p">,</span> <span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">,</span> <span class="nx">cardsRoot</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">			<span class="nx">out</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">out</span><span class="p">,</span> <span class="nx">report</span><span class="p">.</span><span class="nx">Violation</span><span class="p">{</span><span class="o">...</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">	<span class="k">return</span> <span class="nx">out</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>兩個 helper 展示 graph 跟 AST 的交界：</p>
<ul>
<li><code>firstBodyParagraph(doc)</code> 用 AST 走第一個 Paragraph 節點（document 的第一個 top-level child）。</li>
<li><code>subtreeHasCardLink(node, sourcePath, cardsRoot)</code> 用 <code>ast.Walk</code> 在該節點下找所有 Link，再用 <code>resolveTarget</code> 判斷是不是指向卡片。</li>
</ul>
<p><strong>為什麼用 AST 而不是行號範圍</strong>：Hugo content 的卡片結構多樣，首段可能跨多行；用 <code>paragraph.Lines()</code> 也能拿到 byte range，但還要處理 list item、table row 這類邊界。直接走 AST 子樹是最穩定的做法。</p>
<h2 id="反向索引的設計擴充slug-based-啟發式">反向索引的設計擴充：slug-based 啟發式</h2>
<p><code>mdtools migrate fix-links</code> 要處理「broken link 但作者其實想連到某個存在的檔案，只是路徑寫錯」。這需要<strong>額外一個 slug 反向索引</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// scripts/mdtools/internal/mdmigrate/fixlinks.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">buildSlugIndexes</span><span class="p">(</span><span class="nx">g</span> <span class="o">*</span><span class="nx">mdcards</span><span class="p">.</span><span class="nx">Graph</span><span class="p">)</span> <span class="p">(</span><span class="nx">primary</span><span class="p">,</span> <span class="nx">normalized</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">][]</span><span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="nx">primary</span> <span class="p">=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">][]</span><span class="kt">string</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="nx">normalized</span> <span class="p">=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">][]</span><span class="kt">string</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">	<span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">fn</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">g</span><span class="p">.</span><span class="nx">Files</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">		<span class="nx">base</span> <span class="o">:=</span> <span class="nx">filepath</span><span class="p">.</span><span class="nf">Base</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">		<span class="kd">var</span> <span class="nx">slug</span><span class="p">,</span> <span class="nx">target</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">		<span class="k">if</span> <span class="nx">base</span> <span class="o">==</span> <span class="s">&#34;_index.md&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">			<span class="nx">parent</span> <span class="o">:=</span> <span class="nx">filepath</span><span class="p">.</span><span class="nf">Dir</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">			<span class="nx">slug</span> <span class="p">=</span> <span class="nx">filepath</span><span class="p">.</span><span class="nf">Base</span><span class="p">(</span><span class="nx">parent</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">			<span class="nx">target</span> <span class="p">=</span> <span class="nx">parent</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">		<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">			<span class="nx">slug</span> <span class="p">=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSuffix</span><span class="p">(</span><span class="nx">base</span><span class="p">,</span> <span class="s">&#34;.md&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">			<span class="nx">target</span> <span class="p">=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSuffix</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">,</span> <span class="s">&#34;.md&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">		<span class="nx">primary</span><span class="p">[</span><span class="nx">slug</span><span class="p">]</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">primary</span><span class="p">[</span><span class="nx">slug</span><span class="p">],</span> <span class="nx">target</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">		<span class="k">if</span> <span class="nx">norm</span> <span class="o">:=</span> <span class="nx">sectionPrefixRe</span><span class="p">.</span><span class="nf">ReplaceAllString</span><span class="p">(</span><span class="nx">slug</span><span class="p">,</span> <span class="s">&#34;&#34;</span><span class="p">);</span> <span class="nx">norm</span> <span class="o">!=</span> <span class="nx">slug</span> <span class="o">&amp;&amp;</span> <span class="nx">norm</span> <span class="o">!=</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">			<span class="nx">normalized</span><span class="p">[</span><span class="nx">norm</span><span class="p">]</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">normalized</span><span class="p">[</span><span class="nx">norm</span><span class="p">],</span> <span class="nx">target</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">	<span class="k">return</span> <span class="nx">primary</span><span class="p">,</span> <span class="nx">normalized</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>查詢時走多層啟發式：</p>
<ol>
<li><strong>精確 slug 命中</strong>（<code>broker</code> → <code>content/backend/knowledge-cards/broker</code>）</li>
<li><strong>數字前綴 normalized 命中</strong>（<code>03-cpython-internals</code> 找不到 → 試 <code>cpython-internals</code> → 命中 <code>04-cpython-internals</code>）</li>
<li><strong>卡片優先</strong>（多個 candidate 時選 <code>knowledge-cards/</code> 下的）</li>
<li><strong>同頂層子目錄優先</strong>（source 在 <code>content/go/...</code> 時選 <code>content/go/</code> 下的 candidate）</li>
</ol>
<p>這四層啟發式把 143 個 multi-candidate 收斂到 0 個 ambiguous。<strong>啟發式的層層疊加是 static analysis 工具常見 pattern</strong> — 寫過 linter、LSP、refactoring tool 的人都會在類似的決策樹花時間。</p>
<h2 id="parse-成本的實務控制">Parse 成本的實務控制</h2>
<p>跨檔分析一次要 parse 幾百個檔案。幾個優化：</p>
<ul>
<li><strong>只 parse 一次</strong>：Graph 建好後 L1 / L2 / L4 共用。呼叫端不該重建 Graph。</li>
<li><strong>concurrent parse（選擇性）</strong>：<code>mdtools</code> 目前單執行緒 parse ~400 檔案 &lt; 1 秒，沒必要並發。若檔案過萬，用 <code>golang.org/x/sync/errgroup</code> + worker pool fan out。</li>
<li><strong>避免記憶體持有</strong>：Graph 的 <code>FileNode.Src</code> 跟 <code>AST</code> 都持有 reference。如果 GC 壓力敏感，做完 L1-L4 後顯式 <code>g = nil</code> 或分段釋放。blog 這個規模沒必要。</li>
</ul>
<h2 id="常見陷阱">常見陷阱</h2>
<h3 id="slice-append-失效的指標">Slice append 失效的指標</h3>
<p>上面提過 — 邊 append 邊取指標會炸。BuildGraph 的兩段式是標準修法。</p>
<h3 id="filepathrel-在-root-外的-panic">filepath.Rel 在 root 外的 panic</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">rel</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">filepath</span><span class="p">.</span><span class="nf">Rel</span><span class="p">(</span><span class="s">&#34;/foo&#34;</span><span class="p">,</span> <span class="s">&#34;/bar&#34;</span><span class="p">)</span>  <span class="c1">// 不會 panic，回傳 &#34;../bar&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">rel</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">filepath</span><span class="p">.</span><span class="nf">Rel</span><span class="p">(</span><span class="s">&#34;foo&#34;</span><span class="p">,</span> <span class="s">&#34;../bar&#34;</span><span class="p">)</span> <span class="c1">// err != nil</span></span></span></code></pre></div><p>跨 repo root 的 rel 路徑會回 error。graph 的 target resolution 要接住這個 error。</p>
<h3 id="symlink-的走訪">Symlink 的走訪</h3>
<p><code>filepath.WalkDir</code> 預設<strong>不跟隨 symlink</strong>。這對 blog repo OK（沒 symlink）；但對其他 repo（例如 monorepo 有 symlink 指到 sibling package）要用 <code>filepath.Walk</code> 或自己實作。mdtools 不處理這個情境。</p>
<h3 id="hugo-的-url-路由細節">Hugo 的 URL 路由細節</h3>
<p><code>../broker/</code> 從 <code>content/backend/knowledge-cards/_index.md</code> 出發，Hugo 解析成 <code>content/backend/knowledge-cards/broker</code>（也就是 sibling card 的 URL）。從 <code>content/backend/knowledge-cards/acme-automation.md</code> 出發，同樣的 <code>../broker/</code> 卻解析成 <code>content/backend/broker</code>（錯誤，broker 不在 backend 直屬）。這是因為 Hugo 把<strong>內容頁 URL dir 當成「slug 的 URL 資料夾」</strong>，不是「檔案所在的資料夾」。做 target resolution 時要注意這一點，參考 <code>resolveTarget()</code> 的實作。</p>
<h2 id="擴充路徑">擴充路徑</h2>
<ul>
<li><strong>Ingest commit history</strong>：把 git commit 的時序資訊加進 graph，抓「這張卡片連結了一個之前存在但被刪掉的檔案」。需要整合 <code>go-git</code>。</li>
<li><strong>Parallel parse</strong>：大型 monorepo 的 lint 可用 worker pool 並行 parse。用 channel 把 parse 結果丟回 main，注意 goldmark context 不跨 goroutine。</li>
<li><strong>Graph visualization</strong>：把 graph 輸出成 Graphviz DOT 或 Mermaid，給作者看「這張卡片的 backlinks 是什麼」。有助於規劃內容修訂。</li>
</ul>
<h2 id="下一步">下一步</h2>
<p><a href="/blog/go/09-tooling-and-analysis/tool-decision-tripwire/" data-link-title="9.5 工具決策：regex 到 AST、Python 到 Go 的 tripwire" data-link-desc="什麼訊號代表工具該升級到下一個層次；用 WRAP 框架做語言與實作層的技術決策；延遲決策的成本">9.5 工具決策的 tripwire</a> 跳出實作，看「什麼時候該從 regex 升級到 AST、什麼時候該從 Python 換到 Go」的決策方法。</p>
]]></content:encoded></item><item><title>9.5 工具決策：regex 到 AST、Python 到 Go 的 tripwire</title><link>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/tool-decision-tripwire/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/tool-decision-tripwire/</guid><description>&lt;p>工具決策的核心責任是&lt;strong>用事前約定的條件決定何時升級，取代事後的模糊直覺&lt;/strong>。&lt;a href="https://tarrragon.github.io/blog/go/glossary/#tripwire-%e6%b1%ba%e7%ad%96" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">Tripwire 決策&lt;/a>（預設觸發條件）的設計：在某個可量測訊號命中時，主動重新評估現有工具是否夠用、或該升級到下一個層級。這個約定避開兩個常見失敗 —「太早升級」（把 shell 一行解決的事過度工程化）跟「太晚升級」（regex 每週出狀況卻忍著不升，信譽破產）。&lt;/p>
&lt;p>9.1–9.4 講了怎麼寫 Go 工具。這一章退一步，看&lt;strong>什麼時候該寫這個工具、什麼時候該升級既有工具&lt;/strong>。決策錯了，寫得多好都沒用。本章介紹 tripwire 決策法、WRAP 框架在技術決策的套用、以及用 blog 工具鏈選型作為 concrete instance。&lt;/p>
&lt;h2 id="為什麼需要-tripwire">為什麼需要 tripwire&lt;/h2>
&lt;p>「什麼時候升級」本身是決策。如果不做預設，會發生兩件事：&lt;/p>
&lt;p>&lt;strong>太早升級&lt;/strong>：每次問「該不該升」的時候都說「升吧反正不會錯」。結果工具複雜度爆炸，維護成本拖慢產品開發。&lt;/p>
&lt;p>&lt;strong>太晚升級&lt;/strong>：每次都說「regex 再撐一下就好」。結果工具的誤判累積，作者開始手動 override、skip lint、加例外，工具信譽破產。&lt;/p>
&lt;p>Tripwire 是&lt;strong>事前約定&lt;/strong>：「當以下條件之一命中，就重新評估是否升級」。這把「該不該升」從&lt;strong>臨時直覺&lt;/strong>變成&lt;strong>有根據的再評估&lt;/strong>。&lt;/p>
&lt;p>這個概念在 Chip 與 Dan Heath 的《Decisive》裡有詳細討論 — tripwire 的要點是&lt;strong>用事前的明確條件，取代事後的模糊直覺&lt;/strong>。&lt;/p>
&lt;h2 id="wrap-框架套用到工具決策">WRAP 框架套用到工具決策&lt;/h2>
&lt;p>WRAP = Widen options / Reality-test / Attain distance / Prepare to be wrong。對應到技術決策：&lt;/p>
&lt;p>&lt;strong>Widen options&lt;/strong>：不要只在「Go 還是 Python」之間選。多選項至少要有：&lt;/p>
&lt;ul>
&lt;li>現有工具撐著（regex + shell）&lt;/li>
&lt;li>半自動化（Python + regex，50 行腳本）&lt;/li>
&lt;li>自訂工具（Python / Go + 適當 parser）&lt;/li>
&lt;li>買服務（買現成 linter SaaS）&lt;/li>
&lt;li>不解決（接受這個問題）&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Reality-test&lt;/strong>：用數字跟樣本驗證假設。「regex 夠用」是假設，數字可能說「每 100 個 match 裡 15 個誤判」。&lt;/p>
&lt;p>&lt;strong>Attain distance&lt;/strong>：退一步看，如果這個工具三年後可能被捨棄，現在投入多少才合理。&lt;/p>
&lt;p>&lt;strong>Prepare to be wrong&lt;/strong>：先設 tripwire，萬一決策錯了也能及時 pivot，不會沉沒到底。&lt;/p>
&lt;p>blog 的工具鏈決策用這個框架跑過一次，結論是 Go + goldmark。過程紀錄在 &lt;a href="https://tarrragon.github.io/blog/posts/mdtoolsgo--goldmark-%E7%9A%84-markdown-%E5%B7%A5%E5%85%B7%E9%8F%88%E8%A8%AD%E8%A8%88/" data-link-title="mdtools：Go &amp;#43; goldmark 的 markdown 工具鏈設計" data-link-desc="mdtools 的架構決策：選 Go &amp;#43; goldmark 的理由（與 Hugo 同源保證 lint↔render 等價）、單 binary 多子命令設計、pre-commit 整合、規則開啟紀律。">mdtools 設計&lt;/a>，這裡只提煉可複用的決策 pattern。&lt;/p>
&lt;h2 id="三個實戰-tripwire">三個實戰 tripwire&lt;/h2>
&lt;p>以下三組 tripwire 對多數內部工具都適用。遇到其中一個命中時，該花一小時重新評估現有工具是否夠用。&lt;/p>
&lt;h3 id="tripwire-1從-shell-one-liner-升級到腳本">Tripwire 1：從 shell one-liner 升級到腳本&lt;/h3>
&lt;p>&lt;strong>訊號&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>同樣的 shell 指令在三個以上地方重複貼過&lt;/li>
&lt;li>指令超過 3 個 pipe 或巢狀 subshell&lt;/li>
&lt;li>指令的行為要根據環境（CI vs local）分支&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>升級方向&lt;/strong>：寫成 20-50 行 Python 或 Bash script，放進 &lt;code>scripts/&lt;/code>。&lt;/p>
&lt;p>&lt;strong>反例&lt;/strong>：每次寫新 shell 命令都起腳本檔。常用的一行 &lt;code>grep&lt;/code> 不需要變 script。&lt;/p>
&lt;h3 id="tripwire-2從-regex-升級到-parser--ast">Tripwire 2：從 regex 升級到 parser / AST&lt;/h3>
&lt;p>&lt;strong>訊號&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>Regex 需要「上下文判斷」（這個 match 在 code block 內嗎？在 HTML tag 內嗎？）&lt;/li>
&lt;li>規則要處理嵌套結構（表格內的 link、code block 內的 heading）&lt;/li>
&lt;li>誤報率超過 1% 或每週出現&lt;/li>
&lt;li>新規則要知道「父節點」「子節點」（MD024 siblings_only 就是這類）&lt;/li>
&lt;li>跨檔案的 graph 需求出現（backlink 分析、broken link 偵測）&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>升級方向&lt;/strong>：引入該格式的官方 parser（markdown → goldmark；YAML → &lt;code>gopkg.in/yaml.v3&lt;/code>；Go → &lt;code>go/parser&lt;/code>）。&lt;/p></description><content:encoded><![CDATA[<p>工具決策的核心責任是<strong>用事前約定的條件決定何時升級，取代事後的模糊直覺</strong>。<a href="/blog/go/glossary/#tripwire-%e6%b1%ba%e7%ad%96" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">Tripwire 決策</a>（預設觸發條件）的設計：在某個可量測訊號命中時，主動重新評估現有工具是否夠用、或該升級到下一個層級。這個約定避開兩個常見失敗 —「太早升級」（把 shell 一行解決的事過度工程化）跟「太晚升級」（regex 每週出狀況卻忍著不升，信譽破產）。</p>
<p>9.1–9.4 講了怎麼寫 Go 工具。這一章退一步，看<strong>什麼時候該寫這個工具、什麼時候該升級既有工具</strong>。決策錯了，寫得多好都沒用。本章介紹 tripwire 決策法、WRAP 框架在技術決策的套用、以及用 blog 工具鏈選型作為 concrete instance。</p>
<h2 id="為什麼需要-tripwire">為什麼需要 tripwire</h2>
<p>「什麼時候升級」本身是決策。如果不做預設，會發生兩件事：</p>
<p><strong>太早升級</strong>：每次問「該不該升」的時候都說「升吧反正不會錯」。結果工具複雜度爆炸，維護成本拖慢產品開發。</p>
<p><strong>太晚升級</strong>：每次都說「regex 再撐一下就好」。結果工具的誤判累積，作者開始手動 override、skip lint、加例外，工具信譽破產。</p>
<p>Tripwire 是<strong>事前約定</strong>：「當以下條件之一命中，就重新評估是否升級」。這把「該不該升」從<strong>臨時直覺</strong>變成<strong>有根據的再評估</strong>。</p>
<p>這個概念在 Chip 與 Dan Heath 的《Decisive》裡有詳細討論 — tripwire 的要點是<strong>用事前的明確條件，取代事後的模糊直覺</strong>。</p>
<h2 id="wrap-框架套用到工具決策">WRAP 框架套用到工具決策</h2>
<p>WRAP = Widen options / Reality-test / Attain distance / Prepare to be wrong。對應到技術決策：</p>
<p><strong>Widen options</strong>：不要只在「Go 還是 Python」之間選。多選項至少要有：</p>
<ul>
<li>現有工具撐著（regex + shell）</li>
<li>半自動化（Python + regex，50 行腳本）</li>
<li>自訂工具（Python / Go + 適當 parser）</li>
<li>買服務（買現成 linter SaaS）</li>
<li>不解決（接受這個問題）</li>
</ul>
<p><strong>Reality-test</strong>：用數字跟樣本驗證假設。「regex 夠用」是假設，數字可能說「每 100 個 match 裡 15 個誤判」。</p>
<p><strong>Attain distance</strong>：退一步看，如果這個工具三年後可能被捨棄，現在投入多少才合理。</p>
<p><strong>Prepare to be wrong</strong>：先設 tripwire，萬一決策錯了也能及時 pivot，不會沉沒到底。</p>
<p>blog 的工具鏈決策用這個框架跑過一次，結論是 Go + goldmark。過程紀錄在 <a href="/blog/posts/mdtoolsgo--goldmark-%E7%9A%84-markdown-%E5%B7%A5%E5%85%B7%E9%8F%88%E8%A8%AD%E8%A8%88/" data-link-title="mdtools：Go &#43; goldmark 的 markdown 工具鏈設計" data-link-desc="mdtools 的架構決策：選 Go &#43; goldmark 的理由（與 Hugo 同源保證 lint↔render 等價）、單 binary 多子命令設計、pre-commit 整合、規則開啟紀律。">mdtools 設計</a>，這裡只提煉可複用的決策 pattern。</p>
<h2 id="三個實戰-tripwire">三個實戰 tripwire</h2>
<p>以下三組 tripwire 對多數內部工具都適用。遇到其中一個命中時，該花一小時重新評估現有工具是否夠用。</p>
<h3 id="tripwire-1從-shell-one-liner-升級到腳本">Tripwire 1：從 shell one-liner 升級到腳本</h3>
<p><strong>訊號</strong>：</p>
<ul>
<li>同樣的 shell 指令在三個以上地方重複貼過</li>
<li>指令超過 3 個 pipe 或巢狀 subshell</li>
<li>指令的行為要根據環境（CI vs local）分支</li>
</ul>
<p><strong>升級方向</strong>：寫成 20-50 行 Python 或 Bash script，放進 <code>scripts/</code>。</p>
<p><strong>反例</strong>：每次寫新 shell 命令都起腳本檔。常用的一行 <code>grep</code> 不需要變 script。</p>
<h3 id="tripwire-2從-regex-升級到-parser--ast">Tripwire 2：從 regex 升級到 parser / AST</h3>
<p><strong>訊號</strong>：</p>
<ul>
<li>Regex 需要「上下文判斷」（這個 match 在 code block 內嗎？在 HTML tag 內嗎？）</li>
<li>規則要處理嵌套結構（表格內的 link、code block 內的 heading）</li>
<li>誤報率超過 1% 或每週出現</li>
<li>新規則要知道「父節點」「子節點」（MD024 siblings_only 就是這類）</li>
<li>跨檔案的 graph 需求出現（backlink 分析、broken link 偵測）</li>
</ul>
<p><strong>升級方向</strong>：引入該格式的官方 parser（markdown → goldmark；YAML → <code>gopkg.in/yaml.v3</code>；Go → <code>go/parser</code>）。</p>
<p><strong>反例</strong>：簡單的「每行開頭是 <code>#</code> 就當 heading」這類規則，regex 永遠夠用。不要為了「學 AST」硬上。</p>
<h3 id="tripwire-3從腳本語言升級到-go">Tripwire 3：從腳本語言升級到 Go</h3>
<p><strong>訊號</strong>：</p>
<ul>
<li>需要 parse 有官方 Go 實作的格式（goldmark、go/ast、protobuf 等）</li>
<li>需要跨平台分發單一 binary</li>
<li>Python / Node 的啟動時間在 pre-commit 的 accumulated cost 已感</li>
<li>要整合到 Go 生態系（產生 Go 程式碼、讀 Go 原始碼）</li>
<li>團隊主要語言是 Go，Python 腳本的維護者變成單一瓶頸</li>
</ul>
<p><strong>升級方向</strong>：Go。</p>
<p><strong>反例</strong>：臨時的資料轉換、一次性的 data migration、快速 prototyping — Python 永遠比 Go 快動筆。</p>
<h2 id="實戰紀錄blog-的三層升級">實戰紀錄：blog 的三層升級</h2>
<p>blog 的 markdown 品質工具鏈在一個 session 內走完三層升級。把這個時間線攤開當案例。</p>
<h3 id="layer-0沒工具靠-markdownlint-ide-extension">Layer 0：沒工具，靠 markdownlint IDE extension</h3>
<p><strong>狀況</strong>：IDE 裝 markdownlint extension，作者寫稿時看到 yellow underline 手動改。</p>
<p><strong>出現什麼 tripwire</strong>：內容規模長大後，reviewer 收到 PR 發現 20 個 MD026 違規，手動改成 cognitive burden。更糟的是紅隊教材有平行結構（13 個案例各有 <code>### 弱點環節</code>），被 MD024 誤判為重複，作者開始 ignore 警告。</p>
<p><strong>升級驅動</strong>：Tripwire 2 命中（規則需要父標題上下文，siblings_only 規則 IDE 沒有）。決策：升級到自訂工具。</p>
<h3 id="layer-1-候選python--regex">Layer 1 候選：Python + regex</h3>
<p><strong>狀況</strong>：50 行 Python 腳本，逐行 match。</p>
<p><strong>為什麼沒選</strong>：兩個跨檔需求已經浮現 — 卡片雙向完整性、L1 link 驗證。這是 graph 需求，regex 做不到 (Tripwire 2 的後半段命中)。加上 blog 本身用 Hugo（Go 寫的，markdown 由 goldmark parse），用 Python 的 markdown parser 會有 render 跟 lint 判讀不一致的長尾風險。</p>
<p>這個評估花了約 15 分鐘，記錄在決策文件裡 — 重點是<strong>評估本身有 artefact 可追溯</strong>。</p>
<h3 id="layer-2go--goldmark">Layer 2：Go + goldmark</h3>
<p><strong>狀況</strong>：選 Go，因為 (a) goldmark 是 Hugo 的 parser，lint 結果跟 render 必然一致；(b) 跨檔 graph 分析用 Go struct 乾淨；(c) 單一 binary 方便接 pre-commit hook 跟 CI，不用擔心 Python 環境。</p>
<p><strong>如何驗證決策正確</strong>：看三個月後的狀態 — 工具有沒有被 bypass？新規則加起來順不順？CI 有沒有反覆失敗？作者有沒有開始覺得工具阻礙產出？這些訊號都沒出現，表示決策有效。若有出現，就是 Tripwire 3 的反向觸發（「該降級回 Python」或「該拆成多個專門工具」），又要重新評估。</p>
<h2 id="延遲決策的具體成本">延遲決策的具體成本</h2>
<p>常見反論：「不急，等真的需要再升」。問題是<strong>延遲本身有成本</strong>：</p>
<ul>
<li><strong>Technical debt 複利</strong>：regex 工具越長越大，每條新 rule 都變難，最後要重寫時要一次 migration 所有 rule。</li>
<li><strong>誤報侵蝕信譽</strong>：使用者每週看到工具報錯、檢查後發現是誤判，開始忽略工具。信譽一旦壞，再好的工具也沒用。</li>
<li><strong>Option value 流失</strong>：跨檔分析、graph 視覺化、CI 整合這些 downstream feature 都要在 AST 基礎上才能做；延遲升級等於延遲 feature 路徑。</li>
<li><strong>機會成本複利</strong>：每週花 30 分鐘手動改 lint 誤判，一年累積 26 小時 — 比升級工具的 8 小時多 3 倍。</li>
</ul>
<p>時間視角變長，升級的 NPV 幾乎永遠正。<strong>延遲不是零成本的預設，是要主動合理化的選擇</strong>。</p>
<h2 id="為什麼-blog-不走先-python-再-go的雙階段">為什麼 blog 不走「先 Python 再 Go」的雙階段</h2>
<p>有個常見建議：「先用 Python 快速做出雛形，驗證概念後再用 Go 重寫」。這個建議對<strong>不確定需求</strong>的情境有效（「我們不知道要什麼工具」），對<strong>已知需求</strong>的情境是浪費。</p>
<p>blog 的狀況是已知：</p>
<ul>
<li>要 markdown lint + 跨檔 graph + pre-commit + CI — 四個需求都很明確</li>
<li>目標語言（Go）已經確定（Hugo 生態、單一 binary 需求）</li>
<li>第三方 parser 選擇（goldmark）已經是最優解</li>
</ul>
<p>在這些條件下，寫 Python 原型的唯一價值是「學 AST 概念」。但同樣學習也能直接用 Go + goldmark 完成。花兩倍時間寫兩遍只為了「先驗證」，邏輯不成立。</p>
<p>判準：<strong>需求越明確、目標語言越確定，雙階段越浪費；需求越模糊、選型還在評估，雙階段越值得</strong>。</p>
<h2 id="決策的副作用artefact">決策的副作用：artefact</h2>
<p>不管最終選什麼，決策過程本身要留下 artefact。blog 案例裡的 artefact：</p>
<ul>
<li><a href="/blog/posts/mdtoolsgo--goldmark-%E7%9A%84-markdown-%E5%B7%A5%E5%85%B7%E9%8F%88%E8%A8%AD%E8%A8%88/" data-link-title="mdtools：Go &#43; goldmark 的 markdown 工具鏈設計" data-link-desc="mdtools 的架構決策：選 Go &#43; goldmark 的理由（與 Hugo 同源保證 lint↔render 等價）、單 binary 多子命令設計、pre-commit 整合、規則開啟紀律。">mdtools 設計紀錄</a>：為什麼是 Go、為什麼是 goldmark、tripwire 怎麼設的</li>
<li><a href="/blog/posts/%E4%BB%80%E9%BA%BC%E6%98%AF-ast-%E5%BE%9E%E5%AD%97%E4%B8%B2%E5%88%B0%E8%AA%9E%E6%B3%95%E6%A8%B9%E7%9A%84%E8%A6%96%E8%A7%92%E8%BD%89%E6%8F%9B/" data-link-title="什麼是 AST — 從字串到語法樹的視角轉換" data-link-desc="AST 與 regex 的差異判準：規則需要知道文字處在什麼結構中時 regex 就不夠。附 regex 誤判的具體 case。">什麼是 AST</a>：AST vs regex 的概念說明</li>
<li><a href="/blog/posts/blog-markdown-%E5%AF%AB%E4%BD%9C%E8%A6%8F%E7%AF%84%E8%88%87-mdtools-%E6%AA%A2%E6%9F%A5/" data-link-title="Blog Markdown 寫作規範與 mdtools 檢查" data-link-desc="本 blog 的 Markdown 排版規範權威契約。涵蓋 H1 禁用、MD024 siblings_only、反釣魚 TLD 校驗、卡片雙向完整性、front matter schema；改規則時要與 scripts/mdtools 實作同步。">markdown 寫作規範</a>：工具要滿足的契約</li>
</ul>
<p>三個文件加起來約 900 行，寫作時間不到半天。</p>
<p><strong>為什麼留 artefact 比決策本身更重要</strong>：</p>
<ul>
<li>半年後同樣問題再浮現時，不會重跑一遍評估</li>
<li>新加入的協作者能快速跟上決策脈絡</li>
<li>Tripwire 條件寫下來才能被驗證（「三個月後有沒有命中？」）</li>
<li>反面證據出現時（例如發現 goldmark 有 bug），有清楚的位置記錄 revised decision</li>
</ul>
<p>沒 artefact 的決策基本上等於沒做過。<strong>決策是動作，artefact 才是沉澱</strong>。</p>
<h2 id="常見陷阱">常見陷阱</h2>
<h3 id="把-tripwire-設太低">把 tripwire 設太低</h3>
<p>「每週誤判一次就升級」實務上等於「每週升級」。Tripwire 要設在<strong>真的造成信譽或產出瓶頸</strong>的位置。</p>
<h3 id="把-tripwire-設太高">把 tripwire 設太高</h3>
<p>「等到 50% 誤判才升級」就太晚了 — 信譽早就垮了。合理範圍是 1-5% 誤判，或每週一次以上。</p>
<h3 id="用-tripwire-取代日常-review">用 tripwire 取代日常 review</h3>
<p>Tripwire 是「提醒重新評估」，不是「自動升級」。命中時要花時間評估，可能發現「還不該升，因為還有 X 原因」。Tripwire 是重新思考的觸發，不是自動化決策。</p>
<h3 id="忽視已命中的-tripwire">忽視已命中的 tripwire</h3>
<p>「這個誤判已經出現第四週了，但我還是覺得先不要升」— 這是在告訴自己原本的 tripwire 設錯了，不是在等更好的時機。重新評估 tripwire 本身，不是 ignore。</p>
<h2 id="擴充路徑">擴充路徑</h2>
<ul>
<li><strong>Decision log 範本</strong>：把團隊的決策過程寫成 template，讓下次不用從零開始</li>
<li><strong>Post-mortem of decisions</strong>：決策後三個月回頭看，把「當時怎麼想」跟「現在怎麼看」對照</li>
<li><strong>Pre-mortem 技巧</strong>：決策前假設「三個月後這決定被推翻，最可能的原因是什麼」，當成補充 tripwire</li>
</ul>
<h2 id="下一步">下一步</h2>
<p><a href="/blog/go/09-tooling-and-analysis/pre-commit-and-ci/" data-link-title="9.6 Pre-commit hook 與 CI 整合" data-link-desc="工具寫完只是起點；接到 pre-commit hook 跟 CI 才真正守住品質。Re-staging、dry-run vs apply、不能繞過的邊界">9.6 pre-commit hook 與 CI 整合</a> 回到工程落地，看工具怎麼從 binary 變成 commit 與 CI 流程裡的執行體。</p>
]]></content:encoded></item><item><title>9.6 Pre-commit hook 與 CI 整合</title><link>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/pre-commit-and-ci/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/pre-commit-and-ci/</guid><description>&lt;p>工具落地的核心責任是&lt;strong>讓檢查在對的時機自動執行&lt;/strong>，把紀律從「勤勞的人手動跑」轉移到「每次 commit / push 都跑」的基礎設施。&lt;a href="https://tarrragon.github.io/blog/go/glossary/#pre-commit-hook-%e5%ae%9a%e4%bd%8d" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">Pre-commit hook&lt;/a> 守本機開發、CI 守共享 branch；兩者互補、一起把規則失敗成本壓到秒級可回饋，避免 bug 漏到 production。這個模式對 &lt;a href="https://tarrragon.github.io/blog/go/glossary/#idempotent-%e6%96%87%e5%ad%97%e6%94%b9%e5%af%ab" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">idempotent&lt;/a> 工具特別重要 — hook 每次 commit 都會跑，非冪等的工具會累積漂移、讓作者反覆看到「為什麼這個檔案又被改了」的困惑。&lt;/p>
&lt;p>工具一旦從 CLI 進入 hook / CI，就有幾個容易出狀況的邊界：&lt;strong>哪些 check 該放 hook&lt;/strong>（快、本地可執行）、&lt;strong>哪些該放 CI&lt;/strong>（慢、需要乾淨環境）、&lt;strong>hook 改了檔怎麼 re-stage&lt;/strong>、&lt;strong>&amp;ndash;no-verify 的邊界&lt;/strong>怎麼約定、&lt;strong>CI strict mode 跟 local dev 的差異&lt;/strong>怎麼處理。本章展開這些問題，並以 &lt;code>.githooks/pre-commit&lt;/code> + &lt;code>.github/workflows/md-check.yml&lt;/code> 作為 concrete instance。&lt;/p>
&lt;h2 id="pre-commit-hook-能做什麼不該做什麼">Pre-commit hook 能做什麼、不該做什麼&lt;/h2>
&lt;p>&lt;strong>能做&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>讀 staged 檔案，跑 lint / fmt&lt;/li>
&lt;li>自動修正格式違規、&lt;code>git add&lt;/code> re-stage&lt;/li>
&lt;li>擋下 lint error 的 commit&lt;/li>
&lt;li>跑跨檔分析（cards）&lt;/li>
&lt;li>執行 build（確保程式碼能編譯）&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>不該做&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>執行完整 test suite（太慢，交給 CI）&lt;/li>
&lt;li>執行 e2e 或需要網路的操作（脆弱，commit 不該依賴外部）&lt;/li>
&lt;li>修改未 stage 的檔案（會造成 working tree 混亂）&lt;/li>
&lt;li>執行超過幾秒的任務（心流殺手）&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>原則&lt;/strong>：pre-commit 是&lt;strong>快速守門員&lt;/strong>，不是&lt;strong>完整驗證器&lt;/strong>。該做的 checks 要在秒級完成；更慢的驗證交給 CI。&lt;/p>
&lt;h2 id="makefile-作為-hook-與-ci-的共同介面">Makefile 作為 hook 與 CI 的共同介面&lt;/h2>
&lt;p>有個常被忽略的 pattern：&lt;strong>hook 跟 CI 都透過 Makefile 呼叫工具&lt;/strong>，不直接呼叫 binary。這讓三方共用同一套指令。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-makefile" data-lang="makefile">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c"># Makefile
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c">&lt;/span>&lt;span class="nv">MDTOOLS_SRC&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">$(&lt;/span>shell find scripts/mdtools -type f -name &lt;span class="s1">&amp;#39;*.go&amp;#39;&lt;/span> 2&amp;gt;/dev/null&lt;span class="k">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="nv">MDTOOLS_MOD&lt;/span> &lt;span class="o">:=&lt;/span> scripts/mdtools/go.mod scripts/mdtools/go.sum
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="nv">MDTOOLS_BIN&lt;/span> &lt;span class="o">:=&lt;/span> bin/mdtools
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="nf">.PHONY&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="n">build&lt;/span> &lt;span class="n">check&lt;/span> &lt;span class="n">fix&lt;/span> &lt;span class="n">lint&lt;/span> &lt;span class="n">cards&lt;/span> &lt;span class="n">install&lt;/span>-&lt;span class="n">hooks&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="nf">build&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="k">$(&lt;/span>&lt;span class="nv">MDTOOLS_BIN&lt;/span>&lt;span class="k">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="nf">$(MDTOOLS_BIN)&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="k">$(&lt;/span>&lt;span class="nv">MDTOOLS_SRC&lt;/span>&lt;span class="k">)&lt;/span> &lt;span class="k">$(&lt;/span>&lt;span class="nv">MDTOOLS_MOD&lt;/span>&lt;span class="k">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">	@mkdir -p bin
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">	@cd scripts/mdtools &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> go build -o ../../&lt;span class="k">$(&lt;/span>MDTOOLS_BIN&lt;span class="k">)&lt;/span> .
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="nf">check&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="n">build&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">	@./&lt;span class="k">$(&lt;/span>MDTOOLS_BIN&lt;span class="k">)&lt;/span> fmt --check content/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">	@./&lt;span class="k">$(&lt;/span>MDTOOLS_BIN&lt;span class="k">)&lt;/span> lint content/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">	@./&lt;span class="k">$(&lt;/span>MDTOOLS_BIN&lt;span class="k">)&lt;/span> cards content/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="nf">install-hooks&lt;/span>&lt;span class="o">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">	@git config core.hooksPath .githooks
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這樣：&lt;/p>
&lt;ul>
&lt;li>開發者本機：&lt;code>make check&lt;/code> 手動驗一次&lt;/li>
&lt;li>Pre-commit：hook 呼叫 &lt;code>./bin/mdtools ...&lt;/code>（或透過 Makefile target）&lt;/li>
&lt;li>CI：workflow 跑 &lt;code>make check&lt;/code>&lt;/li>
&lt;li>所有人看到的失敗訊息格式一致&lt;/li>
&lt;/ul>
&lt;p>Make 的依賴 timestamp 機制也剛好解決「binary 什麼時候重 build」— &lt;code>MDTOOLS_BIN&lt;/code> 依賴 &lt;code>MDTOOLS_SRC&lt;/code>，source 新於 binary 才重 build。&lt;/p></description><content:encoded><![CDATA[<p>工具落地的核心責任是<strong>讓檢查在對的時機自動執行</strong>，把紀律從「勤勞的人手動跑」轉移到「每次 commit / push 都跑」的基礎設施。<a href="/blog/go/glossary/#pre-commit-hook-%e5%ae%9a%e4%bd%8d" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">Pre-commit hook</a> 守本機開發、CI 守共享 branch；兩者互補、一起把規則失敗成本壓到秒級可回饋，避免 bug 漏到 production。這個模式對 <a href="/blog/go/glossary/#idempotent-%e6%96%87%e5%ad%97%e6%94%b9%e5%af%ab" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">idempotent</a> 工具特別重要 — hook 每次 commit 都會跑，非冪等的工具會累積漂移、讓作者反覆看到「為什麼這個檔案又被改了」的困惑。</p>
<p>工具一旦從 CLI 進入 hook / CI，就有幾個容易出狀況的邊界：<strong>哪些 check 該放 hook</strong>（快、本地可執行）、<strong>哪些該放 CI</strong>（慢、需要乾淨環境）、<strong>hook 改了檔怎麼 re-stage</strong>、<strong>&ndash;no-verify 的邊界</strong>怎麼約定、<strong>CI strict mode 跟 local dev 的差異</strong>怎麼處理。本章展開這些問題，並以 <code>.githooks/pre-commit</code> + <code>.github/workflows/md-check.yml</code> 作為 concrete instance。</p>
<h2 id="pre-commit-hook-能做什麼不該做什麼">Pre-commit hook 能做什麼、不該做什麼</h2>
<p><strong>能做</strong>：</p>
<ul>
<li>讀 staged 檔案，跑 lint / fmt</li>
<li>自動修正格式違規、<code>git add</code> re-stage</li>
<li>擋下 lint error 的 commit</li>
<li>跑跨檔分析（cards）</li>
<li>執行 build（確保程式碼能編譯）</li>
</ul>
<p><strong>不該做</strong>：</p>
<ul>
<li>執行完整 test suite（太慢，交給 CI）</li>
<li>執行 e2e 或需要網路的操作（脆弱，commit 不該依賴外部）</li>
<li>修改未 stage 的檔案（會造成 working tree 混亂）</li>
<li>執行超過幾秒的任務（心流殺手）</li>
</ul>
<p><strong>原則</strong>：pre-commit 是<strong>快速守門員</strong>，不是<strong>完整驗證器</strong>。該做的 checks 要在秒級完成；更慢的驗證交給 CI。</p>
<h2 id="makefile-作為-hook-與-ci-的共同介面">Makefile 作為 hook 與 CI 的共同介面</h2>
<p>有個常被忽略的 pattern：<strong>hook 跟 CI 都透過 Makefile 呼叫工具</strong>，不直接呼叫 binary。這讓三方共用同一套指令。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-makefile" data-lang="makefile"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># Makefile
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c"></span><span class="nv">MDTOOLS_SRC</span> <span class="o">:=</span> <span class="k">$(</span>shell find scripts/mdtools -type f -name <span class="s1">&#39;*.go&#39;</span> 2&gt;/dev/null<span class="k">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nv">MDTOOLS_MOD</span> <span class="o">:=</span> scripts/mdtools/go.mod scripts/mdtools/go.sum
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="nv">MDTOOLS_BIN</span> <span class="o">:=</span> bin/mdtools
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="nf">.PHONY</span><span class="o">:</span> <span class="n">build</span> <span class="n">check</span> <span class="n">fix</span> <span class="n">lint</span> <span class="n">cards</span> <span class="n">install</span>-<span class="n">hooks</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="nf">build</span><span class="o">:</span> <span class="k">$(</span><span class="nv">MDTOOLS_BIN</span><span class="k">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="nf">$(MDTOOLS_BIN)</span><span class="o">:</span> <span class="k">$(</span><span class="nv">MDTOOLS_SRC</span><span class="k">)</span> <span class="k">$(</span><span class="nv">MDTOOLS_MOD</span><span class="k">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">	@mkdir -p bin
</span></span><span class="line"><span class="ln">12</span><span class="cl">	@cd scripts/mdtools <span class="o">&amp;&amp;</span> go build -o ../../<span class="k">$(</span>MDTOOLS_BIN<span class="k">)</span> .
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="nf">check</span><span class="o">:</span> <span class="n">build</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">	@./<span class="k">$(</span>MDTOOLS_BIN<span class="k">)</span> fmt --check content/
</span></span><span class="line"><span class="ln">16</span><span class="cl">	@./<span class="k">$(</span>MDTOOLS_BIN<span class="k">)</span> lint content/
</span></span><span class="line"><span class="ln">17</span><span class="cl">	@./<span class="k">$(</span>MDTOOLS_BIN<span class="k">)</span> cards content/
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="nf">install-hooks</span><span class="o">:</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">	@git config core.hooksPath .githooks
</span></span></code></pre></div><p>這樣：</p>
<ul>
<li>開發者本機：<code>make check</code> 手動驗一次</li>
<li>Pre-commit：hook 呼叫 <code>./bin/mdtools ...</code>（或透過 Makefile target）</li>
<li>CI：workflow 跑 <code>make check</code></li>
<li>所有人看到的失敗訊息格式一致</li>
</ul>
<p>Make 的依賴 timestamp 機制也剛好解決「binary 什麼時候重 build」— <code>MDTOOLS_BIN</code> 依賴 <code>MDTOOLS_SRC</code>，source 新於 binary 才重 build。</p>
<h2 id="pre-commit-hook-實作">Pre-commit hook 實作</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="cp"></span><span class="c1"># .githooks/pre-commit</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="nv">MDTOOLS_BIN</span><span class="o">=</span><span class="s2">&#34;bin/mdtools&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="nv">REPO_ROOT</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>git rev-parse --show-toplevel<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="nb">cd</span> <span class="s2">&#34;</span><span class="nv">$REPO_ROOT</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># 沒 staged .md 快速退出</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="nv">staged_md</span><span class="o">=</span><span class="k">$(</span>git diff --cached --name-only --diff-filter<span class="o">=</span>ACMR <span class="p">|</span> grep -E <span class="s1">&#39;\.md$&#39;</span> <span class="o">||</span> <span class="nb">true</span><span class="k">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="k">if</span> <span class="o">[[</span> -z <span class="s2">&#34;</span><span class="nv">$staged_md</span><span class="s2">&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nb">exit</span> <span class="m">0</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"># Rebuild if source newer than binary</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="k">if</span> <span class="o">[[</span> ! -x <span class="s2">&#34;</span><span class="nv">$MDTOOLS_BIN</span><span class="s2">&#34;</span> <span class="o">]]</span> <span class="o">||</span> <span class="o">[[</span> -n <span class="s2">&#34;</span><span class="k">$(</span>find scripts/mdtools -type f -name <span class="s1">&#39;*.go&#39;</span> -newer <span class="s2">&#34;</span><span class="nv">$MDTOOLS_BIN</span><span class="s2">&#34;</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span><span class="k">)</span><span class="s2">&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;[pre-commit] rebuilding mdtools...&#34;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="o">(</span><span class="nb">cd</span> scripts/mdtools <span class="o">&amp;&amp;</span> go build -o <span class="s2">&#34;</span><span class="nv">$REPO_ROOT</span><span class="s2">/</span><span class="nv">$MDTOOLS_BIN</span><span class="s2">&#34;</span> .<span class="o">)</span> <span class="o">||</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="nb">echo</span> <span class="s2">&#34;[pre-commit] mdtools build failed&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="o">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="c1"># fmt --fix on staged，re-stage 變動的檔案</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;[pre-commit] mdtools fmt --fix&#34;</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="k">while</span> <span class="nv">IFS</span><span class="o">=</span> <span class="nb">read</span> -r f<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="o">[[</span> -z <span class="s2">&#34;</span><span class="nv">$f</span><span class="s2">&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="k">continue</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="nv">before</span><span class="o">=</span><span class="k">$(</span>git hash-object <span class="s2">&#34;</span><span class="nv">$f</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="s2">&#34;</span><span class="nv">$MDTOOLS_BIN</span><span class="s2">&#34;</span> fmt --fix <span class="s2">&#34;</span><span class="nv">$f</span><span class="s2">&#34;</span> &gt;/dev/null
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="nv">after</span><span class="o">=</span><span class="k">$(</span>git hash-object <span class="s2">&#34;</span><span class="nv">$f</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="k">if</span> <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$before</span><span class="s2">&#34;</span> !<span class="o">=</span> <span class="s2">&#34;</span><span class="nv">$after</span><span class="s2">&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">        git add <span class="s2">&#34;</span><span class="nv">$f</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl"><span class="k">done</span> <span class="o">&lt;&lt;&lt;</span> <span class="s2">&#34;</span><span class="nv">$staged_md</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">
</span></span><span class="line"><span class="ln">36</span><span class="cl"><span class="c1"># lint on staged (擋錯)</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;[pre-commit] mdtools lint&#34;</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl"><span class="s2">&#34;</span><span class="nv">$MDTOOLS_BIN</span><span class="s2">&#34;</span> lint <span class="nv">$staged_md</span> <span class="o">||</span> <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">
</span></span><span class="line"><span class="ln">40</span><span class="cl"><span class="c1"># cards on full content (擋錯)</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;[pre-commit] mdtools cards&#34;</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl"><span class="s2">&#34;</span><span class="nv">$MDTOOLS_BIN</span><span class="s2">&#34;</span> cards content/ <span class="o">||</span> <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl">
</span></span><span class="line"><span class="ln">44</span><span class="cl"><span class="nb">exit</span> <span class="m">0</span></span></span></code></pre></div><p>幾個<strong>關鍵 pattern</strong>：</p>
<h3 id="fast-exit-when-no-markdown-staged">Fast exit when no markdown staged</h3>
<p>沒 md 改動時 hook 在 10ms 內退出。Go 工程師改 <code>.go</code> 檔時不會被 markdown hook 擋。這是使用者體驗的生死線。</p>
<h3 id="git-diff---cached---diff-filteracmr"><code>git diff --cached --diff-filter=ACMR</code></h3>
<ul>
<li><code>A</code> (added), <code>C</code> (copied), <code>M</code> (modified), <code>R</code> (renamed) — 該檢查的變動類型</li>
<li>排除 <code>D</code> (deleted) — 刪除的檔案不用 lint</li>
</ul>
<h3 id="git-hash-object-偵測實際變動"><code>git hash-object</code> 偵測實際變動</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nv">before</span><span class="o">=</span><span class="k">$(</span>git hash-object <span class="s2">&#34;</span><span class="nv">$f</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">./bin/mdtools fmt --fix <span class="s2">&#34;</span><span class="nv">$f</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nv">after</span><span class="o">=</span><span class="k">$(</span>git hash-object <span class="s2">&#34;</span><span class="nv">$f</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$before</span><span class="s2">&#34;</span> !<span class="o">=</span> <span class="s2">&#34;</span><span class="nv">$after</span><span class="s2">&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> git add <span class="s2">&#34;</span><span class="nv">$f</span><span class="s2">&#34;</span></span></span></code></pre></div><p>只在檔案<strong>內容實際改變</strong>時 re-stage。如果 <code>fmt --fix</code> 跑完沒改東西（檔案已經 compliant），不觸發多餘 <code>git add</code>。</p>
<p>避免用 <code>stat</code> 或 mtime — 那些會誤判（file touched 但內容相同）。</p>
<h3 id="分層-exit-code">分層 exit code</h3>
<ul>
<li>Fast exit (<code>exit 0</code>)：沒事要做</li>
<li>Lint error (<code>exit 1</code>)：違規，擋 commit</li>
<li>Build failure (<code>exit 1</code>)：工具壞了，擋 commit（比讓人用壞工具好）</li>
</ul>
<p>Git 看 non-zero 就會阻止 commit，訊息會印到 terminal，作者能看到原因。</p>
<h2 id="ci-workflow">CI workflow</h2>
<p>CI 跑得比 hook 更嚴格：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># .github/workflows/md-check.yml</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">md-check</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">  </span><span class="nt">md-check</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/setup-go@v5</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">          </span><span class="nt">go-version-file</span><span class="p">:</span><span class="w"> </span><span class="l">scripts/mdtools/go.mod</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">          </span><span class="nt">cache-dependency-path</span><span class="p">:</span><span class="w"> </span><span class="l">scripts/mdtools/go.sum</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Build mdtools</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="sd">          mkdir -p bin
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="sd">          (cd scripts/mdtools &amp;&amp; go build -o ../../bin/mdtools .)</span><span class="w">
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">fmt --check</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">./bin/mdtools fmt --check content/</span><span class="w">
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">lint</span><span class="w">
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">./bin/mdtools lint content/</span><span class="w">
</span></span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">cards</span><span class="w">
</span></span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">./bin/mdtools cards content/</span></span></span></code></pre></div><p><strong>設計決策</strong>：</p>
<h3 id="go-version-file-scriptsmdtoolsgomod"><code>go-version-file: scripts/mdtools/go.mod</code></h3>
<p>讓 CI 從 <code>go.mod</code> 讀取 Go 版本。<code>go.mod</code> 裡寫 <code>go 1.25.1</code>，CI 自動用匹配的版本；本機升 Go 時 workflow 跟著同步。</p>
<h3 id="ci-用---check-而非---fix">CI 用 <code>--check</code> 而非 <code>--fix</code></h3>
<p>CI 的角色是<strong>偵測</strong>，修復留給本機。<code>--check</code> 發現問題就 fail，讓作者在本機修完再 push。若 CI 自動 <code>--fix</code> 然後 commit，會造成「CI 偷改作者 PR」的混亂。</p>
<h3 id="不寫-try-catch-吞錯">不寫 try-catch 吞錯</h3>
<p>CI 步驟失敗就 fail — 不要寫 <code>continue-on-error: true</code> 藏錯誤。早期接工具時覺得「讓 CI 通過先」很誘人，但藏錯誤等於工具沒生效。寧可 CI 紅，也要誠實。</p>
<h2 id="安裝-hook-的-ux">安裝 hook 的 UX</h2>
<p><code>.githooks/pre-commit</code> 放進 repo，但 git 預設看 <code>.git/hooks/</code>，不會自動啟用。要跑：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git config core.hooksPath .githooks</span></span></code></pre></div><p>包成 Makefile target：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-makefile" data-lang="makefile"><span class="line"><span class="ln">1</span><span class="cl"><span class="nf">install-hooks</span><span class="o">:</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">	@git config core.hooksPath .githooks
</span></span><span class="line"><span class="ln">3</span><span class="cl">	@echo <span class="s2">&#34;hooks installed&#34;</span>
</span></span></code></pre></div><p>README 或 CONTRIBUTING.md 要寫「新 clone 時執行 <code>make install-hooks</code>」。這是一次性動作，但必要。</p>
<p><strong>考慮過的替代方案</strong>：</p>
<ul>
<li>放 <code>.git/hooks/pre-commit</code> — 不能 commit 進 repo，每個 clone 都要重設</li>
<li>用 <code>husky</code> / <code>pre-commit</code> 等工具 — 增加依賴，值得與否看團隊</li>
<li>用 direnv <code>.envrc</code> 自動設定 — 依賴 direnv，非標準</li>
</ul>
<p>最乾淨是 Makefile target + 明確的 onboarding 步驟。</p>
<h2 id="不能繞過的邊界">不能繞過的邊界</h2>
<p>Pre-commit hook 可以用 <code>--no-verify</code> 跳過。規範要明寫：</p>
<blockquote>
<p>寫作時遇到 pre-commit 報錯：讀錯誤訊息並修正，<strong>不可用 <code>--no-verify</code> 繞過 hook</strong>。</p></blockquote>
<p>這是<strong>社會規範而非技術強制</strong> — 技術上 git 一定允許 <code>--no-verify</code>。但只要規範明列、有人做 code review 抓到違規，就足夠維持紀律。</p>
<p><strong>有個 nuance</strong>：緊急情況真的需要 <code>--no-verify</code> 怎麼辦？例如在服務中斷時要緊急 commit 修復。規範要留這個緊急閥門，但搭配：</p>
<ul>
<li>事後必須補 commit 把違規修掉</li>
<li><code>--no-verify</code> 的使用要 log 或在 PR 描述標註</li>
</ul>
<p>大多數 repo 一年可能用不到兩次。關鍵是<strong>預設是不繞過</strong>，而不是「看情況」。</p>
<h2 id="常見陷阱">常見陷阱</h2>
<h3 id="hook-執行時間爆炸">Hook 執行時間爆炸</h3>
<p>常見在 <code>cards</code> 這類需要 parse 全 repo 的 check。對 400 檔 &lt; 1 秒可接受；對 10000 檔就要評估。降級手段：</p>
<ul>
<li><code>cards</code> 只跑受影響的子圖（根據 staged 檔案 inferrence）</li>
<li>複雜 check 搬到 CI</li>
<li>本機加 cache（invalidate on file mtime）</li>
</ul>
<h3 id="binary-不-commit-進-repo但-ci-失敗">Binary 不 commit 進 repo，但 CI 失敗</h3>
<p><code>.gitignore</code> 排除 <code>bin/</code>，所以 CI checkout 時沒有 binary。要記得在 CI 加 build step（上面 workflow 的 <code>Build mdtools</code> step）。</p>
<h3 id="fmt-fix-後-commit-有兩個版本">fmt &ndash;fix 後 commit 有兩個版本</h3>
<p>若 hook 的 <code>fmt --fix</code> 改了檔但 re-stage 失敗（例如 permission 問題），作者以為 commit 成功但實際 commit 的是舊版本。每次 staged 版本跟 working tree 都要同步 — <code>git hash-object</code> 比對能早期發現不一致。</p>
<h3 id="hook-不能跨平台">Hook 不能跨平台</h3>
<p>macOS / Linux 的 bash hook 在 Windows（未裝 WSL 或 Git Bash）可能不執行。如果 contributor 有 Windows，把 hook 寫成 Go 程式（例如 <code>bin/mdtools hook pre-commit</code>），讓 Go 本身處理跨平台。</p>
<h2 id="擴充路徑">擴充路徑</h2>
<ul>
<li><strong>Hook 只跑 staged 子圖</strong>：根據 staged files 推算需要 parse 的 repo subset，降低 hook 延遲。</li>
<li><strong>CI artifact 留 report</strong>：把 lint / cards 的報告 upload 成 GitHub Actions artifact，讓 PR 評論能連結到完整報告。</li>
<li><strong>Pre-push hook 做更重檢查</strong>：把 full test suite 放 pre-push（本機 push 前跑），更頻繁的 pre-commit 只做格式與 lint。</li>
</ul>
<h2 id="模組總結">模組總結</h2>
<p>走完九個章節，回到出發點：<strong>Go 除了寫後端服務，還能寫內部工具 / 靜態分析 / CLI / 程式碼生成</strong>。跟後端服務的差異在於生命週期、I/O 模式、錯誤處理慣例，但共用的 Go 技能（型別、interface、package、error）完全可遷移。</p>
<p>本模組介紹的技術 — stdlib flag、goldmark AST、idempotent rewriting、graph analysis、tripwire 決策、pre-commit 整合 — 適用範圍不只 markdown 工具。寫 linter、codegen、migration tool、build tool、dev helper 都是這些技術的組合。</p>
<p>下一步：動手把 <code>scripts/mdtools</code> clone 出來，加一條自己的 rule 進去。真正讀懂一個工具的方式是改它一次。</p>
]]></content:encoded></item><item><title>9.0 Go 在工具鏈生態的位置</title><link>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/overview/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/overview/</guid><description>&lt;p>工具類 Go 程式的核心責任是&lt;strong>完成一次特定工作就退出&lt;/strong>：讀輸入、處理、寫輸出、結束。這個生命週期特徵決定了它的結構 — 以短命、I/O 為主、錯誤即時中斷為預設，跟服務類 Go 長期健康運行的假設正好相反。本章先把這個前提講清楚，後續章節對 main 結構、goroutine 用法、錯誤處理的安排才能看懂為什麼長那樣。&lt;/p>
&lt;p>工具類跟服務類的差異常被隱晦地帶過，於是後端工程師轉寫工具時會帶進服務的慣性（長時 goroutine pool、defensive 錯誤降級、龐大依賴樹），讓工具變得重而難維護。把分野講清楚比給 cheatsheet 有用 — 後續每個模式落地時，讀者自己會判斷該採哪套預設。&lt;/p>
&lt;h2 id="業界哪些人在用-go-寫工具">業界哪些人在用 Go 寫工具&lt;/h2>
&lt;p>下列工具都用 Go 寫成，讀者多半每天都在使用或間接依賴它們：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>hugo&lt;/strong> — 靜態網站產生器，parse markdown + render template + serve dev。&lt;/li>
&lt;li>&lt;strong>kubectl&lt;/strong> / &lt;strong>helm&lt;/strong> — Kubernetes 的 CLI 客戶端，parse YAML + call API + render output。&lt;/li>
&lt;li>&lt;strong>terraform&lt;/strong> — 基礎設施描述語言的 interpreter + state management。&lt;/li>
&lt;li>&lt;strong>gh&lt;/strong> — GitHub CLI，把 REST/GraphQL API 包成命令列操作。&lt;/li>
&lt;li>&lt;strong>goldmark&lt;/strong> — CommonMark parser，提供 AST 給其他 Go 程式使用。&lt;/li>
&lt;li>&lt;strong>stringer&lt;/strong> / &lt;strong>gopls&lt;/strong> — Go 官方工具鏈，分析 Go 原始碼並產生程式碼或語言服務。&lt;/li>
&lt;li>&lt;strong>golangci-lint&lt;/strong> — 聚合多個 Go linter 的 runner。&lt;/li>
&lt;li>&lt;strong>caddy&lt;/strong> / &lt;strong>traefik&lt;/strong> — 雖然是服務，但以 CLI-first 配置見長。&lt;/li>
&lt;li>&lt;strong>protobuf / grpc&lt;/strong> 的 code generator — 讀 IDL、吐 Go 程式碼。&lt;/li>
&lt;/ul>
&lt;p>這些程式都享受 Go 的幾個特定優勢：單一 binary 跨平台部署、快速啟動、stdlib 的 I/O 與檔案系統支援、goroutine 讓 pipeline fan-out 便宜、型別系統防止參數解析等瑣碎錯誤。&lt;/p>
&lt;h2 id="工具類-go-跟服務類-go-的結構差異">工具類 Go 跟服務類 Go 的結構差異&lt;/h2>
&lt;p>多數後端工程師轉去寫工具會遇到幾個慣性衝突。本節列五個最明顯的。&lt;/p>
&lt;h3 id="生命週期短命而非長時">生命週期：短命而非長時&lt;/h3>
&lt;p>服務類 Go 跑起來就不預期結束 — goroutine pool、connection pool、graceful shutdown、health check 都繞著「長時健康運行」打轉。工具類 Go 預設是&lt;strong>執行、完成工作、退出&lt;/strong>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">main&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">run&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fprintln&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Stderr&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Exit&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>沒有 &lt;code>for { select {} }&lt;/code> 的主迴圈，也不用註冊 signal handler 做 graceful 收尾（OS 會幫你回收檔案描述子）。延伸影響：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>錯誤處理偏向中斷&lt;/strong>：服務類在錯誤時常選擇降級、記錄、繼續；工具類多半直接退出並印訊息，交給呼叫者決定下一步。&lt;/li>
&lt;li>&lt;strong>goroutine 用法保守&lt;/strong>：工具很少要 100 個並發 worker。有也多半是 pipeline 的 fan-out，而非長時 pool。&lt;/li>
&lt;li>&lt;strong>context 用法簡單&lt;/strong>：很少需要 &lt;code>context.WithCancel&lt;/code>，&lt;code>context.Background()&lt;/code> 經常夠用。&lt;/li>
&lt;/ul>
&lt;h3 id="輸入輸出檔案--stdin--命令列而非-http--queue">輸入輸出：檔案 / stdin / 命令列，而非 HTTP / queue&lt;/h3>
&lt;p>服務讀 request body、寫 response JSON、從 queue 拉訊息。工具讀檔案、parse 命令列 flag、接 stdin pipe、寫 stdout。這改變了幾個預設：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>輸入格式多元&lt;/strong>：工具常處理 markdown、YAML、CSV、純文字；encoding/json 是基礎而非核心，其他 parser 反而更常用。&lt;/li>
&lt;li>&lt;strong>&lt;code>os.ReadFile&lt;/code> / &lt;code>os.WriteFile&lt;/code> / &lt;code>filepath.WalkDir&lt;/code> 是主力&lt;/strong>。Path 處理（&lt;code>filepath.Rel&lt;/code>、&lt;code>filepath.ToSlash&lt;/code>）會反覆出現。&lt;/li>
&lt;li>&lt;strong>stdout 是結果通道&lt;/strong>。服務的 log 跟 response 是兩條 stream；工具的 log 跟 output 經常搶同一條 stream，需要嚴格 discipline：log 全部走 stderr，output 走 stdout，讓使用者能 pipe。&lt;/li>
&lt;/ul>
&lt;h3 id="錯誤處理accumulate-or-fail-fast">錯誤處理：accumulate or fail-fast&lt;/h3>
&lt;p>服務處理單一 request 時 fail-fast 容易（回 500、log 原因）。工具常一次處理多個輸入（批次 lint 300 個檔案），需要決定：&lt;/p></description><content:encoded><![CDATA[<p>工具類 Go 程式的核心責任是<strong>完成一次特定工作就退出</strong>：讀輸入、處理、寫輸出、結束。這個生命週期特徵決定了它的結構 — 以短命、I/O 為主、錯誤即時中斷為預設，跟服務類 Go 長期健康運行的假設正好相反。本章先把這個前提講清楚，後續章節對 main 結構、goroutine 用法、錯誤處理的安排才能看懂為什麼長那樣。</p>
<p>工具類跟服務類的差異常被隱晦地帶過，於是後端工程師轉寫工具時會帶進服務的慣性（長時 goroutine pool、defensive 錯誤降級、龐大依賴樹），讓工具變得重而難維護。把分野講清楚比給 cheatsheet 有用 — 後續每個模式落地時，讀者自己會判斷該採哪套預設。</p>
<h2 id="業界哪些人在用-go-寫工具">業界哪些人在用 Go 寫工具</h2>
<p>下列工具都用 Go 寫成，讀者多半每天都在使用或間接依賴它們：</p>
<ul>
<li><strong>hugo</strong> — 靜態網站產生器，parse markdown + render template + serve dev。</li>
<li><strong>kubectl</strong> / <strong>helm</strong> — Kubernetes 的 CLI 客戶端，parse YAML + call API + render output。</li>
<li><strong>terraform</strong> — 基礎設施描述語言的 interpreter + state management。</li>
<li><strong>gh</strong> — GitHub CLI，把 REST/GraphQL API 包成命令列操作。</li>
<li><strong>goldmark</strong> — CommonMark parser，提供 AST 給其他 Go 程式使用。</li>
<li><strong>stringer</strong> / <strong>gopls</strong> — Go 官方工具鏈，分析 Go 原始碼並產生程式碼或語言服務。</li>
<li><strong>golangci-lint</strong> — 聚合多個 Go linter 的 runner。</li>
<li><strong>caddy</strong> / <strong>traefik</strong> — 雖然是服務，但以 CLI-first 配置見長。</li>
<li><strong>protobuf / grpc</strong> 的 code generator — 讀 IDL、吐 Go 程式碼。</li>
</ul>
<p>這些程式都享受 Go 的幾個特定優勢：單一 binary 跨平台部署、快速啟動、stdlib 的 I/O 與檔案系統支援、goroutine 讓 pipeline fan-out 便宜、型別系統防止參數解析等瑣碎錯誤。</p>
<h2 id="工具類-go-跟服務類-go-的結構差異">工具類 Go 跟服務類 Go 的結構差異</h2>
<p>多數後端工程師轉去寫工具會遇到幾個慣性衝突。本節列五個最明顯的。</p>
<h3 id="生命週期短命而非長時">生命週期：短命而非長時</h3>
<p>服務類 Go 跑起來就不預期結束 — goroutine pool、connection pool、graceful shutdown、health check 都繞著「長時健康運行」打轉。工具類 Go 預設是<strong>執行、完成工作、退出</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">run</span><span class="p">();</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="nx">fmt</span><span class="p">.</span><span class="nf">Fprintln</span><span class="p">(</span><span class="nx">os</span><span class="p">.</span><span class="nx">Stderr</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">os</span><span class="p">.</span><span class="nf">Exit</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>沒有 <code>for { select {} }</code> 的主迴圈，也不用註冊 signal handler 做 graceful 收尾（OS 會幫你回收檔案描述子）。延伸影響：</p>
<ul>
<li><strong>錯誤處理偏向中斷</strong>：服務類在錯誤時常選擇降級、記錄、繼續；工具類多半直接退出並印訊息，交給呼叫者決定下一步。</li>
<li><strong>goroutine 用法保守</strong>：工具很少要 100 個並發 worker。有也多半是 pipeline 的 fan-out，而非長時 pool。</li>
<li><strong>context 用法簡單</strong>：很少需要 <code>context.WithCancel</code>，<code>context.Background()</code> 經常夠用。</li>
</ul>
<h3 id="輸入輸出檔案--stdin--命令列而非-http--queue">輸入輸出：檔案 / stdin / 命令列，而非 HTTP / queue</h3>
<p>服務讀 request body、寫 response JSON、從 queue 拉訊息。工具讀檔案、parse 命令列 flag、接 stdin pipe、寫 stdout。這改變了幾個預設：</p>
<ul>
<li><strong>輸入格式多元</strong>：工具常處理 markdown、YAML、CSV、純文字；encoding/json 是基礎而非核心，其他 parser 反而更常用。</li>
<li><strong><code>os.ReadFile</code> / <code>os.WriteFile</code> / <code>filepath.WalkDir</code> 是主力</strong>。Path 處理（<code>filepath.Rel</code>、<code>filepath.ToSlash</code>）會反覆出現。</li>
<li><strong>stdout 是結果通道</strong>。服務的 log 跟 response 是兩條 stream；工具的 log 跟 output 經常搶同一條 stream，需要嚴格 discipline：log 全部走 stderr，output 走 stdout，讓使用者能 pipe。</li>
</ul>
<h3 id="錯誤處理accumulate-or-fail-fast">錯誤處理：accumulate or fail-fast</h3>
<p>服務處理單一 request 時 fail-fast 容易（回 500、log 原因）。工具常一次處理多個輸入（批次 lint 300 個檔案），需要決定：</p>
<ul>
<li><strong>Fail-fast</strong>：第一個錯誤就退出 — 適合 <code>make check</code> 這類 gate。</li>
<li><strong>Accumulate</strong>：蒐集全部錯誤一起報告 — 適合 <code>lint</code> 這類讓使用者看全貌的模式。</li>
</ul>
<p><code>mdtools lint</code> 就選 accumulate：一次 parse 全部 content，收齊所有 violations，sort 後輸出，退出碼反映是否有 error。作者可以一次看到所有問題，不用反覆跑。</p>
<h3 id="依賴管理盡可能-stdlib">依賴管理：盡可能 stdlib</h3>
<p>服務的 go.mod 動輒幾十個 require — ORM、HTTP router、metrics、tracing、queue client 全要。工具圈文化明顯保守：</p>
<ul>
<li>很多優秀 Go CLI 工具只有 <strong>1-3 個</strong> direct dependency。</li>
<li>標準選型是先看 <code>flag</code> + <code>os</code> + <code>filepath</code> + <code>encoding/*</code> 能否滿足。</li>
<li>確實需要外部 parser、terminal UI、或結構化資料函式庫時才引入，而非預設。</li>
</ul>
<p>這個 convention 出於實用考量：工具經常作為單一 binary 發佈，依賴越少、build 越快、跨平台問題越少。</p>
<h3 id="部署binary-而非-container">部署：binary 而非 container</h3>
<p>服務類部署到 k8s，工具類部署成 <code>go install example.com/tool@latest</code> 的 binary。連帶的預設：</p>
<ul>
<li><strong>配置用 CLI flag + 環境變數覆蓋</strong>：真正需要結構化配置時才引入 config schema。</li>
<li><strong>版本管理用 build tag</strong>：<code>go build -ldflags &quot;-X main.Version=...&quot;</code> 把版本刻進 binary。</li>
<li><strong>升級由 <code>go install</code> 承接</strong>：使用者重跑 <code>go install</code> 拉最新版，end-user 工具（hugo / kubectl）才額外設計自更新。</li>
</ul>
<h2 id="工具選型的判讀表">工具選型的判讀表</h2>
<p>工具語言選型的核心判準是<strong>工作負載特徵</strong>：要不要跨平台分發、要不要處理大量 I/O、要不要整合既有生態。下表給八個判讀情境，各自有展開說明。</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>偏好 Go</th>
          <th>偏好其他</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單一 binary 跨平台分發</td>
          <td>是</td>
          <td>shell / Python 要求受眾處理執行環境</td>
      </tr>
      <tr>
          <td>大量檔案 I/O + 併發加速</td>
          <td>是</td>
          <td>shell 慢、Python GIL 是 CPU 瓶頸</td>
      </tr>
      <tr>
          <td>Parse 複雜格式（markdown、AST、protobuf）</td>
          <td>是</td>
          <td>shell 寫起來會變成 awk/sed 煉金術</td>
      </tr>
      <tr>
          <td>整合 Go 生態（goldmark、go/ast、x/tools）</td>
          <td>是</td>
          <td>跨語言整合成本（FFI、serialization）</td>
      </tr>
      <tr>
          <td>一次性 one-liner（grep、sed 可解）</td>
          <td></td>
          <td>shell</td>
      </tr>
      <tr>
          <td>要用 ML / 資料科學套件</td>
          <td></td>
          <td>Python（PyTorch、pandas）</td>
      </tr>
      <tr>
          <td>快速 prototype、throw-away 腳本</td>
          <td></td>
          <td>Python（動筆 3 倍快）</td>
      </tr>
      <tr>
          <td>需要 REPL 互動探索</td>
          <td></td>
          <td>Python / Node / Clojure</td>
      </tr>
  </tbody>
</table>
<p><strong>單一 binary 跨平台分發</strong>：工具的使用者可能分散在 macOS / Linux / Windows，每個人的 runtime 版本不同。Python 工具要求受眾先裝 Python、確定 3.x vs 2.7、管好 venv；shell 工具在不同 shell（bash / zsh / dash）行為分歧。Go 的靜態編譯讓一個 binary 直接丟出去能跑，這是推廣一個工具時最大的摩擦減法。適用信號：使用者超過 3 人、或使用者非工程師。反例：只在 CI 環境跑的 hook，runtime 已經固定，shell / Python 成本低。</p>
<p><strong>大量檔案 I/O + 併發加速</strong>：linter 跑 1000 個檔案、migration 處理 10000 個 record，都是 I/O 密集任務。Go 的 goroutine + channel 讓 pipeline fan-out 極便宜，shell 靠 <code>xargs -P</code> 也能做但錯誤處理很脆弱；Python 的 GIL 限制真併發，得靠 multiprocessing 增加複雜度。信號：處理量超過「等半秒等得住」、或錯誤需要結構化蒐集。反例：處理一次、規模小於 100 檔，shell 反而快。</p>
<p><strong>Parse 複雜格式</strong>：markdown、YAML、protobuf、Go 原始碼這類格式需要完整 parser，自己寫 AST walker 成本高。Go 有大量成熟 parser（goldmark、x/net/html、go/parser）可直接 import；shell 靠 grep / awk 拼不出正確解析；Python 的對應 parser（mistune、lxml）也成熟但跟 Go 生態隔離。信號：錯誤率 regex 已經解不乾淨。反例：純粹的文字搜尋 / 取代，regex 穩定勝出。</p>
<p><strong>整合 Go 生態</strong>：要讀 Go 原始碼（gopls、stringer）、要跟 Hugo / Kubernetes 控制平面互動、要產生 Go 程式碼。這些場景跨語言整合成本高（要 FFI 或 serialization 橋接），Go 原生最直接。信號：上游依賴是 Go 專案、或產出物是 Go 程式碼。反例：跟 Python / JavaScript 生態為主時，用該語言更順。</p>
<p><strong>一次性 one-liner</strong>：要做的事 grep / sed / awk 十行內能解決，沒有可觀的重複執行需求。用 Go 寫等於建立一個新 binary、build pipeline、版本管理 — 投資回不來。信號：命令能在 shell 下一口氣打完。反例：同樣 logic 要在三個地方重複貼，就該升級成腳本。</p>
<p><strong>要用 ML / 資料科學套件</strong>：PyTorch、pandas、scikit-learn 沒有 Go 生態等價物。Go 有 gonum、但離 Python ML stack 的工具豐富度差一個數量級。信號：要調 model、做 EDA、畫圖表。反例：只是簡單統計彙總，Go 夠用。</p>
<p><strong>快速 prototype、throw-away 腳本</strong>：動筆成本比 runtime 效能重要。Python 寫一個 50 行 script 的心智負擔比 Go 低（不用宣告型別、不用 import 大堆 package、REPL 可探索）。信號：要先弄清楚問題形狀。反例：prototype 很快會變成正式工具，Go 直接上反而省重寫。</p>
<p><strong>需要 REPL 互動探索</strong>：Python / Node / Clojure 有成熟 REPL 文化，能邊試邊寫；Go 的 REPL 工具（yaegi 等）存在但非主流。信號：要實驗資料結構、API 行為、或設計決策。反例：解法已確定，不需要試 — Go 的 test-driven 小程式效果也不差。</p>
<h2 id="mdtools-作為本模組的-worked-example">mdtools 作為本模組的 worked example</h2>
<p>本模組每一章講一個可複用的工具開發技術，同時用 <code>scripts/mdtools</code>（blog 自己用的 markdown 品質工具鏈，實體檔案在本 repo）作為 <strong>concrete instance</strong>。讀者不需要預先熟悉 mdtools — 每章會先講通用 pattern，再用 mdtools 的對應 code 示範一種可行實作。以下是 mdtools 的全貌，方便後面章節引用時有背景：</p>
<ul>
<li><strong>目的</strong>：保證 blog 的 <code>content/**/*.md</code> 在 commit 前符合規範文件（<code>content/posts/markdown-writing-spec.md</code>）的所有約束。</li>
<li><strong>結構</strong>：單一 binary <code>bin/mdtools</code>，三個子命令 — <code>fmt</code>（格式修正）、<code>lint</code>（結構檢查）、<code>cards</code>（跨檔完整性）、加一個 <code>migrate</code>（一次性批量修正）。</li>
<li><strong>實作層</strong>：
<ul>
<li><code>internal/mdfmt</code> — 行為層 format rule，idempotent。</li>
<li><code>internal/mdlint</code> — AST 層結構 rule，只報告。</li>
<li><code>internal/mdcards</code> — 跨檔 link graph，報告 L1 / L2 / L4 違規。</li>
<li><code>internal/mdmigrate</code> — 讀 graph、計算可自動化的修復、改檔。</li>
</ul>
</li>
<li><strong>依賴</strong>：stdlib + <code>github.com/yuin/goldmark</code> + <code>github.com/mattn/go-runewidth</code>（僅此三個 direct）。</li>
<li><strong>整合</strong>：<code>.githooks/pre-commit</code> 跟 <code>.github/workflows/md-check.yml</code> 讓工具在每次 commit / push 都跑。</li>
</ul>
<p>本模組的章節會逐層展開這些實作背後的 Go 技術。</p>
<h2 id="下一步">下一步</h2>
<p>進入 <a href="/blog/go/09-tooling-and-analysis/stdlib-flag-subcommands/" data-link-title="9.1 用 stdlib flag 寫 subcommand CLI" data-link-desc="Go 的 flag 套件足以支撐多層 subcommand 的 CLI，不用過早引入 cobra；本章示範 main → cmd/ → internal/ 的標準 layout">9.1 stdlib flag 做 subcommand CLI</a> 開始看具體實作。</p>
]]></content:encoded></item></channel></rss>