iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0

Index 建好之後,資料都在那裡了,只是還沒有人把它讀出來給人看。」

Day 10 讓 internal/vault 有了 BuildIndex,把 Scan 產出的筆記清單與 ExtractWikilinks 抓到的 Wikilink 目標,整併成一份全庫關係索引:Outbound/Inbound 記錄連結的進出,Unresolved 記錄哪些連結指向不存在的筆記。但索引建好之後就沒有下文了——沒有任何 CLI 指令會讀它、也沒有人看得到裡面的內容。今天把這份索引「用起來」:新增 brain health 指令,直接消費 Index 判定孤立筆記與斷鏈,輸出一份人類可讀的健康檢查報告。

孤立筆記:無 outbound 且無 inbound,才算孤立

判定邏輯只有一行:

for note := range idx.Outbound {
    if len(idx.Outbound[note]) == 0 && len(idx.Inbound[note]) == 0 {
        report.OrphanNotes = append(report.OrphanNotes, note)
    }
}

刻意選擇「outbound 跟 inbound 都要是零」,而不是只看 len(Inbound[note]) == 0。理由是:只看 inbound 為零,會把「剛寫好、內容紮實地引用了既有筆記,只是還沒有人回頭連結它」的筆記也算成孤立——這種筆記其實已經是知識網格的一部分,只是連結方向是單向的,不該被提醒使用者處理。真正該提醒的,是「完全沒有跟任何筆記產生關係」的筆記,那才是遊離在知識庫外、可能被遺忘的孤島。

internal/vault/health_test.go 用四種情境把這個判斷釘死:

func TestRunHealthCheck_OutboundOnlyNoteIsNotOrphan(t *testing.T) {
    target := newTestNote("target.md", "Target Note", nil, nil, "")
    source := newTestNote("source.md", "Source Note", nil, nil, "參考 [[Target Note]]。\n")
    idx := vault.BuildIndex([]*vault.Note{target, source})

    report := vault.RunHealthCheck(idx)

    assert.NotContains(t, report.OrphanNotes, source)
}

source 只有 outbound(它連結了 target),沒有 inbound,測試驗證它不會被誤判成孤立;反過來 target 只有 inbound、沒有 outbound,同樣不算孤立。只有「outbound 為空、inbound 也為空」的筆記,才會出現在 OrphanNotes 裡。

斷鏈:直接重用 idx.Unresolved,不重新解析

Index 在 Day 10 建構時,就已經把每一條解析失敗的 Wikilink 記錄進 UnresolvedRunHealthCheck 不會再呼叫一次 idx.Resolve,只是把這份清單原樣複製:

report := &HealthReport{
    BrokenLinks: append([]UnresolvedLink{}, idx.Unresolved...),
}

這樣做的好處是「brain health 回報的斷鏈」跟「BuildIndex 內部認定的斷鏈」永遠是同一份事實,不會因為重新計算而產生兩份標準不一致的結果——如果未來 Resolve 的解析規則變了(例如支援更寬鬆的別名匹配),brain health 會自動跟著變,不需要在 health.go 裡維護第二套判斷邏輯。

為什麼一定要排序

Index.Outbound/Inbound 都是 Go 的 map[*Note][]stringmap[*Note][]*Note,map 的走訪順序不保證穩定。如果 RunHealthCheck 直接把走訪 map 得到的順序塞進 OrphanNotes,同一份 vault、內容完全沒變,兩次執行 brain health 印出來的孤立筆記清單可能長得不一樣——這對人類讀報告(想確認某篇筆記到底在不在清單裡)是干擾雜訊,對 Day 28 打算接進 CI 的 log diff 更是麻煩:明明沒有任何筆記異動,diff 卻因為順序洗牌而顯示一堆變化。

所以 RunHealthCheck 最後一定會排序:

sort.Slice(report.OrphanNotes, func(i, j int) bool {
    return report.OrphanNotes[i].Frontmatter.Title < report.OrphanNotes[j].Frontmatter.Title
})

sort.Slice(report.BrokenLinks, func(i, j int) bool {
    a, b := report.BrokenLinks[i], report.BrokenLinks[j]
    if a.Source.Frontmatter.Title != b.Source.Frontmatter.Title {
        return a.Source.Frontmatter.Title < b.Source.Frontmatter.Title
    }
    return a.Target < b.Target
})

OrphanNotes 依標題排序;BrokenLinks 先依來源筆記標題排序,同一篇筆記有多條斷鏈時再依 Target 字串排序。用 Title 而不是 FilePath 排序,是因為讀報告的人通常記得筆記標題,不會記得檔案路徑——排序的目的是給人看,不是給檔案系統看。

結束碼:只有斷鏈會讓指令失敗

if report.HasBrokenLinks() {
    return fmt.Errorf("偵測到 %d 筆斷鏈", len(report.BrokenLinks))
}
return nil

