fix(collector): 萃取產物加 vault 辨識+落卡不再無條件覆蓋(arcrun-rag#60)

事故:daemon 完全沒有 vault 辨識,把萃取卡片寫進使用者的 Logseq vault
(system-dev/wiki/cards/ 對一般資料夾沒事,對 vault=憑空多出頁面,
2026-08-10 leo 實撞:25 個簡體字卡片污染 ~/Documents/KB)。

修法(照票上指示搬現成的,不重新設計):
- collector/vault.go:DetectVaultType/IsVault,判準逐條抄自
  system-dev-template/scripts/install.sh:209-221(先查 logseq/、再查 .obsidian/)。
  已用同一批 fixture 資料夾跑過 install.sh 與這支 Go 版,五種情境(logseq/
  obsidian/一般資料夾/兩者皆有/空資料夾)IS_VAULT 判斷逐一比對一致。
- extract.go:cardsRelDirFor() 依 IsVault 決定卡片相對路徑——非 vault 不變
  (system-dev/wiki/cards/),vault 改落 .arcrun-rag/wiki/cards/(點開頭隱藏
  目錄,Logseq/Obsidian 預設不掃描,跟 daemon 自己 scan.go 的隱藏目錄跳過規則
  一致)。extract_workersai.go/extract_gemma.go/direct.go 的落卡與下架清除
  都改用這個函式,三處對同一個 absRoot 保證同一個答案。
- safewrite.go:safeWriteCard() 取代兩處無條件 os.WriteFile——目標已存在且
  內容不同就先備份成 <dest>.bak-<unixnano> 才覆寫;內容相同則不動(不產生
  垃圾備份);備份失敗就整個中止,不無聲蓋掉使用者機器上已有的東西。

驗證(見 PR/commit 說明附的實測輸出):
- 用 fake Logseq vault fixture 重現舊行為(卡片確實落在 system-dev/wiki/cards/),
  再用同一份 fixture 驗新行為(卡片改落 .arcrun-rag/wiki/cards/,vault 根目錄
  非隱藏 .md 數量不變、journals/ 原稿位元不動)。
- 故意放同名既有卡片,跑完既有內容被備份、新內容確實寫入,未無聲遺失。
- 非 vault 既有測試(extract_gemma_test.go 原有三支)全數不動照過,確認
  一般資料夾行為零改變。

範圍外(留給下一輪):ExtractWithClaude(extract.go 的 claude 路)與其
templatefs/.claude/commands/rag-extract-file.md 技能檔仍硬寫 system-dev/wiki/cards/,
但這條路目前在 direct.go 的 RunDirectOnce 是不支援狀態(cfg.Extractor 只認
workers-ai/gemma),非本次事故的作用路徑,故未動。

不影響:leo 機器上的 daemon(未重啟、未重新打包);此修復要生效還要
①總管審過併 main ②重新打包桌面版裝上他機器 ③總管用新二進位實測,
三件缺一不可(見 issue #60 leo 的重啟條件)。
This commit is contained in:
2026-08-11 12:12:51 +08:00
parent 3862a1a877
commit f10d10747c
10 changed files with 538 additions and 13 deletions
+99
View File
@@ -0,0 +1,99 @@
// vault_test.go — arcrun-rag#60:驗 vault 偵測跟 system-dev-template/scripts/install.sh:209-221
// 的 bash 判準逐條對齊(同一個資料夾兩邊必須得出同一個答案)。
package collector
import (
"os"
"path/filepath"
"testing"
)
func TestDetectVaultType_Logseq(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "logseq"))
mustMkdir(t, filepath.Join(root, "pages"))
mustMkdir(t, filepath.Join(root, "journals"))
if got := DetectVaultType(root); got != VaultLogseq {
t.Fatalf("DetectVaultType=%qwant %q", got, VaultLogseq)
}
if !IsVault(root) {
t.Fatal("IsVault 應為 true")
}
}
func TestDetectVaultType_Obsidian(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, ".obsidian"))
if got := DetectVaultType(root); got != VaultObsidian {
t.Fatalf("DetectVaultType=%qwant %q", got, VaultObsidian)
}
if !IsVault(root) {
t.Fatal("IsVault 應為 true")
}
}
func TestDetectVaultType_PlainFolder(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "docs"))
if got := DetectVaultType(root); got != VaultNone {
t.Fatalf("DetectVaultType=%qwant VaultNone(一般資料夾不該被認成 vault", got)
}
if IsVault(root) {
t.Fatal("IsVault 應為 false")
}
}
// 兩者都有時,install.sh 用 if/eliflogseq 優先)——Go 這邊也要一樣的優先序。
func TestDetectVaultType_BothPresent_LogseqWins(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "logseq"))
mustMkdir(t, filepath.Join(root, ".obsidian"))
if got := DetectVaultType(root); got != VaultLogseq {
t.Fatalf("DetectVaultType=%qwant %qinstall.sh 用 if/eliflogseq 優先)", got, VaultLogseq)
}
}
// 空資料夾也不該被誤判成 vault。
func TestDetectVaultType_EmptyFolder(t *testing.T) {
root := t.TempDir()
if got := DetectVaultType(root); got != VaultNone {
t.Fatalf("DetectVaultType=%qwant VaultNone", got)
}
}
// install.sh 判準是「-d logseq」——檔案(非目錄)叫這名字不算數。
func TestDetectVaultType_FileNotDir_DoesNotCount(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "logseq"), []byte("not a dir"), 0o644); err != nil {
t.Fatal(err)
}
if got := DetectVaultType(root); got != VaultNone {
t.Fatalf("DetectVaultType=%qwant VaultNonelogseq 是檔案不是目錄,不該算 vault)", got)
}
}
// cardsRelDirFor:非 vault 行為不變(回歸),vault 改落隱藏目錄。
func TestCardsRelDirFor(t *testing.T) {
plain := t.TempDir()
if got := cardsRelDirFor(plain); got != cardsRelDir {
t.Fatalf("非 vaultcardsRelDirFor=%qwant %q(既有行為不能變)", got, cardsRelDir)
}
vault := t.TempDir()
mustMkdir(t, filepath.Join(vault, "logseq"))
if got := cardsRelDirFor(vault); got != vaultCardsRelDir {
t.Fatalf("vaultcardsRelDirFor=%qwant %q", got, vaultCardsRelDir)
}
// vault 目的地必須是隱藏(點開頭)路徑——這是「Logseq/Obsidian/daemon 自己的 scan.go
// 都不掃描」的唯一保證來源,不能改掉。
if filepath.Base(filepath.Dir(vaultCardsRelDir))[0] != '.' && vaultCardsRelDir[0] != '.' {
t.Fatalf("vaultCardsRelDir=%q 不是隱藏路徑,vault 頁面數保護會失效", vaultCardsRelDir)
}
}
func mustMkdir(t *testing.T, p string) {
t.Helper()
if err := os.MkdirAll(p, 0o755); err != nil {
t.Fatal(err)
}
}