<?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>Tooling on Tarragon</title><link>https://tarrragon.github.io/blog/tags/tooling/</link><description>Recent content in Tooling on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Sat, 27 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/tooling/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>1.1 Go 專案結構與 module</title><link>https://tarrragon.github.io/blog/go/01-basics/modules/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/01-basics/modules/</guid><description>&lt;p>Go 專案的邊界通常從 &lt;code>go.mod&lt;/code> 開始。它定義目前程式碼屬於哪個 module、使用哪個 Go 版本，以及依賴哪些外部套件。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>看懂 &lt;code>go.mod&lt;/code> 的三個核心欄位&lt;/li>
&lt;li>理解 module path 與 import path 的關係&lt;/li>
&lt;li>知道為什麼 Go 指令要在 module 根目錄執行&lt;/li>
&lt;li>分辨標準庫與第三方依賴&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察gomod-定義-module">【觀察】&lt;code>go.mod&lt;/code> 定義 module&lt;/h2>
&lt;p>&lt;code>go.mod&lt;/code> 的核心用途是宣告目前 module 的身份、Go 版本與外部依賴。一個 Go 專案通常會在 module 根目錄放 &lt;code>go.mod&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="nx">module&lt;/span> &lt;span class="nx">example&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">com&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">notify&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="nx">service&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="k">go&lt;/span> &lt;span class="mf">1.25.1&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="nf">require&lt;/span> &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="nx">github&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">com&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">gorilla&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">websocket&lt;/span> &lt;span class="nx">v1&lt;/span>&lt;span class="mf">.5.3&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這份檔案表達三件事：module 名稱、Go 語言版本、外部依賴。&lt;/p>
&lt;h2 id="判讀module-是-go-編譯與依賴解析的單位">【判讀】module 是 Go 編譯與依賴解析的單位&lt;/h2>
&lt;p>module 的核心規則是：Go 工具鏈以 &lt;code>go.mod&lt;/code> 所在目錄作為依賴解析與 package 掃描的根。Go 工具鏈需要知道「目前這批程式碼」的根在哪裡；&lt;code>go.mod&lt;/code> 就是這個根。&lt;/p>
&lt;p>當你在 module 根目錄執行：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">go &lt;span class="nb">test&lt;/span> ./...&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>./...&lt;/code> 的意思是測試目前 module 底下所有 package。實務上要先找到 &lt;code>go.mod&lt;/code> 所在目錄，再從那裡執行 Go 指令。&lt;/p>
&lt;h2 id="策略先分辨三種-import">【策略】先分辨三種 import&lt;/h2>
&lt;p>閱讀 import 的核心規則是：先分辨能力來源，再決定去哪裡查。讀 Go 檔案時，先把 import 分成三類：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>類型&lt;/th>
 &lt;th>例子&lt;/th>
 &lt;th>意義&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>標準庫&lt;/td>
 &lt;td>&lt;code>net/http&lt;/code>, &lt;code>context&lt;/code>, &lt;code>encoding/json&lt;/code>&lt;/td>
 &lt;td>Go 內建能力&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>第三方套件&lt;/td>
 &lt;td>&lt;code>github.com/gorilla/websocket&lt;/code>&lt;/td>
 &lt;td>由 &lt;code>go.mod&lt;/code> 管理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>module 內部套件&lt;/td>
 &lt;td>&lt;code>example.com/notify-service/messages&lt;/code>&lt;/td>
 &lt;td>同一個 module 的其他 package&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這個分類會告訴你：問題應該去查標準庫文件、第三方套件文件，還是目前 module 的其他目錄。&lt;/p>
&lt;h2 id="執行用-module-模型閱讀-maingo">【執行】用 module 模型閱讀 &lt;code>main.go&lt;/code>&lt;/h2>
&lt;p>閱讀入口程式 import 的核心方法是：先把 import 依來源分群，再判斷程式依賴哪些能力。&lt;code>main.go&lt;/code> 的 import 可以整理成這樣：&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="kn">import&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="s">&amp;#34;context&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="s">&amp;#34;fmt&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="s">&amp;#34;log/slog&amp;#34;&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;net/http&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;os&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;time&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="s">&amp;#34;example.com/notify-service/messages&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;/code>&lt;/pre>&lt;/div>&lt;p>前面是標準庫，最後一個是專案內部 package。這表示入口程式主要依賴 Go 標準庫，只有日誌訊息常數被拆到內部 &lt;code>messages&lt;/code> package。&lt;/p></description><content:encoded><![CDATA[<p>Go 專案的邊界通常從 <code>go.mod</code> 開始。它定義目前程式碼屬於哪個 module、使用哪個 Go 版本，以及依賴哪些外部套件。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>看懂 <code>go.mod</code> 的三個核心欄位</li>
<li>理解 module path 與 import path 的關係</li>
<li>知道為什麼 Go 指令要在 module 根目錄執行</li>
<li>分辨標準庫與第三方依賴</li>
</ol>
<hr>
<h2 id="觀察gomod-定義-module">【觀察】<code>go.mod</code> 定義 module</h2>
<p><code>go.mod</code> 的核心用途是宣告目前 module 的身份、Go 版本與外部依賴。一個 Go 專案通常會在 module 根目錄放 <code>go.mod</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="nx">module</span> <span class="nx">example</span><span class="p">.</span><span class="nx">com</span><span class="o">/</span><span class="nx">notify</span><span class="o">-</span><span class="nx">service</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">go</span> <span class="mf">1.25.1</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="nf">require</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">github</span><span class="p">.</span><span class="nx">com</span><span class="o">/</span><span class="nx">gorilla</span><span class="o">/</span><span class="nx">websocket</span> <span class="nx">v1</span><span class="mf">.5.3</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>這份檔案表達三件事：module 名稱、Go 語言版本、外部依賴。</p>
<h2 id="判讀module-是-go-編譯與依賴解析的單位">【判讀】module 是 Go 編譯與依賴解析的單位</h2>
<p>module 的核心規則是：Go 工具鏈以 <code>go.mod</code> 所在目錄作為依賴解析與 package 掃描的根。Go 工具鏈需要知道「目前這批程式碼」的根在哪裡；<code>go.mod</code> 就是這個根。</p>
<p>當你在 module 根目錄執行：</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">go <span class="nb">test</span> ./...</span></span></code></pre></div><p><code>./...</code> 的意思是測試目前 module 底下所有 package。實務上要先找到 <code>go.mod</code> 所在目錄，再從那裡執行 Go 指令。</p>
<h2 id="策略先分辨三種-import">【策略】先分辨三種 import</h2>
<p>閱讀 import 的核心規則是：先分辨能力來源，再決定去哪裡查。讀 Go 檔案時，先把 import 分成三類：</p>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>例子</th>
          <th>意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>標準庫</td>
          <td><code>net/http</code>, <code>context</code>, <code>encoding/json</code></td>
          <td>Go 內建能力</td>
      </tr>
      <tr>
          <td>第三方套件</td>
          <td><code>github.com/gorilla/websocket</code></td>
          <td>由 <code>go.mod</code> 管理</td>
      </tr>
      <tr>
          <td>module 內部套件</td>
          <td><code>example.com/notify-service/messages</code></td>
          <td>同一個 module 的其他 package</td>
      </tr>
  </tbody>
</table>
<p>這個分類會告訴你：問題應該去查標準庫文件、第三方套件文件，還是目前 module 的其他目錄。</p>
<h2 id="執行用-module-模型閱讀-maingo">【執行】用 module 模型閱讀 <code>main.go</code></h2>
<p>閱讀入口程式 import 的核心方法是：先把 import 依來源分群，再判斷程式依賴哪些能力。<code>main.go</code> 的 import 可以整理成這樣：</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="kn">import</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="s">&#34;context&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="s">&#34;fmt&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="s">&#34;log/slog&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="s">&#34;net/http&#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 class="s">&#34;time&#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;example.com/notify-service/messages&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>前面是標準庫，最後一個是專案內部 package。這表示入口程式主要依賴 Go 標準庫，只有日誌訊息常數被拆到內部 <code>messages</code> package。</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 壓測工具選型</title><link>https://tarrragon.github.io/blog/backend/09-performance-capacity/load-test-tooling/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/09-performance-capacity/load-test-tooling/</guid><description>&lt;h2 id="概念定位">概念定位&lt;/h2>
&lt;p>壓測工具選型的核心不是「哪個工具最強」、是「哪個工具最貼合本團隊的 workload model 表達能力跟 CI 整合需求」。沒有絕對最好的工具、只有最匹配當前場景的工具。&lt;/p>
&lt;p>跟 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/workload-modeling/" data-link-title="9.2 Workload Modeling" data-link-desc="把 production traffic shape 翻成可重播的壓測模型">9.2 Workload Modeling&lt;/a> 的關係：9.2 定義 workload 長什麼樣、9.3 找能複製這個樣子的工具。工具選對、壓測結果可信；工具選錯、壓測結果誤導。&lt;/p>
&lt;p>本章不是工具教學、是 &lt;em>選型維度&lt;/em> + 主流工具的 &lt;em>適用情境&lt;/em>。讀者讀完後能回答「我現在這個 workload 該用哪個工具」、而不是「哪個工具最快」。&lt;/p>
&lt;h2 id="六個選型維度">六個選型維度&lt;/h2>
&lt;p>選工具時要按六個維度評估、不能只看「能不能跑 HTTP GET」。&lt;/p>
&lt;p>&lt;strong>腳本表達能力&lt;/strong>：能不能寫複雜 user journey（登入 → 瀏覽 → 加購物車 → 結帳）、不只是單一 HTTP request。複雜系統的壓測通常是 user journey 級別、單一 endpoint 壓測只能找絕對極限、找不到 cross-endpoint contention。&lt;/p>
&lt;p>&lt;strong>協議支援&lt;/strong>：HTTP / WebSocket / gRPC / TCP / 自家二進位協議。WebSocket 跟 gRPC 是現代後端常見、傳統工具（JMeter、wrk）可能要 plugin 補。&lt;/p>
&lt;p>&lt;strong>規模能力&lt;/strong>：單機可以發多少 RPS、能不能分散式擴容。本機 wrk 可發 10K-50K RPS；分散式 Locust 可發 1M+ RPS。決定因素：CPU 效率、async I/O 模型、是否單機 bound。&lt;/p>
&lt;p>&lt;strong>CI 整合&lt;/strong>：能不能在 PR 上跑 lightweight perf check、結果能不能機器可讀（JSON / Prometheus exposition）、能不能跟 baseline diff。沒有 CI 整合的工具只能做「事件型壓測」、無法做 continuous perf governance。&lt;/p>
&lt;p>&lt;strong>結果分析&lt;/strong>：原生 dashboard（k6 Cloud、Gatling Enterprise）/ Prometheus + Grafana 整合 / 純文字輸出。要看結果分發、團隊成員能不能輕鬆查詢歷史。&lt;/p>
&lt;p>&lt;strong>學習曲線&lt;/strong>：腳本語言（JavaScript / Scala / Python / Go）、團隊熟悉度。工具好但團隊不會用、會變成 1-2 個工程師的孤島技能、流失時整套廢掉。&lt;/p>
&lt;h2 id="主流開源工具對照">主流開源工具對照&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>工具&lt;/th>
 &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>k6&lt;/td>
 &lt;td>JS&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>低-中&lt;/td>
 &lt;td>複雜 user journey + CI 整合、現代工具首選&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>JMeter&lt;/td>
 &lt;td>XML/GUI&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>中-高&lt;/td>
 &lt;td>企業已有流程、protocol 廣、reluctant 改&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Gatling&lt;/td>
 &lt;td>Scala&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>報表精美、Scala 學習門檻&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Locust&lt;/td>
 &lt;td>Python&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>複雜邏輯、Python 生態、單機 throughput 受限&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Vegeta&lt;/td>
 &lt;td>CLI&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>CLI driven、quick HTTP 壓測&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>wrk/wrk2&lt;/td>
 &lt;td>C&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>單機極限 RPS、saturation discovery 用&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>k6&lt;/strong> 是過去 5 年崛起的綜合首選。JavaScript 腳本（前端工程師也能寫）、原生 dashboard、Prometheus exposition、CI 友善。Grafana 收購後生態加速。缺點：複雜 stateful 場景（DB connection pool 共享）需要繞 workaround。&lt;/p>