brain health 的結束碼只由 HasBrokenLinks() 決定,孤立筆記數量與 Scan 階段的解析失敗都不影響結束碼。這是刻意的取捨:斷鏈是「資料明確錯誤」——Wikilink 指向一個不存在的筆記,很可能是打錯字或筆記被誤刪,適合當成 CI 門檢的硬性失敗條件;孤立筆記則是「資訊性提示」,新筆記本來就會經歷一段還沒被連結回去的階段,不代表寫錯了什麼。如果讓孤立筆記也讓結束碼非零,Day 28 的 CI/CD 會對幾乎每一次新增筆記的 PR 都判定失敗,久了使用者只會忽略整個健康檢查機制——這正是我們想避免的警報疲勞。

這也是本系列第一個「執行結果可能讓結束碼非零」的指令。brain scan 遇到單篇解析失敗仍維持結束碼 0,brain health 延續這個慣例處理 ScanError,但為斷鏈開了先例,直接呼應 Day 11 proposal 裡「讓 Day 28 的 CI/CD 有一個可以直接掛進 pipeline 的健康檢查指令」的目標。

測試對照:四種孤立情境、斷鏈一致性、結束碼行為

cmd/brain/health_test.go 用暫存目錄驗證 CLI 層級的結束碼行為:

func TestHealthCmd_BrokenLinkExitsNonZero(t *testing.T) {
    dir := t.TempDir()
    writeFixtureNote(t, dir, "source.md", "Source Note", "參考 [[Missing Note]]。\n")

    cmd := newHealthCmd(dir)
    var out bytes.Buffer
    cmd.SetOut(&out)

    err := cmd.RunE(cmd, nil)

    assert.Error(t, err)
}

func TestHealthCmd_NoBrokenLinksExitsZeroEvenWithOrphan(t *testing.T) {
    dir := t.TempDir()
    writeFixtureNote(t, dir, "lonely.md", "Lonely Note", "")

    cmd := newHealthCmd(dir)
    var out bytes.Buffer
    cmd.SetOut(&out)

    err := cmd.RunE(cmd, nil)

    assert.NoError(t, err)
    assert.Contains(t, out.String(), "Lonely Note")
}

第一個測試的 vault 只有一篇筆記、寫了一條指向不存在筆記的連結——沒有孤立筆記可言,但因為有斷鏈,RunE 回傳非零錯誤。第二個測試反過來:一篇完全沒有連結的孤立筆記、沒有任何斷鏈——RunE 回傳 nil,結束碼維持 0,即使孤立筆記清單裡確實列出了它。這組對照剛好把「孤立筆記不影響結束碼、斷鏈才影響結束碼」的規則釘死在測試上,不是只靠文件承諾。

實機跑一次目前的 vault/

$ go run ./cmd/brain health
孤立筆記:
- Cobra CLI 框架 (vault/20_Areas/Cobra CLI 框架.md)
- PARA 筆記法 (vault/00_Inbox/PARA 筆記法.md)
斷鏈:
共 2 篇孤立筆記,0 筆斷鏈
  解析失敗:vault/README.md: 缺少 Frontmatter:檔案開頭必須是 "---"
exit=0

兩篇範例筆記彼此還沒有互相連結,所以都被列為孤立,但因為沒有斷鏈,結束碼是 0;vault/README.md 沒有 Frontmatter 而解析失敗,沿用 brain scan 既有格式列在報告最後,也不影響結束碼。go run ./cmd/brain scan 重新跑過,輸出格式與內容完全沒變——這次沒有動到 Note/Frontmatter/Index 既有結構,也沒有動到 Scan/ExtractWikilinks/BuildIndex 的函式簽章。

銜接 Day 16 與 Day 28

Index.Conflicts(標題/別名衝突)今天刻意沒有處理——不是不重要,而是「孤立筆記」與「斷鏈」已經是 README 對 Day 11 明確承諾的範疇,衝突報告牽涉到「該視為錯誤還是警告」這種額外的產品決策,留給未來需要時再開獨立 change。

brain health 目前只負責偵測與回報,不負責修復——不會自動幫孤立筆記補上連結,也不會自動建立斷鏈指向的缺失筆記。這部分是 Day 16(自動回填雙向連結)的範疇,屆時很可能會直接重用今天的孤立筆記判定邏輯或 HealthReport 資料結構,作為「該回填哪些筆記」的輸入。而今天訂下的「斷鏈非零結束碼」規則,則是專門為 Day 28(ci-cd-pipeline)鋪路:CI 只需要呼叫 brain health、檢查結束碼,就能判斷這次 PR 有沒有引入斷鏈,不需要自己解析輸出文字。

👉 明天 Day 12,我們要幫 Stage 2 的 brain-cli 做一次整合展示,看看 scan/capture/health 串起來,能不能在毫秒級跑完全庫分析。我們明天見!


上一篇
索引與快取:建立本地 Vault Metadata 輕量記憶庫
下一篇
【階段成果展】在 Terminal 中跑通 Inbox 自動掃描與 AST 分析
系列文
打造 AI Agent 驅動的第二大腦:用 Go + Claude Code + Obsidian + Graphify 打造工程師知識作業系統15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言