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:
@@ -1,4 +1,15 @@
|
||||
// vault.go — 偵測監看根是不是筆記軟體的 vault(Logseq/Obsidian)。
|
||||
// vault.go — 判斷「daemon 要寫的東西,會不會落進使用者的筆記庫(Logseq/Obsidian)」。
|
||||
//
|
||||
// 本檔有**兩層**判準,回答兩個不同的問題,混用就是 bug:
|
||||
//
|
||||
// · DetectVaultType(dir) =「**這一層**是不是筆記庫」。逐字對齊 install.sh,
|
||||
// 給「使用者親手指定的那個資料夾」用。
|
||||
// · DetectVaultContext(root)=「這個監看根**在不在**某個筆記庫的範圍內」(會往上找)。
|
||||
// VaultDirUnder(root,dst) =「這個寫入目標**會不會踩進**某個子筆記庫」(往下擋)。
|
||||
// 決定「寫哪裡/能不能寫」一律用這一組。
|
||||
//
|
||||
// 前兩輪只有第一層,於是「監看根是 vault 底下的子資料夾」整個繞過保護——
|
||||
// 全文見下方「第三輪」那段。
|
||||
//
|
||||
// 🔴 為什麼有這支檔(arcrun-rag#60,2026-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#60,2026-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.edn=Logseq 開過這個 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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user