&lt;p>&lt;strong>JMeter&lt;/strong> 是企業常見的 incumbent。協議支援廣（含 LDAP、JDBC、JMS）、有 GUI 編輯器。缺點：腳本是 XML、版本控制困難；GUI 主要用來生成腳本、實際跑壓測還是要 headless。已經在用的團隊建議繼續、新團隊不必特意選它。&lt;/p>
&lt;p>&lt;strong>Gatling&lt;/strong> 高 throughput 純 async、性能優秀、報表精美。缺點：Scala / Kotlin DSL 學習曲線陡、新版本（11+）改了 DSL 不向後相容。&lt;/p></description><content:encoded><![CDATA[<h2 id="概念定位">概念定位</h2>
<p>壓測工具選型的核心不是「哪個工具最強」、是「哪個工具最貼合本團隊的 workload model 表達能力跟 CI 整合需求」。沒有絕對最好的工具、只有最匹配當前場景的工具。</p>
<p>跟 <a href="/blog/backend/09-performance-capacity/workload-modeling/" data-link-title="9.2 Workload Modeling" data-link-desc="把 production traffic shape 翻成可重播的壓測模型">9.2 Workload Modeling</a> 的關係：9.2 定義 workload 長什麼樣、9.3 找能複製這個樣子的工具。工具選對、壓測結果可信；工具選錯、壓測結果誤導。</p>
<p>本章不是工具教學、是 <em>選型維度</em> + 主流工具的 <em>適用情境</em>。讀者讀完後能回答「我現在這個 workload 該用哪個工具」、而不是「哪個工具最快」。</p>
<h2 id="六個選型維度">六個選型維度</h2>
<p>選工具時要按六個維度評估、不能只看「能不能跑 HTTP GET」。</p>
<p><strong>腳本表達能力</strong>：能不能寫複雜 user journey（登入 → 瀏覽 → 加購物車 → 結帳）、不只是單一 HTTP request。複雜系統的壓測通常是 user journey 級別、單一 endpoint 壓測只能找絕對極限、找不到 cross-endpoint contention。</p>
<p><strong>協議支援</strong>：HTTP / WebSocket / gRPC / TCP / 自家二進位協議。WebSocket 跟 gRPC 是現代後端常見、傳統工具（JMeter、wrk）可能要 plugin 補。</p>
<p><strong>規模能力</strong>：單機可以發多少 RPS、能不能分散式擴容。本機 wrk 可發 10K-50K RPS；分散式 Locust 可發 1M+ RPS。決定因素：CPU 效率、async I/O 模型、是否單機 bound。</p>
<p><strong>CI 整合</strong>：能不能在 PR 上跑 lightweight perf check、結果能不能機器可讀（JSON / Prometheus exposition）、能不能跟 baseline diff。沒有 CI 整合的工具只能做「事件型壓測」、無法做 continuous perf governance。</p>
<p><strong>結果分析</strong>：原生 dashboard（k6 Cloud、Gatling Enterprise）/ Prometheus + Grafana 整合 / 純文字輸出。要看結果分發、團隊成員能不能輕鬆查詢歷史。</p>
<p><strong>學習曲線</strong>：腳本語言（JavaScript / Scala / Python / Go）、團隊熟悉度。工具好但團隊不會用、會變成 1-2 個工程師的孤島技能、流失時整套廢掉。</p>
<h2 id="主流開源工具對照">主流開源工具對照</h2>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>腳本</th>
          <th>規模</th>
          <th>學習曲線</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>k6</td>
          <td>JS</td>
          <td>中</td>
          <td>低-中</td>
          <td>複雜 user journey + CI 整合、現代工具首選</td>
      </tr>
      <tr>
          <td>JMeter</td>
          <td>XML/GUI</td>
          <td>中</td>
          <td>中-高</td>
          <td>企業已有流程、protocol 廣、reluctant 改</td>
      </tr>
      <tr>
          <td>Gatling</td>
          <td>Scala</td>
          <td>高</td>
          <td>高</td>
          <td>報表精美、Scala 學習門檻</td>
      </tr>
      <tr>
          <td>Locust</td>
          <td>Python</td>
          <td>高</td>
          <td>中</td>
          <td>複雜邏輯、Python 生態、單機 throughput 受限</td>
      </tr>
      <tr>
          <td>Vegeta</td>
          <td>CLI</td>
          <td>中</td>
          <td>低</td>
          <td>CLI driven、quick HTTP 壓測</td>
      </tr>
      <tr>
          <td>wrk/wrk2</td>
          <td>C</td>
          <td>高</td>
          <td>低</td>
          <td>單機極限 RPS、saturation discovery 用</td>
      </tr>
  </tbody>
</table>
<p><strong>k6</strong> 是過去 5 年崛起的綜合首選。JavaScript 腳本（前端工程師也能寫）、原生 dashboard、Prometheus exposition、CI 友善。Grafana 收購後生態加速。缺點：複雜 stateful 場景（DB connection pool 共享）需要繞 workaround。</p>
<p><strong>JMeter</strong> 是企業常見的 incumbent。協議支援廣（含 LDAP、JDBC、JMS）、有 GUI 編輯器。缺點：腳本是 XML、版本控制困難；GUI 主要用來生成腳本、實際跑壓測還是要 headless。已經在用的團隊建議繼續、新團隊不必特意選它。</p>
<p><strong>Gatling</strong> 高 throughput 純 async、性能優秀、報表精美。缺點：Scala / Kotlin DSL 學習曲線陡、新版本（11+）改了 DSL 不向後相容。</p>
<p><strong>Locust</strong> 是 Python 生態的選擇、特別適合複雜業務邏輯（用 Python 寫 user journey 自然）。分散式部署原生支援。缺點：Python 單線程 throughput 受限、要靠分散式擴容。</p>
<p><strong>Vegeta</strong> 跟 <strong>wrk</strong> 是「quick check」工具、用於單一 endpoint 的極限測試。不適合複雜場景、適合 saturation discovery 第一輪「找這個服務的天花板」。</p>
<h2 id="production-traffic-replay-工具">Production traffic replay 工具</h2>
<p>當需要複製 <em>真實 production traffic</em> 的壓測場景時、需要另一類工具。</p>
<p><strong>GoReplay</strong> 是最常用的開源 traffic replay 工具。在 production server 上 tcpdump-based 捕獲 HTTP traffic、可以 store 到 file 或 stream 到 staging 環境。優點：開源、無 vendor lock-in；缺點：HTTP only、加密流量要拿到 key 才能用。</p>
<p><strong>Service mesh shadow（Istio / Linkerd mirror）</strong>：mesh 層 mirror traffic 到 staging service。優點：mesh 已部署的話 zero infra cost、加密 traffic 也能 mirror。缺點：需要 service mesh 已落地。</p>
<p><strong>AWS VPC Traffic Mirroring</strong>：底層網路層 mirror、application 完全無感。優點：最低 invasion；缺點：AWS only、加密 traffic 要另外處理。</p>
<p><strong>Diffy（Twitter / X 開源、已 deprecated 但概念仍有效）</strong>：dual-write 同時打到舊 / 新版本、比對結果。適合驗證「新版本是否邏輯正確」、不是純壓測。</p>
<p>對應案例：<a href="/blog/backend/09-performance-capacity/cases/tixcraft-ticketing-flash-sale-spike/" data-link-title="9.C15 拓元 Tixcraft：售票搶購的瞬間爆量架構" data-link-desc="拓元用 DynamoDB 當寫入緩衝 &#43; 傳統伺服器當慢速消費者、承受 100K&#43; 同時選位 &#43; 30 秒從 6 台擴到 800 台">Tixcraft 10K t2.micro 壓測</a> — 用分散式 EC2 跑 synthetic load 模擬 100K 同時搶票；<a href="/blog/backend/09-performance-capacity/cases/seatgeek-virtual-waiting-room/" data-link-title="9.C16 SeatGeek：DynamoDB &#43; Lambda 打造的虛擬等候室" data-link-desc="SeatGeek 用 DynamoDB 4 張表 &#43; Lambda Bouncer 實作 flash-sale 限流排隊機制、取代第三方 waiting room 服務">SeatGeek Virtual Waiting Room</a> — token 配發邏輯通常用 dual-write 驗證新舊版本一致。</p>
<h2 id="雲端-managed-壓測服務">雲端 managed 壓測服務</h2>
<p>當不想養 load test infrastructure、想 ad-hoc 跑大規模壓測時、用 managed service。</p>
<p><strong>AWS Distributed Load Testing</strong>：CloudFormation 起 Fargate cluster 跑 JMeter 或 Taurus、報表寫到 S3。優點：一鍵部署、Fargate 計費；缺點：JMeter-based、不是現代 k6 風格。</p>
<p><strong>Grafana k6 Cloud</strong>：託管 k6、跨地理 distributed 壓測（從多個 region 同時發流量）。優點：地理分散原生、跟 Grafana 整合無縫；缺點：vendor cost。</p>
<p><strong>Azure Load Testing</strong>：Azure 原生、整合 Application Insights。優點：Azure 用戶無縫；缺點：相對較新、生態還在補。</p>
<p><strong>GCP 沒有 first-party managed load testing</strong>：要靠 Marketplace 方案或自管 Locust on GKE。</p>
<h2 id="工具選型決策樹">工具選型決策樹</h2>
<p>落地時的快速決策：</p>
<ul>
<li>想快速驗證單一 API 極限 → wrk / Vegeta</li>
<li>想寫複雜 user journey + CI 整合 + JavaScript 團隊 → <strong>k6</strong>（新項目首選）</li>
<li>企業已有 JMeter 流程、不想換 → JMeter（接受 XML / GUI 複雜度）</li>
<li>大規模分散式 + Python 生態 → Locust</li>
<li>報表給管理層看、Scala 團隊 → Gatling</li>
<li>想複製真實 production traffic → GoReplay 或 service mesh shadow</li>
<li>想 ad-hoc 雲端大規模壓測 → 對應雲商的 managed load test</li>
</ul>
<h2 id="常見反模式">常見反模式</h2>
<ul>
<li><strong>只測單一 API、不測 user journey</strong>：找不到 cross-endpoint contention、找不到 session state 累積</li>
<li><strong>壓測機跟被測機在同一網段</strong>：網路延遲被低估、p99 比 production 樂觀</li>
<li><strong>壓測時 throttle 自己的工具</strong>：結果不是被測系統的極限、是工具自己的極限</li>
<li><strong>結果報表只看平均</strong>：<a href="/blog/backend/knowledge-cards/tail-latency/" data-link-title="Tail Latency" data-link-desc="說明 p99 / p999 等長尾延遲為何比平均延遲更能反映 saturation">tail latency</a> 看不到、p99 退化被掩蓋</li>
<li><strong>壓測環境跟 production hardware 不一致</strong>：CPU 型號、network、disk IOPS 差很大、結果不可外推</li>
<li><strong>沒驗證 model</strong>：跑了壓測但沒對比 production metrics、不知道 model 是否貼近 reality</li>
</ul>
<h2 id="案例對照">案例對照</h2>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>教學重點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/09-performance-capacity/cases/tixcraft-ticketing-flash-sale-spike/" data-link-title="9.C15 拓元 Tixcraft：售票搶購的瞬間爆量架構" data-link-desc="拓元用 DynamoDB 當寫入緩衝 &#43; 傳統伺服器當慢速消費者、承受 100K&#43; 同時選位 &#43; 30 秒從 6 台擴到 800 台">9.C15 Tixcraft</a></td>
          <td>10,000 台 t2.micro 跑分散式壓測（$130 / 小時）</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/09-performance-capacity/cases/tubi-elasticache-ml-feature-store/" data-link-title="9.C25 Tubi：從 ScyllaDB 遷到 ElastiCache、ML feature store 達 sub-10ms p99" data-link-desc="Tubi 把 ML 推薦的 feature store 從 ScyllaDB 遷到 ElastiCache for Redis、99 百分位延遲降到 10ms 以下">9.C25 Tubi</a></td>
          <td>ML p99 &lt; 10ms 壓測必須帶 latency distribution</td>
      </tr>
  </tbody>
</table>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>上游：<a href="/blog/backend/09-performance-capacity/workload-modeling/" data-link-title="9.2 Workload Modeling" data-link-desc="把 production traffic shape 翻成可重播的壓測模型">9.2 Workload Modeling</a></li>
<li>下游：<a href="/blog/backend/09-performance-capacity/saturation-discovery/" data-link-title="9.4 Saturation Discovery" data-link-desc="找出 throughput plateau 與 latency knee 的方法">9.4 Saturation Discovery</a>（用工具找 knee）</li>
<li>下游：<a href="/blog/backend/09-performance-capacity/improvement-loop/" data-link-title="9.9 Performance Improvement Loop" data-link-desc="壓測 → profile → fix → re-test → release gate 的閉環">9.9 Improvement Loop</a>（CI 整合）</li>
<li>跨模組：<a href="/blog/backend/06-reliability/ci-pipeline/" data-link-title="6.1 CI pipeline" data-link-desc="CI pipeline 的分層策略、artifact 管理、flaky 治理與 release gate 輸入">06.1 CI Pipeline</a>（壓測在 CI 的位置）</li>
</ul>
<h2 id="既建知識卡片">既建知識卡片</h2>
<ul>
<li><a href="/blog/backend/knowledge-cards/load-test/" data-link-title="Load Test" data-link-desc="說明在預期流量下驗證容量、延遲與降級策略的測試">Load Test</a></li>
<li><a href="/blog/backend/knowledge-cards/workload-model/" data-link-title="Workload Model" data-link-desc="描述 production traffic 形狀的可重播模型 — 容量規劃跟壓測的共同輸入">Workload Model</a></li>
<li><a href="/blog/backend/knowledge-cards/shadow-traffic/" data-link-title="Shadow Traffic" data-link-desc="把 production traffic 複製到新版本驗證、但不返回結果給用戶的測試模式">Shadow Traffic</a></li>
<li><a href="/blog/backend/knowledge-cards/saturation-point/" data-link-title="Saturation Point" data-link-desc="說明系統從線性穩態進入 latency 指數成長區的關鍵流量點">Saturation Point</a></li>
</ul>
]]></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>1.8 Go tooling 與日常開發流程</title><link>https://tarrragon.github.io/blog/go/01-basics/go-tooling-workflow/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/01-basics/go-tooling-workflow/</guid><description>&lt;p>Go tooling 的核心價值是讓日常開發流程標準化。&lt;code>go run&lt;/code>、&lt;code>go test&lt;/code>、&lt;code>go fmt&lt;/code>、&lt;code>go mod tidy&lt;/code>、&lt;code>go build&lt;/code> 是 Go 專案最基本的協作語言。&lt;/p>
&lt;h2 id="預計補充內容">預計補充內容&lt;/h2>
&lt;p>這些工具使用邊界會在下列章節展開：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/main-flow/" data-link-title="1.7 從入口程式看應用啟動流程" data-link-desc="用入口程式建立 Go 程式的啟動與資料流模型">Go 入門：從入口程式看應用啟動流程&lt;/a>：先看 &lt;code>go run&lt;/code> 與 &lt;code>go build&lt;/code> 如何對應入口 package，才能理解 Go 專案真正的執行起點。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go 入門：testing 基礎&lt;/a>：先建立最小回歸檢查的習慣，再談 &lt;code>go test ./...&lt;/code> 在流程中的角色。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend：可靠性驗證流程&lt;/a>：CI 與自動化驗證的責任在這裡展開，不應塞進語言章節。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口&lt;/a>：容器建置、發布門檻與平台合約屬於部署層，不是 toolchain 本身。&lt;/li>
&lt;/ul>
&lt;h2 id="與-backend-教材的分工">與 Backend 教材的分工&lt;/h2>
&lt;p>本章只處理 Go toolchain。&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ci-pipeline/" data-link-title="CI Pipeline" data-link-desc="說明持續整合流程如何在合併前驗證品質與相容性">CI pipeline&lt;/a>、container build、部署前 gate 與 release artifact 會放在 &lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend：可靠性驗證流程&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口&lt;/a>。&lt;/p>
&lt;h2 id="和-go-教材的關係">和 Go 教材的關係&lt;/h2>
&lt;p>這一章承接的是入口流程、測試與設定讀取；如果你要先回看語言教材，可以讀：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/main-flow/" data-link-title="1.7 從入口程式看應用啟動流程" data-link-desc="用入口程式建立 Go 程式的啟動與資料流模型">Go：從入口程式看應用啟動流程&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go：testing 基礎&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/03-stdlib/config-flags-env/" data-link-title="3.9 flag、os/env 與設定邊界" data-link-desc="用標準庫讀取設定，並把外部輸入轉成 config struct">Go：flag、os/env 與設定邊界&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go：composition root 與依賴組裝&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Go tooling 的核心價值是讓日常開發流程標準化。<code>go run</code>、<code>go test</code>、<code>go fmt</code>、<code>go mod tidy</code>、<code>go build</code> 是 Go 專案最基本的協作語言。</p>
<h2 id="預計補充內容">預計補充內容</h2>
<p>這些工具使用邊界會在下列章節展開：</p>
<ul>
<li><a href="/blog/go/01-basics/main-flow/" data-link-title="1.7 從入口程式看應用啟動流程" data-link-desc="用入口程式建立 Go 程式的啟動與資料流模型">Go 入門：從入口程式看應用啟動流程</a>：先看 <code>go run</code> 與 <code>go build</code> 如何對應入口 package，才能理解 Go 專案真正的執行起點。</li>
<li><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go 入門：testing 基礎</a>：先建立最小回歸檢查的習慣，再談 <code>go test ./...</code> 在流程中的角色。</li>
<li><a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend：可靠性驗證流程</a>：CI 與自動化驗證的責任在這裡展開，不應塞進語言章節。</li>
<li><a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口</a>：容器建置、發布門檻與平台合約屬於部署層，不是 toolchain 本身。</li>
</ul>
<h2 id="與-backend-教材的分工">與 Backend 教材的分工</h2>
<p>本章只處理 Go toolchain。<a href="/blog/backend/knowledge-cards/ci-pipeline/" data-link-title="CI Pipeline" data-link-desc="說明持續整合流程如何在合併前驗證品質與相容性">CI pipeline</a>、container build、部署前 gate 與 release artifact 會放在 <a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend：可靠性驗證流程</a> 與 <a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口</a>。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是入口流程、測試與設定讀取；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/01-basics/main-flow/" data-link-title="1.7 從入口程式看應用啟動流程" data-link-desc="用入口程式建立 Go 程式的啟動與資料流模型">Go：從入口程式看應用啟動流程</a></li>
<li><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go：testing 基礎</a></li>
<li><a href="/blog/go/03-stdlib/config-flags-env/" data-link-title="3.9 flag、os/env 與設定邊界" data-link-desc="用標準庫讀取設定，並把外部輸入轉成 config struct">Go：flag、os/env 與設定邊界</a></li>
<li><a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go：composition root 與依賴組裝</a></li>
</ul>
]]></content:encoded></item><item><title>模組九：Go 做工具鏈與靜態分析</title><link>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/</guid><description>&lt;p>前八個模組都把 Go 放在後端服務的脈絡下談。這個模組往另一個方向走 — &lt;strong>Go 寫 CLI、lint / migrate 工具、靜態分析、程式碼生成&lt;/strong>。這些程式沒有 HTTP handler、沒有 goroutine pool、沒有 PostgreSQL connection；但同樣享受 Go 的型別安全、標準庫深度與跨平台編譯。&lt;/p>
&lt;p>業界大量這類 Go 程式：hugo（靜態網站產生器）、kubectl / helm（k8s 客戶端）、terraform（基礎設施描述）、gh（GitHub CLI）、goldmark（markdown parser）、stringer / gopls（官方工具鏈）、golangci-lint（linter 集合）。後端工程師轉過去寫工具時會遇到不同的設計約束：沒有長時執行、資料來自檔案而非 request、錯誤處理偏向中斷而非降級、效能瓶頸是 I/O 而非併發。&lt;/p>
&lt;p>本模組以 &lt;code>scripts/mdtools&lt;/code>（blog 本身用來守住 markdown 品質的內部工具鏈）作為 worked example 串連概念。每一章提煉可複用的 Go 技術；mdtools 只是其中一種 concrete instance，讀者能把同樣 pattern 套到自己的工具上。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>章節&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;th>關鍵收穫&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/09-tooling-and-analysis/overview/" data-link-title="9.0 Go 在工具鏈生態的位置" data-link-desc="後端服務以外，Go 常被用來寫 CLI、靜態分析、基礎設施客戶端。本章建立工具類 Go 程式跟服務類 Go 程式在結構、生命週期與錯誤處理上的分野">9.0&lt;/a>&lt;/td>
 &lt;td>Go 在工具鏈生態的位置&lt;/td>
 &lt;td>從後端服務切換到工具開發的心態調整；CLI vs service 的結構差異&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/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&lt;/a>&lt;/td>
 &lt;td>stdlib &lt;code>flag&lt;/code> 做 subcommand CLI&lt;/td>
 &lt;td>&lt;code>main&lt;/code> + &lt;code>cmd/&lt;/code> + &lt;code>internal/&lt;/code> 佈局；&lt;code>flag.NewFlagSet&lt;/code> 分派；什麼時候該上 cobra&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&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 如何定位到行號">9.2&lt;/a>&lt;/td>
 &lt;td>第三方 parser 整合：goldmark AST 入門&lt;/td>
 &lt;td>&lt;code>ast.Walk&lt;/code> visitor 模式；block vs inline 節點；byte offset 定位&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&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 如何共用邏輯">9.3&lt;/a>&lt;/td>
 &lt;td>AST 驅動的 idempotent 文字改寫&lt;/td>
 &lt;td>多 rule 的執行順序；line-based vs AST-guided 的取捨；&lt;code>--check&lt;/code> / &lt;code>--fix&lt;/code> 雙模式&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&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 為例">9.4&lt;/a>&lt;/td>
 &lt;td>跨檔案圖分析：從 lint 走到 static analysis&lt;/td>
 &lt;td>建 link graph；反向索引；slug 啟發式多層匹配&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/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&lt;/a>&lt;/td>
 &lt;td>工具決策：regex 到 AST、Python 到 Go 的 tripwire&lt;/td>
 &lt;td>用 WRAP 框架做技術決策；哪些訊號代表該升級；延遲決策的代價&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/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&lt;/a>&lt;/td>
 &lt;td>Pre-commit hook 與 CI 整合&lt;/td>
 &lt;td>工具從 CLI 走到開發流程；re-staging；CI strict mode；不能繞過的邊界&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組的教學主軸">本模組的教學主軸&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>stdlib 優先&lt;/strong>：Go 的工具鏈文化偏好最小依賴。cobra / viper / 各種框架都有存在的理由，但 Go 的 &lt;code>flag&lt;/code> + &lt;code>os&lt;/code> + &lt;code>filepath&lt;/code> 已經能撐起 80% 的 CLI 需求。&lt;/li>
