fix(collector): #60 監看的是筆記庫底下的子資料夾時,保護整個失效

真正的根因不是「vault 判斷漏了子庫」,是**判斷的方向搞反了**:
前兩輪問的都是「監看根**這一層**是不是 vault」,但 daemon 的產物一律落在
監看根底下——這兩件事只有在「監看根 == 庫根」時才等價,而那正好是前兩輪
唯一測過、也唯一不會出事的擺法。

使用者只要把庫底下的某一層加進監看(`KB/docs`、`KB/pages`、Obsidian 庫裡的
某個專案夾——很自然的用法),DetectVaultType 就回 VaultNone,整套保護退回
一般資料夾模式,卡片落在 `<監看根>/system-dev/wiki/cards/`:那個路徑就在
使用者的 graph 裡面,而且看得見,Logseq/Obsidian 每一張卡都收編成一頁。
前綴(第二輪)只擋得住撞名,擋不住「多出一堆機器頁」。

改法:把「這一層是不是庫」與「我寫的東西會不會落進誰的庫」拆成兩個判準。

- vault.go:新增 DetectVaultContext(往上找到最近的庫根)與 VaultDirUnder
  (往下擋:寫入目標會不會踩進子庫)。DetectVaultType 一字未改,繼續與
  install.sh 對齊——往上找用較嚴的判準(logseq/ 要有 config.edn 或
  journals//pages/ 佐證),因為那是替使用者猜、而且一次猜好幾層。
  停在家目錄與檔案系統根,避免 `~/logseq` 這種常見資料夾把整個家目錄判成庫。
- extract.go:cardsRelDirFor 改用 DetectVaultContext。
- safewrite.go:落卡前過 ensureWritable 機械閘——目標踩進子庫就中止,
  不靜靜寫進去。今天不會觸發,它防的是以後新增的寫檔點。
- tidy.go:收拾判準從「有沒有帶標記」擴充成「位置對不對 + 有沒有帶標記」,
  舊版留在看得見位置的卡會被搬進隱藏目錄;MigrateCardNames 每輪自動做,
  使用者不必下任何指令。報告多一個 VaultRoot,說清楚是誰的庫。

leo 派工單上的線索(庫在監看根**底下**)實測不成立:產物一律錨在監看根,
不會落進子庫。但那個「本來就沒破」原本沒有任何機制保證,所以照樣把兩種
格式的子庫情境永久寫進測試,加上 ensureWritable 當第二道保險。

驗證缺口(票上第 6 條):第二輪的足跡測試方向是對的,漏的是**觀測窗**——
snapshotTree 只拍監看根,而災情發生在監看根外面、庫裡面;且 fixture 只有
`root := vault` 一種擺法,測試與被測程式犯了同一個假設,所以永遠是綠的。
vault_subdir_test.go 把快照邊界改成筆記庫,並把「監看根與庫根的關係」升格
成測試維度(庫在上/庫在下/庫就是它/沒有庫 × Logseq/Obsidian)。

全程只用 t.TempDir() 與 mktemp -d;沒碰任何真實筆記庫、沒重啟任何 daemon。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 18:23:49 +08:00
parent 5322103459
commit 2f33324d3f
11 changed files with 863 additions and 38 deletions
+143 -2
View File
@@ -1,4 +1,15 @@
// vault.go — 偵測監看根是不是筆記軟體的 vaultLogseq/Obsidian)。
// vault.go — 判斷「daemon 要寫的東西,會不會落進使用者的筆記庫Logseq/Obsidian
//
// 本檔有**兩層**判準,回答兩個不同的問題,混用就是 bug:
//
// · DetectVaultType(dir) =「**這一層**是不是筆記庫」。逐字對齊 install.sh,
// 給「使用者親手指定的那個資料夾」用。
// · DetectVaultContext(root)=「這個監看根**在不在**某個筆記庫的範圍內」(會往上找)。
// VaultDirUnder(root,dst) =「這個寫入目標**會不會踩進**某個子筆記庫」(往下擋)。
// 決定「寫哪裡/能不能寫」一律用這一組。
//
// 前兩輪只有第一層,於是「監看根是 vault 底下的子資料夾」整個繞過保護——
// 全文見下方「第三輪」那段。
//
// 🔴 為什麼有這支檔(arcrun-rag#602026-08-10 leo 實撞):
//
@@ -19,6 +30,7 @@ package collector
import (
"os"
"path/filepath"
"strings"
)
// VaultType 是偵測結果的字串常數,值刻意跟 install.sh 的 VAULT_TYPE 用同一組字。
@@ -43,12 +55,141 @@ func DetectVaultType(absRoot string) VaultType {
return VaultNone
}
// IsVault 是 DetectVaultType 的布林簡寫,供只關心「是不是 vault」的呼叫端用。
// IsVault 是 DetectVaultType 的布林簡寫,供只關心「**這一層**是不是 vault」的呼叫端用。
//
// ⚠️ 要決定「daemon 可不可以在這裡寫東西」請**不要**用這支——用 DetectVaultContext。
// 理由見下方 DetectVaultContext 的開頭:監看根不是 vault,不代表它不在別人的 vault 裡面。
func IsVault(absRoot string) bool {
return DetectVaultType(absRoot) != VaultNone
}
// ─────────────────────────────────────────────────────────────────────────────
// 第三輪(arcrun-rag#602026-08-12):vault 不是「一層」,是「一個範圍」
// ─────────────────────────────────────────────────────────────────────────────
//
// 🔴 前兩輪的共同盲點:所有判斷都問「**監看根這一層**是不是 vault」,
// 而 daemon 的產物一律落在**監看根底下**。這兩件事只有在「監看根 == vault 根」時
// 才等價——而那正好是前兩輪唯一測過的擺法。
//
// 只要使用者把 vault 底下的**某一層子資料夾**加進監看(`KB/docs`、`KB/pages`、
// Obsidian 庫裡的某個專案夾——這是很自然的用法:「我只想讓它讀這一區」),
// DetectVaultType 就回 VaultNone ⇒ 整套保護退回一般資料夾模式 ⇒ 卡片落在
// `<監看根>/system-dev/wiki/cards/`,而那個路徑**就在使用者的 vault 裡面**,
// 且不是隱藏目錄 ⇒ Logseq/Obsidian 會把每一張卡收編成一頁。
// 前綴(machinemark.go)只擋得住「撞名」,擋不住「多出一堆機器頁」。
//
// ⇒ 正確的問題不是「這個資料夾是不是 vault」,是「**我要寫的東西會不會落進誰的 vault**」。
// 那是一個範圍問題,要往上找(我在不在某個 vault 裡)也要往下擋(我會不會踩進某個子庫)。
//
// 為什麼往上找要用比 DetectVaultType 更嚴的判準(ancestorVaultTypeAt):
// DetectVaultType 是**使用者親手指定的那一個資料夾**的判準,必須逐字對齊 install.sh;
// 往上找則是**替使用者猜**,而且一次要猜好幾層——同樣的誤判率乘上層數,代價完全不同。
// 一個叫 `logseq` 的普通資料夾(放筆記軟體匯出檔、放腳本的人都有)擺在家目錄或
// 專案目錄裡,就會讓它底下所有監看根被誤判成「在 vault 裡」。所以往上找時
// **要求佐證**`logseq/config.edn`Logseq 開過的 graph 一定有)或 `journals/``pages/`。
// `.obsidian/` 是點開頭的專用目錄,不存在這種誤判,不必佐證。
// VaultContext 描述「監看根與筆記庫的關係」——daemon 要決定寫哪裡時看的是這個,不是 IsVault。
type VaultContext struct {
Type VaultType // VaultNone=這個監看根不在任何筆記庫的範圍內
Root string // 筆記庫的根(絕對路徑);Type==VaultNone 時為空字串
Self bool // true=筆記庫的根就是監看根本身(前兩輪唯一處理到的那條路)
}
// InVault 回答「這個監看根落在某個筆記庫的範圍內嗎」(含監看根自己就是庫的情況)。
func (c VaultContext) InVault() bool { return c.Type != VaultNone }
// DetectVaultContext 從 absRoot 自己開始,一路往上找到最近的筆記庫根。
//
// 順序刻意是「先自己、再往上」:自己這一層用 DetectVaultType(與 install.sh 同一套字),
// 往上才用 ancestorVaultTypeAt(要佐證,理由見上)。
//
// 停止條件兩個,都是為了不要往上猜過頭:
// - 走到檔案系統根就停(parent == dir)。
// - 走到使用者家目錄就停,且**不檢查家目錄本身**——`~/logseq` 這種名字的資料夾
// 太常見(Logseq 的 graph 常直接放在那),拿它把整個家目錄判成 vault,
// 會讓家目錄底下每一個監看根都改變行為。代價不對稱:漏判一個「家目錄本身就是
// 筆記庫」的病態擺法,遠比誤判所有人便宜。
func DetectVaultContext(absRoot string) VaultContext {
absRoot = filepath.Clean(absRoot)
if vt := DetectVaultType(absRoot); vt != VaultNone {
return VaultContext{Type: vt, Root: absRoot, Self: true}
}
home := ""
if h, err := os.UserHomeDir(); err == nil && h != "" {
home = filepath.Clean(h)
}
for dir := absRoot; ; {
parent := filepath.Dir(dir)
if parent == dir {
return VaultContext{} // 檔案系統根
}
dir = parent
if home != "" && dir == home {
return VaultContext{} // 家目錄(含)以上不猜
}
if vt := ancestorVaultTypeAt(dir); vt != VaultNone {
return VaultContext{Type: vt, Root: dir}
}
}
}
// ancestorVaultTypeAt 是「往上找/往下擋」共用的**嚴格**判準:
// 不是使用者親手指定的資料夾,就要有佐證才算數(理由見本段開頭的長註解)。
// 優先序與 install.sh 一致:logseq 先於 obsidian。
func ancestorVaultTypeAt(dir string) VaultType {
if isDir(filepath.Join(dir, "logseq")) && logseqCorroborated(dir) {
return VaultLogseq
}
if isDir(filepath.Join(dir, ".obsidian")) {
return VaultObsidian
}
return VaultNone
}
// logseqCorroborated:除了 `logseq/` 之外,還看得到 Logseq graph 的其他證據嗎。
// config.ednLogseq 開過這個 graph 就會有;journals/ 與 pages/ 是 graph 的骨架目錄。
func logseqCorroborated(dir string) bool {
if isFile(filepath.Join(dir, "logseq", "config.edn")) {
return true
}
return isDir(filepath.Join(dir, "journals")) || isDir(filepath.Join(dir, "pages"))
}
// VaultDirUnder 往**下**擋:從 absRoot 走到 absTarget 的路上,有沒有踩進某個子筆記庫。
// 命中就回傳那個子庫的絕對路徑與類型。
//
// 🔴 這支刻意**只走 absTarget 這一條路徑上的目錄**(幾層而已),不 walk 整棵樹——
// 使用者的筆記庫可能有幾萬個檔,每次落卡掃一遍是不能接受的成本,而且也沒必要:
// 我們只需要知道「**我這次要寫的這個位置**安不安全」,不需要知道樹裡還有哪些庫。
//
// 目前所有寫檔點的目標都錨在監看根(system-dev/wiki/cards 或 .arcrun-rag/wiki/cards),
// 所以這支正常情況下永遠不命中。它存在的意義是**機械閘**:以後誰新增一個
// 「把產物寫在原稿旁邊」之類的寫檔點,會在這裡當場被擋下來,而不是在某個
// 陌生人的筆記庫裡被發現。
func VaultDirUnder(absRoot, absTarget string) (string, VaultType) {
rel, err := filepath.Rel(filepath.Clean(absRoot), filepath.Clean(absTarget))
if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
return "", VaultNone // 目標不在監看根底下,本支不負責
}
dir := filepath.Clean(absRoot)
parts := strings.Split(filepath.ToSlash(rel), "/")
// 最後一段是檔名,不必檢查;中間每一層目錄都要。
for _, seg := range parts[:len(parts)-1] {
dir = filepath.Join(dir, seg)
if vt := ancestorVaultTypeAt(dir); vt != VaultNone {
return dir, vt
}
}
return "", VaultNone
}
func isDir(p string) bool {
info, err := os.Stat(p)
return err == nil && info.IsDir()
}
func isFile(p string) bool {
info, err := os.Stat(p)
return err == nil && !info.IsDir()
}