&lt;li>&lt;strong>AST 是原始 regex 的升級路徑，不是預設起點&lt;/strong>：line-based 處理便宜、直觀；AST 在需要「段落歸屬」「父子關係」「跨檔連結」時才付出整合成本才有回報。&lt;/li>
&lt;li>&lt;strong>工具要 idempotent&lt;/strong>：&lt;code>fmt --fix&lt;/code> 跑兩次結果要相同；pre-commit 觸發的修改要保持 git state 完整；&lt;code>--check&lt;/code> 跟 &lt;code>--fix&lt;/code> 要共用同一套規則判讀。&lt;/li>
&lt;li>&lt;strong>跨檔案檢查需要圖&lt;/strong>：single-file linter 好寫；跨檔 orphan 偵測、連結完整性、reverse-dependency 這類問題需要先把整個 repo 建成結構化圖，再走訪。&lt;/li>
&lt;li>&lt;strong>工具的價值在落地&lt;/strong>：寫出能跑的 binary 只是起點；接到 pre-commit hook 跟 CI 才讓工具真正守住品質。&lt;/li>
&lt;/ul>
&lt;h2 id="章節粒度說明">章節粒度說明&lt;/h2>
&lt;p>本模組每章都針對 &lt;strong>一個可複用的工具開發技術&lt;/strong>，篇幅會比語法章長一些。每章的結構大致是：&lt;/p></description><content:encoded><![CDATA[<p>前八個模組都把 Go 放在後端服務的脈絡下談。這個模組往另一個方向走 — <strong>Go 寫 CLI、lint / migrate 工具、靜態分析、程式碼生成</strong>。這些程式沒有 HTTP handler、沒有 goroutine pool、沒有 PostgreSQL connection；但同樣享受 Go 的型別安全、標準庫深度與跨平台編譯。</p>
<p>業界大量這類 Go 程式：hugo（靜態網站產生器）、kubectl / helm（k8s 客戶端）、terraform（基礎設施描述）、gh（GitHub CLI）、goldmark（markdown parser）、stringer / gopls（官方工具鏈）、golangci-lint（linter 集合）。後端工程師轉過去寫工具時會遇到不同的設計約束：沒有長時執行、資料來自檔案而非 request、錯誤處理偏向中斷而非降級、效能瓶頸是 I/O 而非併發。</p>
<p>本模組以 <code>scripts/mdtools</code>（blog 本身用來守住 markdown 品質的內部工具鏈）作為 worked example 串連概念。每一章提煉可複用的 Go 技術；mdtools 只是其中一種 concrete instance，讀者能把同樣 pattern 套到自己的工具上。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go/09-tooling-and-analysis/overview/" data-link-title="9.0 Go 在工具鏈生態的位置" data-link-desc="後端服務以外，Go 常被用來寫 CLI、靜態分析、基礎設施客戶端。本章建立工具類 Go 程式跟服務類 Go 程式在結構、生命週期與錯誤處理上的分野">9.0</a></td>
          <td>Go 在工具鏈生態的位置</td>
          <td>從後端服務切換到工具開發的心態調整；CLI vs service 的結構差異</td>
      </tr>
      <tr>
          <td><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</a></td>
          <td>stdlib <code>flag</code> 做 subcommand CLI</td>
          <td><code>main</code> + <code>cmd/</code> + <code>internal/</code> 佈局；<code>flag.NewFlagSet</code> 分派；什麼時候該上 cobra</td>
      </tr>
      <tr>
          <td><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</a></td>
          <td>第三方 parser 整合：goldmark AST 入門</td>
          <td><code>ast.Walk</code> visitor 模式；block vs inline 節點；byte offset 定位</td>
      </tr>
      <tr>
          <td><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</a></td>
          <td>AST 驅動的 idempotent 文字改寫</td>
          <td>多 rule 的執行順序；line-based vs AST-guided 的取捨；<code>--check</code> / <code>--fix</code> 雙模式</td>
      </tr>
      <tr>
          <td><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></td>
          <td>跨檔案圖分析：從 lint 走到 static analysis</td>
          <td>建 link graph；反向索引；slug 啟發式多層匹配</td>
      </tr>
      <tr>
          <td><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</a></td>
          <td>工具決策：regex 到 AST、Python 到 Go 的 tripwire</td>
          <td>用 WRAP 框架做技術決策；哪些訊號代表該升級；延遲決策的代價</td>
      </tr>
      <tr>
          <td><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</a></td>
          <td>Pre-commit hook 與 CI 整合</td>
          <td>工具從 CLI 走到開發流程；re-staging；CI strict mode；不能繞過的邊界</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組的教學主軸">本模組的教學主軸</h2>
<ul>
<li><strong>stdlib 優先</strong>：Go 的工具鏈文化偏好最小依賴。cobra / viper / 各種框架都有存在的理由，但 Go 的 <code>flag</code> + <code>os</code> + <code>filepath</code> 已經能撐起 80% 的 CLI 需求。</li>
<li><strong>AST 是原始 regex 的升級路徑，不是預設起點</strong>：line-based 處理便宜、直觀；AST 在需要「段落歸屬」「父子關係」「跨檔連結」時才付出整合成本才有回報。</li>
<li><strong>工具要 idempotent</strong>：<code>fmt --fix</code> 跑兩次結果要相同；pre-commit 觸發的修改要保持 git state 完整；<code>--check</code> 跟 <code>--fix</code> 要共用同一套規則判讀。</li>
<li><strong>跨檔案檢查需要圖</strong>：single-file linter 好寫；跨檔 orphan 偵測、連結完整性、reverse-dependency 這類問題需要先把整個 repo 建成結構化圖，再走訪。</li>
<li><strong>工具的價值在落地</strong>：寫出能跑的 binary 只是起點；接到 pre-commit hook 跟 CI 才讓工具真正守住品質。</li>
</ul>
<h2 id="章節粒度說明">章節粒度說明</h2>
<p>本模組每章都針對 <strong>一個可複用的工具開發技術</strong>，篇幅會比語法章長一些。每章的結構大致是：</p>
<ol>
<li>問題描述（為何需要這個技術）</li>
<li>概念與 Go 層面的設計取捨</li>
<li>實作與範例（引用 mdtools 對應程式碼）</li>
<li>常見陷阱</li>
<li>擴充路徑</li>
</ol>
<h2 id="先備知識">先備知識</h2>
<p>讀這個模組前建議已經熟悉：</p>
<ul>
<li>模組一到模組三：Go 語法、型別、標準庫基礎</li>
<li>模組五：error 處理與 testing 的基本 pattern</li>
<li>（加分）模組四：concurrency — 雖然 CLI 工具很少需要 goroutine，但 pipeline fan-out 偶爾用得到</li>
</ul>
<p>本模組與後端服務模組（6、7、8）是<strong>並行關係</strong>，讀者可以直接跳入，無需先讀完後端系列。</p>
<h2 id="本模組使用的範例">本模組使用的範例</h2>
<ul>
<li><code>scripts/mdtools/</code> — blog 自己的 markdown 品質工具鏈
<ul>
<li><code>main.go</code> 的 subcommand dispatcher</li>
<li><code>internal/astutil/</code> 的 goldmark wrapper</li>
<li><code>internal/mdfmt/</code> 的格式正規化</li>
<li><code>internal/mdcards/</code> 的 link graph</li>
<li><code>internal/mdmigrate/</code> 的 L1 auto-fix</li>
</ul>
</li>
<li><code>.githooks/pre-commit</code> — 把工具接進 git workflow</li>
<li><code>.github/workflows/md-check.yml</code> — CI 整合</li>
</ul>
<p>完整工具的設計紀錄可參考 <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：Go + goldmark 的 markdown 工具鏈設計</a>；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>。</p>
<h2 id="學習時間">學習時間</h2>
<p>預計 2.5-3.5 小時（含動手把 <code>scripts/mdtools</code> clone 下來編譯、修改、重跑）</p>
]]></content:encoded></item><item><title>3.9 flag、os/env 與設定邊界</title><link>https://tarrragon.github.io/blog/go/03-stdlib/config-flags-env/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/03-stdlib/config-flags-env/</guid><description>&lt;p>設定讀取的核心責任是把外部字串轉成程式內部的 typed config。環境變數、命令列 flag、設定檔與預設值都只是輸入來源；application 應依賴已驗證的 config struct。&lt;/p>
&lt;h2 id="預計補充內容">預計補充內容&lt;/h2>
&lt;p>這些設定邊界會在下列章節展開：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go 進階：composition root 與依賴組裝&lt;/a>：設定讀取的真正用途，是在啟動層把外部輸入轉成可驗證的依賴。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/main-flow/" data-link-title="1.7 從入口程式看應用啟動流程" data-link-desc="用入口程式建立 Go 程式的啟動與資料流模型">Go 入門：從入口程式看應用啟動流程&lt;/a>：先看主程式怎麼啟動，才知道設定應該在哪裡完成驗證。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口&lt;/a>：像 secret manager、ConfigMap 與 rollout 這類平台責任應該留給 Backend。&lt;/li>
&lt;/ul>
&lt;h2 id="與-backend-教材的分工">與 Backend 教材的分工&lt;/h2>
&lt;p>本章只處理 Go 程式內的設定邊界。secret manager、Kubernetes ConfigMap、container environment、遠端動態設定與部署平台 rollout 會放在 &lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口&lt;/a>。&lt;/p>
&lt;h2 id="和-go-教材的關係">和 Go 教材的關係&lt;/h2>
&lt;p>這一章承接的是入口流程與 composition root；如果你要先回看語言教材，可以讀：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/main-flow/" data-link-title="1.7 從入口程式看應用啟動流程" data-link-desc="用入口程式建立 Go 程式的啟動與資料流模型">Go：從入口程式看應用啟動流程&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go：composition root 與依賴組裝&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go：testing 基礎&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/03-stdlib/config-flags-env/" data-link-title="3.9 flag、os/env 與設定邊界" data-link-desc="用標準庫讀取設定，並把外部輸入轉成 config struct">Go：flag、os/env 與設定邊界&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>設定讀取的核心責任是把外部字串轉成程式內部的 typed config。環境變數、命令列 flag、設定檔與預設值都只是輸入來源；application 應依賴已驗證的 config struct。</p>
<h2 id="預計補充內容">預計補充內容</h2>
<p>這些設定邊界會在下列章節展開：</p>
<ul>
<li><a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go 進階：composition root 與依賴組裝</a>：設定讀取的真正用途，是在啟動層把外部輸入轉成可驗證的依賴。</li>
<li><a href="/blog/go/01-basics/main-flow/" data-link-title="1.7 從入口程式看應用啟動流程" data-link-desc="用入口程式建立 Go 程式的啟動與資料流模型">Go 入門：從入口程式看應用啟動流程</a>：先看主程式怎麼啟動，才知道設定應該在哪裡完成驗證。</li>
<li><a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口</a>：像 secret manager、ConfigMap 與 rollout 這類平台責任應該留給 Backend。</li>
</ul>
<h2 id="與-backend-教材的分工">與 Backend 教材的分工</h2>
<p>本章只處理 Go 程式內的設定邊界。secret manager、Kubernetes ConfigMap、container environment、遠端動態設定與部署平台 rollout 會放在 <a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口</a>。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是入口流程與 composition root；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/01-basics/main-flow/" data-link-title="1.7 從入口程式看應用啟動流程" data-link-desc="用入口程式建立 Go 程式的啟動與資料流模型">Go：從入口程式看應用啟動流程</a></li>
<li><a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go：composition root 與依賴組裝</a></li>
<li><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go：testing 基礎</a></li>
<li><a href="/blog/go/03-stdlib/config-flags-env/" data-link-title="3.9 flag、os/env 與設定邊界" data-link-desc="用標準庫讀取設定，並把外部輸入轉成 config struct">Go：flag、os/env 與設定邊界</a></li>
</ul>
]]></content:encoded></item><item><title>macOS 每個 App 到底吃多少空間：聚合佔用的 app-report 腳本</title><link>https://tarrragon.github.io/blog/other/macos-%E6%AF%8F%E5%80%8B-app-%E5%88%B0%E5%BA%95%E5%90%83%E5%A4%9A%E5%B0%91%E7%A9%BA%E9%96%93%E8%81%9A%E5%90%88%E4%BD%94%E7%94%A8%E7%9A%84-app-report-%E8%85%B3%E6%9C%AC/</link><pubDate>Sat, 27 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/other/macos-%E6%AF%8F%E5%80%8B-app-%E5%88%B0%E5%BA%95%E5%90%83%E5%A4%9A%E5%B0%91%E7%A9%BA%E9%96%93%E8%81%9A%E5%90%88%E4%BD%94%E7%94%A8%E7%9A%84-app-report-%E8%85%B3%E6%9C%AC/</guid><description>&lt;p>&lt;code>du ~/Library/*&lt;/code> 只能列出 Caches、Containers 這些目錄各佔多少，答不出「Steam 這個 App 一共吃了多少」。原因是一個 App 的資料散落在 &lt;code>~/Library&lt;/code> 好幾個不同位置，按目錄統計就拆不回它名下。這篇記錄一個把這些散落佔用聚合回各 App 的 &lt;code>app-report&lt;/code> 腳本——搭配磁碟層的 &lt;a href="../macos_disk_space_diagnosis/">disk-report&lt;/a>，後者找出哪棵子樹最大，這篇把子樹拆到 App。&lt;/p>
&lt;h2 id="一個-app-的真實佔用不等於它的-app-大小">一個 App 的真實佔用不等於它的 .app 大小&lt;/h2>
&lt;p>判斷一個 App 吃多少空間，要算的是它的總足跡（footprint），而不是 &lt;code>/Applications&lt;/code> 裡那顆 &lt;code>.app&lt;/code> 的大小。&lt;code>.app&lt;/code> 只是程式本體，App 跑起來產生的資料（下載內容、快取、登入狀態、設定、日誌）絕大多數寫在 &lt;code>~/Library&lt;/code> 底下的好幾個不同位置，跟 &lt;code>.app&lt;/code> 完全分家。&lt;/p>
&lt;p>這台機器上最極端的例子是 Steam：它的 &lt;code>.app&lt;/code> 只有 10.8M，但遊戲資料佔了 8.1G，兩者差了近 800 倍。只看 &lt;code>/Applications&lt;/code> 的大小排序，Steam 會排在很後面，完全看不出它是全機第一大戶。同樣地，Amazon Kindle 的 &lt;code>.app&lt;/code> 才 138M，書庫卻在沙箱容器裡佔了 3.2G。這就是為什麼「按目錄統計」和「按 App 統計」會給出完全不同的排行；要回答「哪個 App 該清」，必須把佔用聚合回 App。&lt;/p>
&lt;h2 id="佔用散落在-library-的哪些地方">佔用散落在 ~/Library 的哪些地方&lt;/h2>
&lt;p>聚合的第一步是知道一個 App 會把資料寫到哪些固定位置。下表只列與空間相關的主要位置（非 &lt;code>~/Library&lt;/code> 全量），macOS 對它們有約定，每個位置承擔不同責任，也決定了它能不能安全清掉。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>位置&lt;/th>
 &lt;th>放什麼&lt;/th>
 &lt;th>清掉的後果&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>/Applications/*.app&lt;/code>&lt;/td>
 &lt;td>程式本體&lt;/td>
 &lt;td>等於移除 App&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>~/Library/Caches/&lt;/code>&lt;/td>
 &lt;td>快取&lt;/td>
 &lt;td>下次自動重建，安全&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>~/Library/HTTPStorages/&lt;/code>&lt;/td>
 &lt;td>網路快取（cookie / 暫存）&lt;/td>
 &lt;td>多半要重新登入，大致安全&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>~/Library/Application Support/&lt;/code>&lt;/td>
 &lt;td>設定與使用者資料&lt;/td>
 &lt;td>掉資料&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>~/Library/Containers/&lt;/code>&lt;/td>
 &lt;td>沙箱 App 的完整家目錄&lt;/td>
 &lt;td>掉資料&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>~/Library/Group Containers/&lt;/code>&lt;/td>
 &lt;td>同廠商 App 共享的資料&lt;/td>
 &lt;td>掉資料、可能影響多個 App&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>~/Library/Saved Application State/&lt;/code>&lt;/td>
 &lt;td>視窗位置與復原狀態&lt;/td>
 &lt;td>下次開窗位置重置，無傷&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>~/Library/Logs/&lt;/code>&lt;/td>
 &lt;td>日誌&lt;/td>
 &lt;td>安全&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表的關鍵分界是「快取」與「資料」。&lt;code>Caches&lt;/code> 和 &lt;code>HTTPStorages&lt;/code> 是純衍生物，清掉只是讓 App 下次重新下載或重建，最多重新登入一次，所以是回收空間時的首選。&lt;code>Application Support&lt;/code>、&lt;code>Containers&lt;/code>、&lt;code>Group Containers&lt;/code> 則是使用者資料，Steam 的遊戲、Kindle 的書庫、聊天記錄都在這裡，刪了就真的沒了。&lt;code>Group Containers&lt;/code> 還要多一層留意：它是同一個開發商旗下多個 App 共享的目錄，動它可能同時影響好幾個 App。&lt;/p>
&lt;p>腳本對每個 App 把上面這些位置全部找出來、用 &lt;code>du&lt;/code> 量實際佔用、加總成一個數字，再附上逐項明細，讓人一眼看出「這 4G 裡有多少是可清的快取、多少是動不得的資料」。&lt;/p>
&lt;h2 id="命名不一致是聚合的主要難點">命名不一致是聚合的主要難點&lt;/h2>
&lt;p>把資料夾正確歸給某個 App 的難點在於：macOS 對這些目錄沒有統一的命名規則。有些 App 用它的 bundle id（例如 &lt;code>com.valvesoftware.steam&lt;/code>）當目錄名，有些直接用 App 的顯示名稱（例如 &lt;code>Steam&lt;/code>），同一個 App 的不同位置甚至各用一種。&lt;/p>
&lt;p>腳本的做法是對每個 App 先讀出它的 bundle id，然後 &lt;code>Caches&lt;/code>、&lt;code>Application Support&lt;/code>、&lt;code>Logs&lt;/code> 這幾個位置兩種命名都比對一次，bundle id 專屬的位置（&lt;code>Containers&lt;/code>、&lt;code>HTTPStorages&lt;/code>、&lt;code>Saved Application State&lt;/code>）則用 bundle id 找。&lt;code>Group Containers&lt;/code> 又是另一種格式，名稱前面多一段開發商的 team id（10 碼英數，像 &lt;code>ABCDE12345.group.com.foo&lt;/code>），因此改用 bundle id 做子字串比對。這套規則涵蓋了絕大多數 App，但用罕見自訂命名的資料仍可能漏抓，這是聚合式估算的固有邊界，腳本在輸出裡據實標明「可能漏抓」而不假裝是精確值。&lt;/p>
&lt;h2 id="homebrew-要分開算">Homebrew 要分開算&lt;/h2>
&lt;p>透過 Homebrew 裝的工具不在 &lt;code>/Applications&lt;/code>，需要獨立統計。佔用分兩類（概念詳見 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/homebrew/" data-link-title="Homebrew" data-link-desc="macOS 上社群維護的套件管理器、用一行指令安裝 CLI 工具與背景服務">Homebrew 知識卡&lt;/a>）：命令列工具與函式庫（&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/homebrew/" data-link-title="Homebrew" data-link-desc="macOS 上社群維護的套件管理器、用一行指令安裝 CLI 工具與背景服務">formula&lt;/a>）在 &lt;code>Cellar/&lt;/code>，GUI App 的下載 artifact 與 metadata（&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/homebrew/" data-link-title="Homebrew" data-link-desc="macOS 上社群維護的套件管理器、用一行指令安裝 CLI 工具與背景服務">cask&lt;/a>）在 &lt;code>Caskroom/&lt;/code>。cask 安裝的 &lt;code>.app&lt;/code> 本體實際放在 &lt;code>/Applications&lt;/code>，已被前面的 App 聚合排行計入；&lt;code>Caskroom/&lt;/code> 存的是安裝來源與版本資訊，體積通常遠小於 App 本體，兩邊不重複計。&lt;/p></description><content:encoded><![CDATA[<p><code>du ~/Library/*</code> 只能列出 Caches、Containers 這些目錄各佔多少，答不出「Steam 這個 App 一共吃了多少」。原因是一個 App 的資料散落在 <code>~/Library</code> 好幾個不同位置，按目錄統計就拆不回它名下。這篇記錄一個把這些散落佔用聚合回各 App 的 <code>app-report</code> 腳本——搭配磁碟層的 <a href="../macos_disk_space_diagnosis/">disk-report</a>，後者找出哪棵子樹最大，這篇把子樹拆到 App。</p>
<h2 id="一個-app-的真實佔用不等於它的-app-大小">一個 App 的真實佔用不等於它的 .app 大小</h2>
<p>判斷一個 App 吃多少空間，要算的是它的總足跡（footprint），而不是 <code>/Applications</code> 裡那顆 <code>.app</code> 的大小。<code>.app</code> 只是程式本體，App 跑起來產生的資料（下載內容、快取、登入狀態、設定、日誌）絕大多數寫在 <code>~/Library</code> 底下的好幾個不同位置，跟 <code>.app</code> 完全分家。</p>
<p>這台機器上最極端的例子是 Steam：它的 <code>.app</code> 只有 10.8M，但遊戲資料佔了 8.1G，兩者差了近 800 倍。只看 <code>/Applications</code> 的大小排序，Steam 會排在很後面，完全看不出它是全機第一大戶。同樣地，Amazon Kindle 的 <code>.app</code> 才 138M，書庫卻在沙箱容器裡佔了 3.2G。這就是為什麼「按目錄統計」和「按 App 統計」會給出完全不同的排行；要回答「哪個 App 該清」，必須把佔用聚合回 App。</p>
<h2 id="佔用散落在-library-的哪些地方">佔用散落在 ~/Library 的哪些地方</h2>
<p>聚合的第一步是知道一個 App 會把資料寫到哪些固定位置。下表只列與空間相關的主要位置（非 <code>~/Library</code> 全量），macOS 對它們有約定，每個位置承擔不同責任，也決定了它能不能安全清掉。</p>
<table>
  <thead>
      <tr>
          <th>位置</th>
          <th>放什麼</th>
          <th>清掉的後果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>/Applications/*.app</code></td>
          <td>程式本體</td>
          <td>等於移除 App</td>
      </tr>
      <tr>
          <td><code>~/Library/Caches/</code></td>
          <td>快取</td>
          <td>下次自動重建，安全</td>
      </tr>
      <tr>
          <td><code>~/Library/HTTPStorages/</code></td>
          <td>網路快取（cookie / 暫存）</td>
          <td>多半要重新登入，大致安全</td>
      </tr>
      <tr>
          <td><code>~/Library/Application Support/</code></td>
          <td>設定與使用者資料</td>
          <td>掉資料</td>
      </tr>
      <tr>
          <td><code>~/Library/Containers/</code></td>
          <td>沙箱 App 的完整家目錄</td>
          <td>掉資料</td>
      </tr>
      <tr>
          <td><code>~/Library/Group Containers/</code></td>
          <td>同廠商 App 共享的資料</td>
          <td>掉資料、可能影響多個 App</td>
      </tr>
      <tr>
          <td><code>~/Library/Saved Application State/</code></td>
          <td>視窗位置與復原狀態</td>
          <td>下次開窗位置重置，無傷</td>
      </tr>
      <tr>
          <td><code>~/Library/Logs/</code></td>
          <td>日誌</td>
          <td>安全</td>
      </tr>
  </tbody>
</table>
<p>這張表的關鍵分界是「快取」與「資料」。<code>Caches</code> 和 <code>HTTPStorages</code> 是純衍生物，清掉只是讓 App 下次重新下載或重建，最多重新登入一次，所以是回收空間時的首選。<code>Application Support</code>、<code>Containers</code>、<code>Group Containers</code> 則是使用者資料，Steam 的遊戲、Kindle 的書庫、聊天記錄都在這裡，刪了就真的沒了。<code>Group Containers</code> 還要多一層留意：它是同一個開發商旗下多個 App 共享的目錄，動它可能同時影響好幾個 App。</p>
<p>腳本對每個 App 把上面這些位置全部找出來、用 <code>du</code> 量實際佔用、加總成一個數字，再附上逐項明細，讓人一眼看出「這 4G 裡有多少是可清的快取、多少是動不得的資料」。</p>
<h2 id="命名不一致是聚合的主要難點">命名不一致是聚合的主要難點</h2>
<p>把資料夾正確歸給某個 App 的難點在於：macOS 對這些目錄沒有統一的命名規則。有些 App 用它的 bundle id（例如 <code>com.valvesoftware.steam</code>）當目錄名，有些直接用 App 的顯示名稱（例如 <code>Steam</code>），同一個 App 的不同位置甚至各用一種。</p>
<p>腳本的做法是對每個 App 先讀出它的 bundle id，然後 <code>Caches</code>、<code>Application Support</code>、<code>Logs</code> 這幾個位置兩種命名都比對一次，bundle id 專屬的位置（<code>Containers</code>、<code>HTTPStorages</code>、<code>Saved Application State</code>）則用 bundle id 找。<code>Group Containers</code> 又是另一種格式，名稱前面多一段開發商的 team id（10 碼英數，像 <code>ABCDE12345.group.com.foo</code>），因此改用 bundle id 做子字串比對。這套規則涵蓋了絕大多數 App，但用罕見自訂命名的資料仍可能漏抓，這是聚合式估算的固有邊界，腳本在輸出裡據實標明「可能漏抓」而不假裝是精確值。</p>
<h2 id="homebrew-要分開算">Homebrew 要分開算</h2>
<p>透過 Homebrew 裝的工具不在 <code>/Applications</code>，需要獨立統計。佔用分兩類（概念詳見 <a href="/blog/llm/knowledge-cards/homebrew/" data-link-title="Homebrew" data-link-desc="macOS 上社群維護的套件管理器、用一行指令安裝 CLI 工具與背景服務">Homebrew 知識卡</a>）：命令列工具與函式庫（<a href="/blog/llm/knowledge-cards/homebrew/" data-link-title="Homebrew" data-link-desc="macOS 上社群維護的套件管理器、用一行指令安裝 CLI 工具與背景服務">formula</a>）在 <code>Cellar/</code>，GUI App 的下載 artifact 與 metadata（<a href="/blog/llm/knowledge-cards/homebrew/" data-link-title="Homebrew" data-link-desc="macOS 上社群維護的套件管理器、用一行指令安裝 CLI 工具與背景服務">cask</a>）在 <code>Caskroom/</code>。cask 安裝的 <code>.app</code> 本體實際放在 <code>/Applications</code>，已被前面的 App 聚合排行計入；<code>Caskroom/</code> 存的是安裝來源與版本資訊，體積通常遠小於 App 本體，兩邊不重複計。</p>
<p>這台機器的 formula 前幾名是開發語言執行環境：<code>dotnet@9</code> 634M、兩個版本的 <code>openjdk</code> 合計 600M、<code>mysql</code> 292M、<code>go</code> 258M。formula 會多版本並存（例如 <code>python@3.13</code> 和 <code>python@3.14</code> 各算各的），所以腳本把整個 formula 目錄一起計。除了已安裝的部分，腳本還列出 <code>brew --cache</code> 的下載快取，以及 <code>brew cleanup -n</code> 預估可回收的舊版本（<code>-n</code> 是 dry-run，只報告不刪），跟整支腳本的唯讀原則一致。</p>
<h2 id="聚合一律用-du-取實際佔用">聚合一律用 du 取實際佔用</h2>
<p>App 各位置的聚合一律用 <code>du -skx</code> 取實際佔用，而不是 <code>ls</code> / <code>find -size</code> 的邏輯大小。sparse 檔（稀疏檔）只有寫入過的區塊才真正佔磁碟，宣告的邏輯大小可能是實際佔用的數十倍；容器與資料目錄裡正好常有 VM 映像、容器磁碟這類 sparse 檔，拿邏輯大小加總會把整份聚合排行灌水。完整的 sparse 踩坑案例見 <a href="../macos_disk_space_diagnosis/">disk-report 那篇</a>。</p>
<p><code>-x</code> 讓 <code>du</code> 不跨越檔案系統邊界，避免把掛載進來的卷重複計入；<code>-k</code> 統一用 KB 當單位，方便把各位置的數字加總後再換算成人類可讀的 G / M。</p>
<h2 id="實測結果">實測結果</h2>
<p>下面是這台機器的實測排行（名次因個人使用習慣而異）；要看的是聚合排行和「按目錄統計」給的印象差多少：</p>
<table>
  <thead>
      <tr>
          <th>App</th>
          <th>總佔用</th>
          <th>主要落點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Steam</td>
          <td>8.1G</td>
          <td>data 8.1G（<code>.app</code> 只有 10.8M）</td>
      </tr>
      <tr>
          <td>Xcode</td>
          <td>4.8G</td>
          <td>bundle 4.8G</td>
      </tr>
      <tr>
          <td>Readmoo 看書</td>
          <td>4.6G</td>
          <td>data 3.8G + bundle 816M</td>
      </tr>
      <tr>
          <td>Dia</td>
          <td>4.1G</td>
          <td>cache 1.6G + bundle 1.3G + data 1.1G</td>
      </tr>
      <tr>
          <td>Amazon Kindle</td>
          <td>3.3G</td>
          <td>container 3.2G（<code>.app</code> 才 138M）</td>
      </tr>
  </tbody>
</table>
<p>全機掃到 65 個 App、聚合總計 48.2G。這份排行的價值在於它直接指向「該從哪裡下手」，而判讀邏輯可以套到任何人的排行上：本體小、資料大的 App（這台是 Steam、Kindle）要回收就得處理書庫與遊戲本身；純快取大的（這台是 Dia 的 1.6G）清掉零風險；本體就大的開發工具（Xcode、Android Studio）除非不再開發否則動不得。同一個總數字底下，可清的比例天差地別，這正是逐項明細要回答的問題。</p>
<h2 id="聚合的邊界總計不等於整機">聚合的邊界：總計不等於整機</h2>
<p>這個 48.2G 是「能歸屬到已安裝 App 的部分」之和，不是 <code>~/Library</code> 的全量。<a href="../macos_disk_space_diagnosis/">disk-report 那篇</a>量到的 <code>~/Library</code> 約 70G，差額落在幾類刻意不歸進單一 App 的位置。</p>
<p>最大的一塊是 <code>~/Library/Developer</code>（這台約 5.5G，幾乎全是 Xcode 的 DerivedData、CoreSimulator 與 iOS DeviceSupport）。它們是 Xcode 與模擬器產生的共用產物，硬塞給 Xcode 會誇大這顆 App、塞給別人又不對，app-report 比照 Homebrew 把它單獨列成一段（<code>app-report --dev</code>）。也因為這樣，上面排行裡的 Xcode 只算到 <code>.app</code> 本體，它的建置產物要看 Developer 那段——這也是為什麼 disk-report 會把「Xcode DeviceSupport」列為大戶，而逐 App 排行卻看不到：那筆資料正住在這個不歸單一 App 的位置。</p>
<p>其餘排除的還有 iCloud 與雲端硬碟的本地鏡像（<code>Mobile Documents</code>、<code>CloudStorage</code>）、已移除 App 留下的孤兒資料夾、以及 <code>Preferences</code>。排行掃的是 <code>/Applications</code>、<code>~/Applications</code>、<code>/Applications/Utilities</code> 與 Setapp、Mac App Store 裝的 App；直接從 DMG 跑、沒搬進 Applications 的 App 不會出現在排行，但它的 <code>~/Library</code> 資料若命名對得上仍可能部分計入。</p>
<p>還有一個方向相反的誤差要記得：這是估算不是精算。同一份資料若以 APFS clone 出現在多個被聚合的位置，逐位置分開跑 <code>du</code> 會各自計入（<code>du</code> 只在單次執行內對硬連結以 inode 去重，對 APFS clone 不去重），聚合值可能偏高。要看整個 <code>~/Library</code> 到底多大、由誰佔，回到 disk-report 的逐層 <code>du</code>。</p>
<h2 id="固化成-app-report-腳本">固化成 app-report 腳本</h2>
<p>把這套聚合邏輯寫成腳本，往後想知道「誰在吃空間」就一行重跑，不必每次重想要比對哪些目錄、要怎麼處理命名差異。腳本和 <code>disk-report</code> 收在同一個公開 repo <a href="https://github.com/tarrragon/scripts">tarrragon/scripts</a> 裡，維持「跟專案無關的系統工具放個人 bin」的一致做法。</p>
<p>兩支腳本在同一個 repo；若已為 <code>disk-report</code> clone 過 <code>~/Projects/scripts</code>，跳過 clone、只做 symlink。首次安裝則把 repo clone 下來，再把腳本本體 symlink 到個人的 <code>~/.local/bin</code>，這樣本機呼叫的永遠是 repo 的最新版：</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 clone https://github.com/tarrragon/scripts.git ~/Projects/scripts
</span></span><span class="line"><span class="ln">2</span><span class="cl">ln -s ~/Projects/scripts/app-report/app-report ~/.local/bin/app-report</span></span></code></pre></div><p>PATH 設定同 disk-report（見 <a href="../macos_new_machine_setup/">macOS 新機基礎建設</a>）。裝好後直接呼叫：</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">app-report           <span class="c1"># 完整報告：App 聚合排行 + Developer + Homebrew</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">app-report --apps    <span class="c1"># 只看 App 聚合排行（預設前 30）</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">app-report --apps <span class="m">50</span> <span class="c1"># 排行顯示前 50</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">app-report --dev     <span class="c1"># 只看 ~/Library/Developer 開發工具共用資料</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">app-report --brew    <span class="c1"># 只看 Homebrew</span></span></span></code></pre></div><p>要清哪個 App，看完明細再動手：移掉 <code>.app</code> 並清對應的 <code>~/Library</code> 資料夾（報告每個 App 下方列的路徑就是清除對象；先從 <code>Caches</code> / <code>HTTPStorages</code> 開始，確認再考慮資料目錄），Homebrew 用 <code>brew cleanup -s</code>。</p>
<h2 id="兩支腳本的分工">兩支腳本的分工</h2>
<p><code>disk-report</code> 與 <code>app-report</code> 是磁碟清理的兩個接力棒。前者在卷與目錄層找出最大的子樹，通常落在 <code>~/Library</code>；後者接手把那棵子樹拆到 App，看出具體是誰佔的、各自有多少是可清的快取。先 disk 找方向、再 app 定位到人，兩支都唯讀，回收的最後一步都留在人這一端。</p>
]]></content:encoded></item><item><title>macOS 新機基礎建設：套件管理與個人 bin 的設定順序</title><link>https://tarrragon.github.io/blog/other/macos-%E6%96%B0%E6%A9%9F%E5%9F%BA%E7%A4%8E%E5%BB%BA%E8%A8%AD%E5%A5%97%E4%BB%B6%E7%AE%A1%E7%90%86%E8%88%87%E5%80%8B%E4%BA%BA-bin-%E7%9A%84%E8%A8%AD%E5%AE%9A%E9%A0%86%E5%BA%8F/</link><pubDate>Sat, 27 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/other/macos-%E6%96%B0%E6%A9%9F%E5%9F%BA%E7%A4%8E%E5%BB%BA%E8%A8%AD%E5%A5%97%E4%BB%B6%E7%AE%A1%E7%90%86%E8%88%87%E5%80%8B%E4%BA%BA-bin-%E7%9A%84%E8%A8%AD%E5%AE%9A%E9%A0%86%E5%BA%8F/</guid><description>&lt;p>重灌或換機後要補的設定很多，但有個底層順序不能跳：套件管理工具要先到位，後面的補強才裝得起來。這篇聚焦最底層的三項基礎建設（Homebrew、bash、個人 bin），按依賴順序排列。重點不只是「裝什麼」，而是「為什麼這個順序」；之後遇到的新需求會接在後面繼續增補。&lt;/p>
&lt;h2 id="先裝-homebrew它是後面一切的基礎">先裝 Homebrew，它是後面一切的基礎&lt;/h2>
&lt;p>macOS 本身沒有內建的第三方套件管理工具，而後面幾乎每一項補強（命令列工具、開發語言、甚至部分 GUI App）都靠它安裝。沒有 Homebrew，這份清單的其他項目全部無從裝起，所以它排第一。&lt;/p>
&lt;p>安裝過程會要求輸入登入密碼（sudo），並自動下載 Xcode Command Line Tools，畫面可能停住數分鐘屬正常。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">/bin/bash -c &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>裝完還要把 Homebrew 的執行檔目錄加進 PATH，shell 才找得到 &lt;code>brew&lt;/code> 與之後用它裝的工具。Homebrew 的安裝前綴依晶片而異：Apple Silicon 機器裝在 &lt;code>/opt/homebrew&lt;/code>、Intel 機器裝在 &lt;code>/usr/local&lt;/code>。安裝腳本結尾會印出對應這台機器的設定指令，照它印的路徑寫進 &lt;code>~/.zprofile&lt;/code> 讓每次開 shell 都生效。以下以 Apple Silicon 為例，Intel 機器把前綴換成 &lt;code>/usr/local&lt;/code> 即可：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nb">echo&lt;/span> &lt;span class="s1">&amp;#39;eval &amp;#34;$(/opt/homebrew/bin/brew shellenv)&amp;#34;&amp;#39;&lt;/span> &amp;gt;&amp;gt; ~/.zprofile
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nb">eval&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>/opt/homebrew/bin/brew shellenv&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這一步做完，驗證安裝成功：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">brew --version &lt;span class="c1"># 應印出 Homebrew 4.x.x&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>版本號印出來，&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/homebrew/" data-link-title="Homebrew" data-link-desc="macOS 上社群維護的套件管理器、用一行指令安裝 CLI 工具與背景服務">Homebrew&lt;/a> 就能當後面所有項目的安裝來源。&lt;/p>
&lt;h2 id="把-bash-更新到-5x">把 bash 更新到 5.x&lt;/h2>
&lt;p>bash 是裝完 Homebrew 後最值得優先換掉的內建工具。macOS 的 &lt;code>/bin/bash&lt;/code> 長年凍結在 3.2 系列（2006 年釋出，目前是 patchlevel 57），Apple 不再更新它，原因是 bash 4 改用 GPLv3 授權、Apple 不願隨系統散布。對寫腳本的人來說，這代表 associative array、&lt;code>${var,,}&lt;/code> 大小寫轉換、&lt;code>mapfile&lt;/code> 等近二十年的語法都用不了。&lt;/p>
&lt;p>正確做法是用 Homebrew 另外裝一份新版並排存在，而不是覆寫系統版。&lt;code>/bin/bash&lt;/code> 在唯讀的系統卷上、受 SIP（System Integrity Protection）保護，覆寫會被擋下：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">brew install bash&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這會把 bash 5.x 裝到 &lt;code>/opt/homebrew/bin/bash&lt;/code>，完全不碰 &lt;code>/bin/bash&lt;/code>。因為前一步已經把 &lt;code>/opt/homebrew/bin&lt;/code> 排在 PATH 前面，用 &lt;code>#!/usr/bin/env bash&lt;/code> 起手的腳本就會自動改用新版。裝完驗證一下版本確實切過去：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">env bash --version &lt;span class="c1"># 應顯示 5.x&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">/bin/bash --version &lt;span class="c1"># 系統版仍是 3.2.57，沒被動&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>要留意的是互動 shell 在現代 macOS 預設是 zsh，這一步不影響它。更新 bash 的目的是給 &lt;code>#!/usr/bin/env bash&lt;/code> 腳本一個現代執行環境，不是換登入 shell。真要把新版 bash 當登入 shell，才需要額外把它加進 &lt;code>/etc/shells&lt;/code> 再 &lt;code>chsh&lt;/code>。&lt;/p>
&lt;h2 id="把-localbin-加進-path放個人腳本">把 ~/.local/bin 加進 PATH，放個人腳本&lt;/h2>
&lt;p>跟專案無關的小工具（例如 &lt;a href="../macos_disk_space_diagnosis/">disk-report&lt;/a> 與 &lt;a href="../macos_app_footprint_report/">app-report&lt;/a> 這類系統診斷腳本）需要一個能在任何地方直接呼叫、又不污染專案 repo 的家。慣例是個人的 &lt;code>~/.local/bin&lt;/code>，把它建好並掛上 PATH。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">mkdir -p ~/.local/bin
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nb">echo&lt;/span> &lt;span class="s1">&amp;#39;export PATH=&amp;#34;$HOME/.local/bin:$PATH&amp;#34;&amp;#39;&lt;/span> &amp;gt;&amp;gt; ~/.zprofile
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">PATH&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$HOME&lt;/span>&lt;span class="s2">/.local/bin:&lt;/span>&lt;span class="nv">$PATH&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>目錄建好、PATH 掛上後，確認它確實生效：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$PATH&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="p">|&lt;/span> tr &lt;span class="s1">&amp;#39;:&amp;#39;&lt;/span> &lt;span class="s1">&amp;#39;\n&amp;#39;&lt;/span> &lt;span class="p">|&lt;/span> grep &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$HOME&lt;/span>&lt;span class="s2">/.local/bin&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>之後把腳本 symlink 進這個目錄就能直接當指令用。&lt;/p>
&lt;h2 id="後續項目">後續項目&lt;/h2>
&lt;p>基礎建設到位後，第一個掛上去的實用腳本就是系統診斷：&lt;a href="../macos_disk_space_diagnosis/">磁碟空間診斷的 disk-report&lt;/a> 與 &lt;a href="../macos_app_footprint_report/">按 App 聚合佔用的 app-report&lt;/a>，兩支都 symlink 進 &lt;code>~/.local/bin&lt;/code> 直接當指令用。&lt;/p>
&lt;p>這份清單會隨著之後遇到的需求往下增補，新項目接在這裡。原則維持不變：基礎建設排前面，依賴它的補強排後面，每一項都寫清楚「為什麼要做」而不只是貼指令。&lt;/p></description><content:encoded><![CDATA[<p>重灌或換機後要補的設定很多，但有個底層順序不能跳：套件管理工具要先到位，後面的補強才裝得起來。這篇聚焦最底層的三項基礎建設（Homebrew、bash、個人 bin），按依賴順序排列。重點不只是「裝什麼」，而是「為什麼這個順序」；之後遇到的新需求會接在後面繼續增補。</p>
<h2 id="先裝-homebrew它是後面一切的基礎">先裝 Homebrew，它是後面一切的基礎</h2>
<p>macOS 本身沒有內建的第三方套件管理工具，而後面幾乎每一項補強（命令列工具、開發語言、甚至部分 GUI App）都靠它安裝。沒有 Homebrew，這份清單的其他項目全部無從裝起，所以它排第一。</p>
<p>安裝過程會要求輸入登入密碼（sudo），並自動下載 Xcode Command Line Tools，畫面可能停住數分鐘屬正常。</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">/bin/bash -c <span class="s2">&#34;</span><span class="k">$(</span>curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh<span class="k">)</span><span class="s2">&#34;</span></span></span></code></pre></div><p>裝完還要把 Homebrew 的執行檔目錄加進 PATH，shell 才找得到 <code>brew</code> 與之後用它裝的工具。Homebrew 的安裝前綴依晶片而異：Apple Silicon 機器裝在 <code>/opt/homebrew</code>、Intel 機器裝在 <code>/usr/local</code>。安裝腳本結尾會印出對應這台機器的設定指令，照它印的路徑寫進 <code>~/.zprofile</code> 讓每次開 shell 都生效。以下以 Apple Silicon 為例，Intel 機器把前綴換成 <code>/usr/local</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"><span class="nb">echo</span> <span class="s1">&#39;eval &#34;$(/opt/homebrew/bin/brew shellenv)&#34;&#39;</span> &gt;&gt; ~/.zprofile
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">eval</span> <span class="s2">&#34;</span><span class="k">$(</span>/opt/homebrew/bin/brew shellenv<span class="k">)</span><span class="s2">&#34;</span></span></span></code></pre></div><p>這一步做完，驗證安裝成功：</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">brew --version   <span class="c1"># 應印出 Homebrew 4.x.x</span></span></span></code></pre></div><p>版本號印出來，<a href="/blog/llm/knowledge-cards/homebrew/" data-link-title="Homebrew" data-link-desc="macOS 上社群維護的套件管理器、用一行指令安裝 CLI 工具與背景服務">Homebrew</a> 就能當後面所有項目的安裝來源。</p>
<h2 id="把-bash-更新到-5x">把 bash 更新到 5.x</h2>
<p>bash 是裝完 Homebrew 後最值得優先換掉的內建工具。macOS 的 <code>/bin/bash</code> 長年凍結在 3.2 系列（2006 年釋出，目前是 patchlevel 57），Apple 不再更新它，原因是 bash 4 改用 GPLv3 授權、Apple 不願隨系統散布。對寫腳本的人來說，這代表 associative array、<code>${var,,}</code> 大小寫轉換、<code>mapfile</code> 等近二十年的語法都用不了。</p>
<p>正確做法是用 Homebrew 另外裝一份新版並排存在，而不是覆寫系統版。<code>/bin/bash</code> 在唯讀的系統卷上、受 SIP（System Integrity Protection）保護，覆寫會被擋下：</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">brew install bash</span></span></code></pre></div><p>這會把 bash 5.x 裝到 <code>/opt/homebrew/bin/bash</code>，完全不碰 <code>/bin/bash</code>。因為前一步已經把 <code>/opt/homebrew/bin</code> 排在 PATH 前面，用 <code>#!/usr/bin/env bash</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">env bash --version   <span class="c1"># 應顯示 5.x</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">/bin/bash --version  <span class="c1"># 系統版仍是 3.2.57，沒被動</span></span></span></code></pre></div><p>要留意的是互動 shell 在現代 macOS 預設是 zsh，這一步不影響它。更新 bash 的目的是給 <code>#!/usr/bin/env bash</code> 腳本一個現代執行環境，不是換登入 shell。真要把新版 bash 當登入 shell，才需要額外把它加進 <code>/etc/shells</code> 再 <code>chsh</code>。</p>
<h2 id="把-localbin-加進-path放個人腳本">把 ~/.local/bin 加進 PATH，放個人腳本</h2>
<p>跟專案無關的小工具（例如 <a href="../macos_disk_space_diagnosis/">disk-report</a> 與 <a href="../macos_app_footprint_report/">app-report</a> 這類系統診斷腳本）需要一個能在任何地方直接呼叫、又不污染專案 repo 的家。慣例是個人的 <code>~/.local/bin</code>，把它建好並掛上 PATH。</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">mkdir -p ~/.local/bin
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;export PATH=&#34;$HOME/.local/bin:$PATH&#34;&#39;</span> &gt;&gt; ~/.zprofile
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nb">export</span> <span class="nv">PATH</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$HOME</span><span class="s2">/.local/bin:</span><span class="nv">$PATH</span><span class="s2">&#34;</span></span></span></code></pre></div><p>目錄建好、PATH 掛上後，確認它確實生效：</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"><span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$PATH</span><span class="s2">&#34;</span> <span class="p">|</span> tr <span class="s1">&#39;:&#39;</span> <span class="s1">&#39;\n&#39;</span> <span class="p">|</span> grep <span class="s2">&#34;</span><span class="nv">$HOME</span><span class="s2">/.local/bin&#34;</span></span></span></code></pre></div><p>之後把腳本 symlink 進這個目錄就能直接當指令用。</p>
<h2 id="後續項目">後續項目</h2>
<p>基礎建設到位後，第一個掛上去的實用腳本就是系統診斷：<a href="../macos_disk_space_diagnosis/">磁碟空間診斷的 disk-report</a> 與 <a href="../macos_app_footprint_report/">按 App 聚合佔用的 app-report</a>，兩支都 symlink 進 <code>~/.local/bin</code> 直接當指令用。</p>
<p>這份清單會隨著之後遇到的需求往下增補，新項目接在這裡。原則維持不變：基礎建設排前面，依賴它的補強排後面，每一項都寫清楚「為什麼要做」而不只是貼指令。</p>
]]></content:encoded></item><item><title>macOS 磁碟空間被吃光的診斷流程</title><link>https://tarrragon.github.io/blog/other/macos-%E7%A3%81%E7%A2%9F%E7%A9%BA%E9%96%93%E8%A2%AB%E5%90%83%E5%85%89%E7%9A%84%E8%A8%BA%E6%96%B7%E6%B5%81%E7%A8%8B/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/other/macos-%E7%A3%81%E7%A2%9F%E7%A9%BA%E9%96%93%E8%A2%AB%E5%90%83%E5%85%89%E7%9A%84%E8%A8%BA%E6%96%B7%E6%B5%81%E7%A8%8B/</guid><description>&lt;p>一台原本還有約 30G 餘裕的 Mac，使用幾小時後空間全部歸零，清過系統各種 cache 也沒有改善。這次排查的重點是順序與判讀依據：用什麼順序找、用哪個數字判斷，最後刪了什麼反而次要。順序對了，就能避開兩個讓人空轉的陷阱。&lt;/p>
&lt;p>最後把整套診斷固化成一個唯讀的 &lt;code>disk-report&lt;/code> 腳本，往後同類情況可以一行指令重跑。&lt;/p>
&lt;h2 id="先確認問題是真的滿還是浮動的假象">先確認問題是「真的滿」還是「浮動的假象」&lt;/h2>
&lt;p>排查磁碟的第一步是分辨空間到底去哪：是被真實檔案佔走，還是被系統的快照與 purgeable（系統可隨時回收的緩衝空間）暫時佔住。這兩者的處理方式完全不同，先分清楚才不會白清。&lt;/p>
&lt;p>在 APFS（Apple File System，macOS 的預設檔案系統）上，根目錄 &lt;code>/&lt;/code> 是唯讀的系統封印卷，真正存放使用者資料的是 &lt;code>/System/Volumes/Data&lt;/code>，而它們和其他卷（Preboot、Recovery、VM、模擬器 runtime）共用同一個 container（容器，APFS 管理空間的最上層單位）的空間池。判斷「還剩多少」要看整個 container 的可用空間，而不是單一卷的數字。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">df -h /System/Volumes/Data
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">diskutil info /System/Volumes/Data &lt;span class="p">|&lt;/span> grep -iE &lt;span class="s2">&amp;#34;Container Free Space|Container Total Space&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這次的結果是資料卷 100% 滿、整個 container 只剩約 591MB。確認確實滿載、不是顯示誤差，後面才值得花力氣找佔用大戶。&lt;/p>
&lt;h2 id="空間掉了又回來的根因本地快照與-purgeable">「空間掉了又回來」的根因：本地快照與 purgeable&lt;/h2>
&lt;p>空間在幾小時內反覆消長、清 cache 卻無效，最常見的原因是 Time Machine 的本地快照（local snapshots）加上 macOS 的 purgeable 空間，而不是某個看得見的檔案。這是排查時要先排除的一條線。&lt;/p>
&lt;p>本地快照的運作方式是：Time Machine 啟用時，系統約每小時自動建立一張快照「凍結」當下狀態，好讓本地也能做時光機回溯。這些被凍結的資料，正是先前以為已刪除、卻怎麼清都不會釋放的空間。快照保留約 24 小時（Apple 的 thinning 策略，觀察值），或在磁碟空間壓力過大時提前清除；後者正是「過一陣子空間又回來」的來源。若從未設定 Time Machine，這條線可跳過——沒啟用就不會有 local snapshot。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">tmutil listlocalsnapshots /System/Volumes/Data&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這次查的時候快照數是 0，但這不代表它不是元兇——恰恰相反，是磁碟已經滿到讓系統把快照全數清光了。判讀訊號是：若這個指令平常列出多筆快照、且磁碟空間在數字上頻繁浮動，浮動量就來自這裡，跟手動清的 cache 無關。根治方向是把總用量降下來、讓磁碟保有餘裕，系統就不會一直貼著上限狂建狂清快照。&lt;/p>
&lt;p>purgeable 是同一條線的另一半，但它沒有好用的精確讀數。&lt;code>diskutil apfs list&lt;/code> 能看 container 層的概況，而 purgeable 主要由快照與系統快取構成、本來就會自己浮動。處理方式跟快照一樣：把總用量降下來、讓系統在空間有壓力時自行釋放，而不是找指令直接清它。「沒有直接讀數」本身就是判讀邊界——看到可用空間和「實際檔案總和」對不上時，差額多半就在這塊浮動緩衝，不必懷疑是哪個檔案在搞鬼。&lt;/p>
&lt;h2 id="用實際佔用值找大戶避開-sparse-假大小">用實際佔用值找大戶，避開 sparse 假大小&lt;/h2>
&lt;p>找佔用大戶要用 &lt;code>du&lt;/code>（實際佔用的磁碟區塊）排序，不能依賴 &lt;code>ls -l&lt;/code> 顯示、或 &lt;code>find -size&lt;/code> 篩選所用的邏輯大小。對一般檔案兩者相同，但對 sparse 檔（稀疏檔）差距可以是好幾十倍，誤判會追錯目標。&lt;/p>
&lt;p>這次就踩到這個陷阱。&lt;code>find&lt;/code> 列出近期修改的大檔時，OrbStack（一套容器與 VM 執行環境）的虛擬磁碟映像顯示為 228G，看起來像頭號兇手；但用 &lt;code>du&lt;/code> 一量，實際佔用只有 1.9G。同樣地，macOS Podcasts 在 tmp 塞的一堆 &lt;code>.tmp.resize.img&lt;/code> 顯示有數十個檔，實際只佔 3.5M。這些都是 sparse 檔：宣告了很大的邏輯大小，但只有寫入過的區塊才真正佔磁碟。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 實際佔用（正確）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">du -sh ~/some/large.img
&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="c1"># 顯示大小（對 sparse 檔會嚴重高估，誤判用）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">ls -lh ~/some/large.img&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>定位順序是由外往內逐層收斂：先看家目錄前 20 大，鎖定最大的子樹（這次是 &lt;code>~/Library&lt;/code> 70G 左右），再往下展開 &lt;code>~/Library/Application Support&lt;/code>、&lt;code>~/Library/Containers&lt;/code>，直到找到具體的檔案或目錄。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">du -shx ~/* ~/.&lt;span class="o">[&lt;/span>!.&lt;span class="o">]&lt;/span>* 2&amp;gt;/dev/null &lt;span class="p">|&lt;/span> sort -rh &lt;span class="p">|&lt;/span> head -20
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">du -shx ~/Library/* 2&amp;gt;/dev/null &lt;span class="p">|&lt;/span> sort -rh &lt;span class="p">|&lt;/span> head -12&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>-x&lt;/code> 讓 &lt;code>du&lt;/code> 不跨越檔案系統邊界，避免把掛載進來的唯讀卷（例如 iOS 模擬器 runtime）重複計入；&lt;code>~/.[!.]*&lt;/code> 這個寫法只展開以單一點開頭的隱藏檔，排除掉 &lt;code>.&lt;/code> 和 &lt;code>..&lt;/code> 兩個會被一般 &lt;code>.*&lt;/code> 誤抓進來、算出整個家目錄大小的假項目。&lt;/p>
&lt;h2 id="這次找到的佔用大戶與處理">這次找到的佔用大戶與處理&lt;/h2>
&lt;p>定位出來的大戶集中在開發工具鏈與閒置的本地資料，多數可逆、刪了之後需要時會自動重建或可重新下載。下面的項目與數字都是這台機器的實測，換一台機器組成會完全不同；值得帶走的是每一項背後的判讀問題，不是這份清單本身。具體刪除指令因工具而異（Android Studio GUI、&lt;code>rm -rf&lt;/code>、&lt;code>ollama rm&lt;/code>），本文只做診斷與定位，刪除操作留給各工具自身的文件。以下逐項說明判讀依據。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>項目&lt;/th>
 &lt;th>實際佔用&lt;/th>
 &lt;th>處理判斷&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>舊版 Android NDK&lt;/td>
 &lt;td>約 3G&lt;/td>
 &lt;td>裝了多版、保留專案實際引用的版本，刪最舊&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>用不到的 AVD + system-image&lt;/td>
 &lt;td>約 3G&lt;/td>
 &lt;td>一個 API 版本一組、停用的版本連 AVD 帶映像一起刪&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Claude 桌面 Cowork 沙箱 VM&lt;/td>
 &lt;td>約 11G&lt;/td>
 &lt;td>只在使用桌面 App 的本地 agent 功能時才佈建，不用則可刪&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>ollama 本地模型&lt;/td>
 &lt;td>約 9G&lt;/td>
 &lt;td>改用雲端後閒置的大模型可刪，小的 embedding 模型常是依賴&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Xcode iOS DeviceSupport&lt;/td>
 &lt;td>約 4.5G&lt;/td>
 &lt;td>實體裝置接線除錯的符號快取，重連會自動重建&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Android NDK 的判讀要回到「誰在用它」：這次專案是 Flutter，NDK 版本由 &lt;code>flutter.ndkVersion&lt;/code> 決定，而不是專案自己 pin。查當前 Flutter 要求的版本後發現，本機裝的兩版都是舊 Flutter 留下的殘留，於是保留較新的一版、刪掉最舊的。判斷可不可刪的關鍵是先確認「現在到底用哪版」，而不是看修改日期就動手。&lt;/p></description><content:encoded><![CDATA[<p>一台原本還有約 30G 餘裕的 Mac，使用幾小時後空間全部歸零，清過系統各種 cache 也沒有改善。這次排查的重點是順序與判讀依據：用什麼順序找、用哪個數字判斷，最後刪了什麼反而次要。順序對了，就能避開兩個讓人空轉的陷阱。</p>
<p>最後把整套診斷固化成一個唯讀的 <code>disk-report</code> 腳本，往後同類情況可以一行指令重跑。</p>
<h2 id="先確認問題是真的滿還是浮動的假象">先確認問題是「真的滿」還是「浮動的假象」</h2>
<p>排查磁碟的第一步是分辨空間到底去哪：是被真實檔案佔走，還是被系統的快照與 purgeable（系統可隨時回收的緩衝空間）暫時佔住。這兩者的處理方式完全不同，先分清楚才不會白清。</p>
<p>在 APFS（Apple File System，macOS 的預設檔案系統）上，根目錄 <code>/</code> 是唯讀的系統封印卷，真正存放使用者資料的是 <code>/System/Volumes/Data</code>，而它們和其他卷（Preboot、Recovery、VM、模擬器 runtime）共用同一個 container（容器，APFS 管理空間的最上層單位）的空間池。判斷「還剩多少」要看整個 container 的可用空間，而不是單一卷的數字。</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">df -h /System/Volumes/Data
</span></span><span class="line"><span class="ln">2</span><span class="cl">diskutil info /System/Volumes/Data <span class="p">|</span> grep -iE <span class="s2">&#34;Container Free Space|Container Total Space&#34;</span></span></span></code></pre></div><p>這次的結果是資料卷 100% 滿、整個 container 只剩約 591MB。確認確實滿載、不是顯示誤差，後面才值得花力氣找佔用大戶。</p>
<h2 id="空間掉了又回來的根因本地快照與-purgeable">「空間掉了又回來」的根因：本地快照與 purgeable</h2>
<p>空間在幾小時內反覆消長、清 cache 卻無效，最常見的原因是 Time Machine 的本地快照（local snapshots）加上 macOS 的 purgeable 空間，而不是某個看得見的檔案。這是排查時要先排除的一條線。</p>
<p>本地快照的運作方式是：Time Machine 啟用時，系統約每小時自動建立一張快照「凍結」當下狀態，好讓本地也能做時光機回溯。這些被凍結的資料，正是先前以為已刪除、卻怎麼清都不會釋放的空間。快照保留約 24 小時（Apple 的 thinning 策略，觀察值），或在磁碟空間壓力過大時提前清除；後者正是「過一陣子空間又回來」的來源。若從未設定 Time Machine，這條線可跳過——沒啟用就不會有 local snapshot。</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">tmutil listlocalsnapshots /System/Volumes/Data</span></span></code></pre></div><p>這次查的時候快照數是 0，但這不代表它不是元兇——恰恰相反，是磁碟已經滿到讓系統把快照全數清光了。判讀訊號是：若這個指令平常列出多筆快照、且磁碟空間在數字上頻繁浮動，浮動量就來自這裡，跟手動清的 cache 無關。根治方向是把總用量降下來、讓磁碟保有餘裕，系統就不會一直貼著上限狂建狂清快照。</p>
<p>purgeable 是同一條線的另一半，但它沒有好用的精確讀數。<code>diskutil apfs list</code> 能看 container 層的概況，而 purgeable 主要由快照與系統快取構成、本來就會自己浮動。處理方式跟快照一樣：把總用量降下來、讓系統在空間有壓力時自行釋放，而不是找指令直接清它。「沒有直接讀數」本身就是判讀邊界——看到可用空間和「實際檔案總和」對不上時，差額多半就在這塊浮動緩衝，不必懷疑是哪個檔案在搞鬼。</p>
<h2 id="用實際佔用值找大戶避開-sparse-假大小">用實際佔用值找大戶，避開 sparse 假大小</h2>
<p>找佔用大戶要用 <code>du</code>（實際佔用的磁碟區塊）排序，不能依賴 <code>ls -l</code> 顯示、或 <code>find -size</code> 篩選所用的邏輯大小。對一般檔案兩者相同，但對 sparse 檔（稀疏檔）差距可以是好幾十倍，誤判會追錯目標。</p>
<p>這次就踩到這個陷阱。<code>find</code> 列出近期修改的大檔時，OrbStack（一套容器與 VM 執行環境）的虛擬磁碟映像顯示為 228G，看起來像頭號兇手；但用 <code>du</code> 一量，實際佔用只有 1.9G。同樣地，macOS Podcasts 在 tmp 塞的一堆 <code>.tmp.resize.img</code> 顯示有數十個檔，實際只佔 3.5M。這些都是 sparse 檔：宣告了很大的邏輯大小，但只有寫入過的區塊才真正佔磁碟。</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"><span class="c1"># 實際佔用（正確）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">du -sh ~/some/large.img
</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="c1"># 顯示大小（對 sparse 檔會嚴重高估，誤判用）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">ls -lh ~/some/large.img</span></span></code></pre></div><p>定位順序是由外往內逐層收斂：先看家目錄前 20 大，鎖定最大的子樹（這次是 <code>~/Library</code> 70G 左右），再往下展開 <code>~/Library/Application Support</code>、<code>~/Library/Containers</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">du -shx ~/* ~/.<span class="o">[</span>!.<span class="o">]</span>* 2&gt;/dev/null <span class="p">|</span> sort -rh <span class="p">|</span> head -20
</span></span><span class="line"><span class="ln">2</span><span class="cl">du -shx ~/Library/* 2&gt;/dev/null <span class="p">|</span> sort -rh <span class="p">|</span> head -12</span></span></code></pre></div><p><code>-x</code> 讓 <code>du</code> 不跨越檔案系統邊界，避免把掛載進來的唯讀卷（例如 iOS 模擬器 runtime）重複計入；<code>~/.[!.]*</code> 這個寫法只展開以單一點開頭的隱藏檔，排除掉 <code>.</code> 和 <code>..</code> 兩個會被一般 <code>.*</code> 誤抓進來、算出整個家目錄大小的假項目。</p>
<h2 id="這次找到的佔用大戶與處理">這次找到的佔用大戶與處理</h2>
<p>定位出來的大戶集中在開發工具鏈與閒置的本地資料，多數可逆、刪了之後需要時會自動重建或可重新下載。下面的項目與數字都是這台機器的實測，換一台機器組成會完全不同；值得帶走的是每一項背後的判讀問題，不是這份清單本身。具體刪除指令因工具而異（Android Studio GUI、<code>rm -rf</code>、<code>ollama rm</code>），本文只做診斷與定位，刪除操作留給各工具自身的文件。以下逐項說明判讀依據。</p>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>實際佔用</th>
          <th>處理判斷</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>舊版 Android NDK</td>
          <td>約 3G</td>
          <td>裝了多版、保留專案實際引用的版本，刪最舊</td>
      </tr>
      <tr>
          <td>用不到的 AVD + system-image</td>
          <td>約 3G</td>
          <td>一個 API 版本一組、停用的版本連 AVD 帶映像一起刪</td>
      </tr>
      <tr>
          <td>Claude 桌面 Cowork 沙箱 VM</td>
          <td>約 11G</td>
          <td>只在使用桌面 App 的本地 agent 功能時才佈建，不用則可刪</td>
      </tr>
      <tr>
          <td>ollama 本地模型</td>
          <td>約 9G</td>
          <td>改用雲端後閒置的大模型可刪，小的 embedding 模型常是依賴</td>
      </tr>
      <tr>
          <td>Xcode iOS DeviceSupport</td>
          <td>約 4.5G</td>
          <td>實體裝置接線除錯的符號快取，重連會自動重建</td>
      </tr>
  </tbody>
</table>
<p>Android NDK 的判讀要回到「誰在用它」：這次專案是 Flutter，NDK 版本由 <code>flutter.ndkVersion</code> 決定，而不是專案自己 pin。查當前 Flutter 要求的版本後發現，本機裝的兩版都是舊 Flutter 留下的殘留，於是保留較新的一版、刪掉最舊的。判斷可不可刪的關鍵是先確認「現在到底用哪版」，而不是看修改日期就動手。</p>
<p>Claude 桌面的 <code>vm_bundles</code> 是最大單一項目（11G）。它是桌面 App 的 Cowork 功能在本地沙箱 VM 裡執行程式用的根檔案系統映像。關鍵判讀是：它不是每次開 App 就重建——映像的修改日期停在數月前，是一次性佈建、之後沿用。只有實際使用 Cowork 沙箱時才會佈建和更新。所以對只用終端機 CLI、桌面 App 僅拿來聊天的人，這 11G 是純佔用，可以安全刪除；唯一後果是哪天實際開了 Cowork session，它會重新佈建。</p>
<p>剩下三項的判讀各有自己的關鍵問題。閒置的 AVD 與 system-image 是「一個 API 版本一組」的綁定，停用某個 Android 版本時要連 AVD 帶它依賴的系統映像一起刪，只刪一邊會留下半套。ollama 本地模型的判斷是「改用雲端後還會不會在本地跑」，閒置的大模型可刪，但小的 embedding 模型常被其他工具當依賴、刪了會牽連（ollama 模型的累積速度與專屬清理 idiom，見 <a href="/blog/llm/01-local-llm-services/hands-on/resource-management/" data-link-title="Hands-on：LLM 運行中 &#43; 結束的資源管理" data-link-desc="RAM / 磁碟 / port 三個 dimension 的觀察跟釋放、Ollama keep_alive 跟 ComfyUI 兩種 lifecycle 對比、實測釋放數字">本地 LLM 的資源管理</a>）。Xcode 的 iOS DeviceSupport 則是實體裝置接線除錯時產生的符號快取，可以放心刪——下次接上同一台裝置除錯時 Xcode 會自動重建。</p>
<p>這幾項合計回收約 17G，可用空間從約 591MB 拉回到 18G，磁碟脫離滿載。</p>
<h2 id="把診斷固化成-disk-report-腳本">把診斷固化成 disk-report 腳本</h2>
<p>一次性查完之後，把這套順序寫成腳本的價值是：下次同類情況不必重新回想指令與判讀順序，一行就能重跑，而且固定先看快照、再用實際佔用值，不會又掉進 sparse 假大小的陷阱。</p>
<p>腳本收在公開 repo <a href="https://github.com/tarrragon/scripts">tarrragon/scripts</a>，而不是放進某個專案的 <code>bin/</code>。它跟任何專案無關，連到個人 bin 才能在任何地方直接呼叫，也不會污染專案 repo。安裝方式是 clone 下來、把腳本本體 symlink 到 <code>~/.local/bin</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 clone https://github.com/tarrragon/scripts.git ~/Projects/scripts
</span></span><span class="line"><span class="ln">2</span><span class="cl">ln -s ~/Projects/scripts/disk-report/disk-report ~/.local/bin/disk-report</span></span></code></pre></div><p>這一步預設 <code>~/.local/bin</code> 已在 PATH 上。若還沒設定，做法見 <a href="../macos_new_machine_setup/">macOS 新機基礎建設</a> 的對應項目。腳本刻意設計成唯讀：只報告、不刪除，刪什麼由人看完報告再決定。</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">disk-report              <span class="c1"># 完整診斷：總覽 + 快照狀態 + 各層大戶 + 開發環境可清項</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">disk-report --growing    <span class="c1"># 只看過去 180 分鐘內長大的大檔（抓動態暴增最快）</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">disk-report --growing <span class="m">60</span> <span class="c1"># 改成過去 60 分鐘</span></span></span></code></pre></div><p><code>--growing</code> 模式對應的是本文開頭那個「幾小時內暴增」的情境：當空間正在快速消失、想抓現行犯時，直接列出近期被寫入的大檔，比逐層 <code>du</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">find <span class="s2">&#34;</span><span class="nv">$HOME</span><span class="s2">&#34;</span> -type f -size +50M -mmin -180 2&gt;/dev/null <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  -exec du -h <span class="o">{}</span> <span class="se">\;</span> 2&gt;/dev/null <span class="p">|</span> sort -rh <span class="p">|</span> head -25</span></span></code></pre></div><p>50M 的下限是為了過濾日常小檔雜訊、鎖定單一大檔暴增；若懷疑是大量小檔累積吃空間（如快取碎片），這個門檻抓不到，要回逐層 <code>du</code> 看目錄總量。排序依據同樣是 <code>du</code> 的實際佔用值，而不是 <code>find -size</code> 的邏輯大小門檻，理由和前面一致：避免 sparse 檔的邏輯大小把排序帶歪。</p>
<h2 id="排查順序總結">排查順序總結</h2>
<p>這次的方法可以收斂成一條固定順序，往後遇到任何「磁碟莫名變滿」都先照這條走：</p>
<ol>
<li>先看 container 可用空間，確認是真滿還是顯示誤差。</li>
<li>再查本地快照與 purgeable，排除「掉了又回來」的浮動來源。</li>
<li>用 <code>du -shx</code> 由外往內逐層找大戶，全程以實際佔用值判斷，不信 <code>ls</code> / <code>find</code> 的顯示大小。</li>
<li>對每個大戶問「現在誰在用它」再決定刪不刪，可逆的優先清。</li>
<li>把整套順序固化成唯讀腳本，下次一行重跑。</li>
</ol>
<p>第 3 步若收斂到 <code>~/Library</code> 這種多個 App 共用的大目錄，按目錄統計只能看出 Caches、Containers 各多大，看不出是哪幾個 App 佔的。把這棵子樹再按 App 拆開的做法，見 <a href="../macos_app_footprint_report/">macOS App 聚合佔用報告</a>。</p>
]]></content:encoded></item><item><title>flutter devices 卡住的訊號：device 數從 N 變 N-1 與 emulator 半活</title><link>https://tarrragon.github.io/blog/work-log/flutter-devices-%E5%8D%A1%E4%BD%8F%E7%9A%84%E8%A8%8A%E8%99%9Fdevice-%E6%95%B8%E5%BE%9E-n-%E8%AE%8A-n-1-%E8%88%87-emulator-%E5%8D%8A%E6%B4%BB/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter-devices-%E5%8D%A1%E4%BD%8F%E7%9A%84%E8%A8%8A%E8%99%9Fdevice-%E6%95%B8%E5%BE%9E-n-%E8%AE%8A-n-1-%E8%88%87-emulator-%E5%8D%8A%E6%B4%BB/</guid><description>&lt;p>&lt;code>flutter devices&lt;/code> 卡住時，最有用的訊號是「device 清單是否穩定」。這次的關鍵訊號是連續兩次掃描從 &lt;code>Found 4 connected devices&lt;/code> 變成 &lt;code>Found 3 connected devices&lt;/code>，再加上 &lt;code>Error -2 retrieving device properties for sdk gphone64 arm64&lt;/code>。這代表 ADB server 看得到某個 emulator entry，但對該 entry 的 property 查詢已經不穩定。&lt;/p>
&lt;p>這類狀態可以稱為 Android emulator 半活（zombie）：emulator host process 還在、ADB 清單仍殘留 device，但 emulator 內的 &lt;code>adbd&lt;/code> 或 Android system 已停止回應。Flutter 在掃描階段會對每個 Android device 查 properties，掃描到這個半活 device 就卡在 timeout。&lt;/p>
&lt;hr>
&lt;h2 id="事故場景">事故場景&lt;/h2>
&lt;p>事故場景的核心是「Flutter 指令看似卡住，其實卡在下游 device property 查詢」。連續跑 &lt;code>flutter devices&lt;/code> 時，輸出長這樣：&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">$ flutter devices
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">Found 4 connected devices:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">Error -2 retrieving device properties for sdk gphone64 arm64:
&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>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">$ flutter devices
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">Found 3 connected devices:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">[繼續卡]&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段輸出有兩個值得注意的點：&lt;/p>
&lt;ol>
&lt;li>&lt;code>Error -2 retrieving device properties for sdk gphone64 arm64:&lt;/code> 訊息出現後仍繼續等待，代表 Flutter 沒有在第一個 device 失敗時 fail-fast&lt;/li>
&lt;li>第一次 &lt;code>Found 4&lt;/code>、第二次 &lt;code>Found 3&lt;/code>，代表 device 數在兩次掃描之間自己少了 1&lt;/li>
&lt;/ol>
&lt;p>&lt;code>sdk gphone64 arm64&lt;/code> 是 Android Studio AVD 預設模板（Google Phone 64-bit ARM）建出來的 emulator 顯示名稱、macOS 上跑 Android system image 都會看到這個。&lt;/p>
&lt;h3 id="為什麼計數變化是關鍵徵兆">為什麼計數變化是關鍵徵兆&lt;/h3>
&lt;p>device 數從 4 變 3，代表 ADB 對某個 emulator 的狀態判斷在兩次查詢之間變了。ADB server 內部追蹤每個 device 的狀態（&lt;code>device&lt;/code> / &lt;code>offline&lt;/code> / &lt;code>unauthorized&lt;/code> / &lt;code>no permissions&lt;/code>）；半活 emulator 在第一次掃描時仍被列在 &lt;code>Found 4&lt;/code>，第二次掃描時可能已被標成 offline 或從候選清單移除，所以掉到 &lt;code>Found 3&lt;/code>。&lt;/p>
&lt;p>判讀訊號是「同一條 list 指令連跑兩次，device 數或 device 狀態自己變」。正常穩定狀態下，清單應該保持一致；清單漂移代表 ADB server 對某個 entry 的看法不穩定，下一步要先找出那個 entry，再決定是否重啟 ADB 或 emulator。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼-flutter-devices-會卡住">為什麼 flutter devices 會卡住&lt;/h2>
&lt;p>&lt;code>flutter devices&lt;/code> 的責任是把每個候選 device 補成 Flutter 可用的 target，而不只是印出 &lt;code>adb devices&lt;/code> 的結果。Flutter 對每個 ADB 看得到的 Android device 還要做幾件事：&lt;/p>
&lt;ol>
&lt;li>跑 &lt;code>adb shell getprop ro.product.cpu.abi&lt;/code> 拉 ABI&lt;/li>
&lt;li>跑 &lt;code>adb shell getprop ro.build.version.sdk&lt;/code> 拉 SDK level&lt;/li>
&lt;li>跑 &lt;code>adb shell getprop ro.product.model&lt;/code> 拉裝置型號&lt;/li>
&lt;li>視情況跑 &lt;code>adb shell&lt;/code> 其他指令確認 Flutter 支援度&lt;/li>
&lt;/ol>
&lt;p>這些是同步、序列化、有 timeout 的呼叫；timeout 通常設得相對寬鬆，讓慢一點的真機也能跑通。當其中一個 device 是 zombie 狀態：&lt;/p></description><content:encoded><![CDATA[<p><code>flutter devices</code> 卡住時，最有用的訊號是「device 清單是否穩定」。這次的關鍵訊號是連續兩次掃描從 <code>Found 4 connected devices</code> 變成 <code>Found 3 connected devices</code>，再加上 <code>Error -2 retrieving device properties for sdk gphone64 arm64</code>。這代表 ADB server 看得到某個 emulator entry，但對該 entry 的 property 查詢已經不穩定。</p>
<p>這類狀態可以稱為 Android emulator 半活（zombie）：emulator host process 還在、ADB 清單仍殘留 device，但 emulator 內的 <code>adbd</code> 或 Android system 已停止回應。Flutter 在掃描階段會對每個 Android device 查 properties，掃描到這個半活 device 就卡在 timeout。</p>
<hr>
<h2 id="事故場景">事故場景</h2>
<p>事故場景的核心是「Flutter 指令看似卡住，其實卡在下游 device property 查詢」。連續跑 <code>flutter devices</code> 時，輸出長這樣：</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">$ flutter devices
</span></span><span class="line"><span class="ln">2</span><span class="cl">Found 4 connected devices:
</span></span><span class="line"><span class="ln">3</span><span class="cl">Error -2 retrieving device properties for sdk gphone64 arm64:
</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></span><span class="line"><span class="ln">6</span><span class="cl">$ flutter devices
</span></span><span class="line"><span class="ln">7</span><span class="cl">Found 3 connected devices:
</span></span><span class="line"><span class="ln">8</span><span class="cl">[繼續卡]</span></span></code></pre></div><p>這段輸出有兩個值得注意的點：</p>
<ol>
<li><code>Error -2 retrieving device properties for sdk gphone64 arm64:</code> 訊息出現後仍繼續等待，代表 Flutter 沒有在第一個 device 失敗時 fail-fast</li>
<li>第一次 <code>Found 4</code>、第二次 <code>Found 3</code>，代表 device 數在兩次掃描之間自己少了 1</li>
</ol>
<p><code>sdk gphone64 arm64</code> 是 Android Studio AVD 預設模板（Google Phone 64-bit ARM）建出來的 emulator 顯示名稱、macOS 上跑 Android system image 都會看到這個。</p>
<h3 id="為什麼計數變化是關鍵徵兆">為什麼計數變化是關鍵徵兆</h3>
<p>device 數從 4 變 3，代表 ADB 對某個 emulator 的狀態判斷在兩次查詢之間變了。ADB server 內部追蹤每個 device 的狀態（<code>device</code> / <code>offline</code> / <code>unauthorized</code> / <code>no permissions</code>）；半活 emulator 在第一次掃描時仍被列在 <code>Found 4</code>，第二次掃描時可能已被標成 offline 或從候選清單移除，所以掉到 <code>Found 3</code>。</p>
<p>判讀訊號是「同一條 list 指令連跑兩次，device 數或 device 狀態自己變」。正常穩定狀態下，清單應該保持一致；清單漂移代表 ADB server 對某個 entry 的看法不穩定，下一步要先找出那個 entry，再決定是否重啟 ADB 或 emulator。</p>
<hr>
<h2 id="為什麼-flutter-devices-會卡住">為什麼 flutter devices 會卡住</h2>
<p><code>flutter devices</code> 的責任是把每個候選 device 補成 Flutter 可用的 target，而不只是印出 <code>adb devices</code> 的結果。Flutter 對每個 ADB 看得到的 Android device 還要做幾件事：</p>
<ol>
<li>跑 <code>adb shell getprop ro.product.cpu.abi</code> 拉 ABI</li>
<li>跑 <code>adb shell getprop ro.build.version.sdk</code> 拉 SDK level</li>
<li>跑 <code>adb shell getprop ro.product.model</code> 拉裝置型號</li>
<li>視情況跑 <code>adb shell</code> 其他指令確認 Flutter 支援度</li>
</ol>
<p>這些是同步、序列化、有 timeout 的呼叫；timeout 通常設得相對寬鬆，讓慢一點的真機也能跑通。當其中一個 device 是 zombie 狀態：</p>
<ul>
<li><code>adb shell getprop ...</code> 送出後，ADB 把指令轉發給 emulator 內的 <code>adbd</code></li>
<li><code>adbd</code> 收到了但 Android system 沒回應，或 emulator process 整個卡住沒在處理 ADB request</li>
<li>Flutter 端等 timeout、再 retry、再等更長 timeout，看起來就是「整個指令卡住」</li>
</ul>
<p><code>Error -2 retrieving device properties</code> 是其中一次嘗試 timeout 拿到的訊息（<code>-2</code> 是 Dart <code>ProcessException</code> 對應 <code>adb</code> exit code 的內部映射）。Flutter 仍會繼續掃描其他 device，所以使用者看到的是「印出錯誤訊息 + 繼續卡」。</p>
<hr>
<h2 id="為什麼是半活狀態">為什麼是半活狀態</h2>
<p>Android emulator 在 macOS 上的結構大致是：</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">qemu-system-aarch64 (host process)
</span></span><span class="line"><span class="ln">2</span><span class="cl">  ├─ Android kernel
</span></span><span class="line"><span class="ln">3</span><span class="cl">  ├─ Android system services
</span></span><span class="line"><span class="ln">4</span><span class="cl">  └─ adbd (在 emulator 內部，跟 host ADB server 對接)</span></span></code></pre></div><p>半活狀態指的是「host process 還在，但 device 內部服務已無法完成 ADB request」。完全正常時 emulator 跑得動、ADB 也通；完全退出時 emulator process 已結束、ADB 清單看不到它。半活介於兩者之間：</p>
<ul>
<li>qemu host process 還在（活著）</li>
<li>emulator 內的某個環節卡住（Android system 沒在 schedule、或 adbd 卡在某個 mutex）</li>
<li>ADB server 還記得有這個 device，尚未穩定 evict</li>
<li>任何 <code>adb shell</code> 指令都打不通</li>
</ul>
<p>常見成因：</p>
<ul>
<li><strong>Quick Boot snapshot 還原失敗或部分還原</strong>——AVD 預設關機是 quick boot（存 snapshot），下次開機從 snapshot 還原；snapshot 跟當前 host kernel / hypervisor 狀態不相容時會半開機</li>
<li><strong>macOS 從 sleep 喚醒後 hypervisor framework 重置</strong>——emulator 是用 Hypervisor.framework，喚醒後虛擬 CPU 可能停在奇怪 state</li>
<li><strong>host 端記憶體壓力導致 emulator 被 swap 嚴重</strong>——表面看起來像卡，其實是在等 page fault</li>
</ul>
<p>這一層的操作目標是恢復工具鏈，而不是追到每個 emulator 內部 race condition。若症狀符合清單漂移與 property 查詢 timeout，先按恢復順序處理；只有反覆發生時，再追 AVD snapshot、system image 或 host 資源壓力。</p>
<hr>
<h2 id="恢復順序從輕到重">恢復順序（從輕到重）</h2>
<p>恢復順序的核心是先重置最小邊界，再逐層擴大。每一步都要重新跑一次 <code>flutter devices</code> 或 <code>adb devices</code>，確認是否已經恢復，避免直接砍掉 emulator 或清資料。</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"><span class="c1"># 1. 看 ADB 對每個 device 的狀態</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">adb devices
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 看到 offline / no device / unauthorized 等異常狀態 → 先鎖定該 device</span></span></span></code></pre></div><p>如果有 device 顯示 <code>offline</code>，或正常列出但實際打不通，先重啟 ADB server：</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"><span class="c1"># 2. 重啟 ADB server（只重置 host 端 ADB session）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">adb kill-server <span class="o">&amp;&amp;</span> adb start-server
</span></span><span class="line"><span class="ln">3</span><span class="cl">adb devices
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 多數狀況下，ADB 重啟後對該 device 的查詢會 fail-fast，flutter devices 會恢復</span></span></span></code></pre></div><p>如果 ADB 重啟後仍打不通該 emulator，再處理 emulator process：</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"><span class="c1"># 3. 對特定 emulator 發 emu kill（讓它優雅關閉）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">adb -s emulator-5554 emu <span class="nb">kill</span>   <span class="c1"># 把 5554 換成實際 port</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="c1"># 4. 還在的話，終止 qemu process</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">pkill -f qemu-system-aarch64</span></span></code></pre></div><p>長期修復路由是清掉不穩定的 snapshot。開 Android Studio → <strong>AVD Manager</strong> → 該 emulator 旁邊的小箭頭 → <strong>Cold Boot Now</strong>（避免 Quick Boot）。如果冷啟動後仍反覆壞，選 <strong>Wipe Data</strong> 把 snapshot 與 emulator 內資料整個清掉。</p>
<hr>
<h2 id="通用診斷思維">通用診斷思維</h2>
<p>工具鏈卡住的診斷核心是先區分「上游 CLI 壞掉」還是「下游 target 沒回應」。<code>flutter</code> / <code>adb</code> 指令卡住時，先用清單穩定性與 device 識別碼定位下游狀態，再決定重啟邊界。</p>
<ol>
<li><strong>觀察「同一指令連跑兩次結果是否一致」</strong>：不一致（device 數變、訊息變）等於某層狀態不穩定</li>
<li><strong>訊息裡有 device 識別碼就釘住它</strong>：<code>sdk gphone64 arm64</code>、<code>emulator-5554</code>、序號等都是 ADB 層的識別，可直接拿來 <code>adb -s &lt;id&gt; ...</code> 局部診斷</li>
<li><strong>從外往內排除</strong>：ADB server → 個別 device → emulator process → emulator 內 system，逐層重啟</li>
<li><strong>重啟邊界越大、副作用越大</strong>：<code>adb kill-server</code> 只影響 ADB session（其他 device 連線會斷一下），<code>pkill qemu</code> 直接砍 emulator，<code>Wipe Data</code> 連 emulator 內的資料都清。能用輕量手段解決就停在那層</li>
</ol>
<hr>
<h2 id="操作判準">操作判準</h2>
<ol>
<li><strong>「device 數兩次掃描之間自己變」是 zombie emulator 的關鍵徵兆</strong>：計數變化代表 ADB 內部狀態不穩定</li>
<li><strong><code>Error -2 retrieving device properties</code> 是 property 查詢失敗訊號</strong>：Flutter 仍可能繼續處理其他 device，結果是「印出錯誤訊息但繼續卡」</li>
<li><strong><code>adb kill-server &amp;&amp; adb start-server</code> 是輕量首選</strong>：它只重置 ADB session，不動 emulator 本身，多數狀況下可讓壞 device fail-fast</li>
<li><strong>半活狀態跟 application code 層級不同</strong>：先把工具鏈狀態釐清，再回到剛改的程式碼</li>
</ol>
<hr>
<h2 id="適用範圍">適用範圍</h2>
<p>這個診斷思維不限於 Android emulator：</p>
<ul>
<li>iOS Simulator 卡住時 <code>xcrun simctl list</code> 印不出來——同樣的「指令卡 + 訊息看似 fatal 但 process 仍存在」結構</li>
<li><code>flutter devices</code> 對任何 device（含 iOS、Web、desktop）的查詢都會走類似的「列出 → 逐個 query property」流程、任一層卡都會表現為類似症狀</li>
<li>廣義地說，任何「server 維護一份 client 清單 + 對每個 client 做同步呼叫」的架構（k8s <code>kubectl get pods</code> 對 zombie node、docker <code>docker ps</code> 對掛掉的 container runtime 等）都有同款 failure mode</li>
</ul>
<p>辨認規則一致：<strong>list 指令連跑兩次結果不一致 → 維護清單的 server 對某個 entry 的看法不穩定 → 找出那個 entry 局部處理</strong>。這條規則的邊界是：如果清單穩定但操作失敗，問題更可能在該 target 的權限、版本或 runtime 狀態，需要改走對應工具的細部診斷。</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>