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:
@@ -946,7 +946,8 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil, rootProgress{}
|
||||
}
|
||||
cfg.migrateManifestIfNeeded(absRoot, absManifest) // t86b:一次性遷移舊格式帳本
|
||||
// arcrun-rag#60 第二輪:把上一版 daemon 落下、還沒帶 arcrun- 標記的舊卡就地改名。
|
||||
// arcrun-rag#60:把上一版 daemon 落下的舊卡歸位——沒帶 arcrun- 標記的改名,
|
||||
// 位置不對的(第三輪:監看根在筆記庫裡,卡卻落在看得見的 system-dev/wiki/cards/)搬進隱藏目錄。
|
||||
// 自動跑而不是叫人下指令——leo 的紅線:「他不該為了保護自己的筆記去學新選項」。
|
||||
// 只碰卡片產物區(那整個目錄只有我們會寫),目標已存在就跳過、永不刪檔,見 tidy.go。
|
||||
// template 殘留不在這裡處理:那要人確認「這是不是你自己的 repo」,走 `collector tidy`。
|
||||
@@ -954,7 +955,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
if n := MigrateCardNames(absRoot); n > 0 {
|
||||
results = append(results, DirectResult{
|
||||
Type: "warning", Path: absRoot, Status: "skipped",
|
||||
Error: fmt.Sprintf("已把 %d 張舊卡片改名加上 arcrun- 前綴(避免與你自己的筆記同名)", n),
|
||||
Error: fmt.Sprintf("已把 %d 張舊卡片歸位(加上 arcrun- 前綴/搬離筆記軟體看得到的位置)", n),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+10
-4
@@ -65,17 +65,23 @@ func FindClaudeBin(hint string) (string, error) {
|
||||
// 行為與改 Go 版之前完全一致。
|
||||
const cardsRelDir = "system-dev/wiki/cards"
|
||||
|
||||
// vaultCardsRelDir 是監看根被判定成 Logseq/Obsidian vault 時,卡片改落的位置(arcrun-rag#60)。
|
||||
// vaultCardsRelDir 是監看根**落在** Logseq/Obsidian vault 範圍內時,卡片改落的位置(arcrun-rag#60)。
|
||||
// 刻意用點開頭的隱藏目錄:Logseq/Obsidian 預設都不掃描點開頭的資料夾(跟 .git/.obsidian
|
||||
// 同待遇),daemon 自己的 Scan() 也一樣(見 scan.go「隱藏目錄整棵跳過」)——三邊一致,
|
||||
// 卡片仍然落在監看根底下(呼叫端「路徑相對監看根」的假設不用改),但不會被筆記軟體
|
||||
// 收編成新頁面,vault 的頁面數不會因為 daemon 跑過而增加。
|
||||
const vaultCardsRelDir = ".arcrun-rag/wiki/cards"
|
||||
|
||||
// cardsRelDirFor 決定某次萃取的卡片該落在哪個相對路徑:非 vault 用 cardsRelDir(不變),
|
||||
// vault 用 vaultCardsRelDir(arcrun-rag#60)。vault 判準見 vault.go,與 install.sh 對齊。
|
||||
// cardsRelDirFor 決定某次萃取的卡片該落在哪個相對路徑:
|
||||
// 監看根不在任何筆記庫範圍內=cardsRelDir(一般資料夾行為,零改變);
|
||||
// 落在筆記庫範圍內=vaultCardsRelDir(隱藏目錄)。
|
||||
//
|
||||
// 🔴 arcrun-rag#60 第三輪:判準從 IsVault(只看監看根**這一層**)換成 DetectVaultContext
|
||||
// (監看根**在不在**某個筆記庫裡)。前者在「監看根是 vault 底下的子資料夾」時回 false,
|
||||
// 於是卡片落回 `<監看根>/system-dev/wiki/cards/`——那個位置就在使用者的 vault 裡面,
|
||||
// 而且不是隱藏目錄,Logseq/Obsidian 會把每一張卡收編成一頁。全文見 vault.go 第三輪那段。
|
||||
func cardsRelDirFor(absRoot string) string {
|
||||
if IsVault(absRoot) {
|
||||
if DetectVaultContext(absRoot).InVault() {
|
||||
return vaultCardsRelDir
|
||||
}
|
||||
return cardsRelDir
|
||||
|
||||
+1
-1
@@ -129,7 +129,7 @@ func ExtractWithGemma(apiKey, model, absRoot, relPath string) ([]string, error)
|
||||
// 因為 Logseq 的頁名是 basename)。落地前先查目標存不存在、不無條件覆蓋(safeWriteCard)。
|
||||
cardRel := cardRelFor(absRoot, pageName)
|
||||
dest := filepath.Join(absRoot, filepath.FromSlash(cardRel))
|
||||
if err := safeWriteCard(dest, []byte(card)); err != nil {
|
||||
if err := safeWriteCard(absRoot, dest, []byte(card)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{cardRel}, nil
|
||||
|
||||
@@ -126,7 +126,7 @@ func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string) ([]string,
|
||||
// 因為 Logseq 的頁名是 basename)。落地前先查目標存不存在、不無條件覆蓋(safeWriteCard)。
|
||||
cardRel := cardRelFor(absRoot, pageName)
|
||||
dest := filepath.Join(absRoot, filepath.FromSlash(cardRel))
|
||||
if err := safeWriteCard(dest, []byte(card)); err != nil {
|
||||
if err := safeWriteCard(absRoot, dest, []byte(card)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{cardRel}, nil
|
||||
|
||||
+28
-2
@@ -12,12 +12,18 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// safeWriteCard 把卡片內容寫到 dest,寫之前先檢查目標是否已存在:
|
||||
// safeWriteCard 把卡片內容寫到 dest,寫之前先過兩道:
|
||||
//
|
||||
// ① **不准踩進使用者的子筆記庫**(arcrun-rag#60 第三輪)——見下方 ensureWritable。
|
||||
// ② 目標已存在時不無條件覆蓋:
|
||||
// - 不存在 → 直接寫(首次落卡,行為不變)。
|
||||
// - 存在且內容相同 → 不動它(冪等:同一份卡片重複萃取不該產生垃圾備份)。
|
||||
// - 存在但內容不同 → 先把既有內容備份成 `<dest>.bak-<unixnano>`,備份成功才覆寫;
|
||||
// 備份失敗就整個中止,寧可這次萃取失敗,也不要無聲蓋掉使用者機器上已經有的東西。
|
||||
func safeWriteCard(dest string, content []byte) error {
|
||||
func safeWriteCard(absRoot, dest string, content []byte) error {
|
||||
if err := ensureWritable(absRoot, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return fmt.Errorf("建立卡片目錄失敗:%w", err)
|
||||
}
|
||||
@@ -38,3 +44,23 @@ func safeWriteCard(dest string, content []byte) error {
|
||||
}
|
||||
return os.WriteFile(dest, content, 0o644)
|
||||
}
|
||||
|
||||
// ensureWritable 是「不准把機器產物寫進使用者的子筆記庫」這條規約的機械閘。
|
||||
//
|
||||
// 監看根**自己**在不在 vault 裡,由 cardsRelDirFor 決定落點(隱藏目錄)已經處理掉;
|
||||
// 這裡擋的是另一半:目標路徑中途經過某個**子**筆記庫。今天所有寫檔點的目標都錨在
|
||||
// 監看根,所以這道閘不會觸發——它的價值在以後:任何人新增一個「寫在原稿旁邊」的
|
||||
// 寫檔點,會在這裡當場失敗,而不是在陌生人的筆記庫裡被發現。
|
||||
//
|
||||
// 失敗而不是靜靜改寫到別處:票上的紅線是「寧可少寫一個檔,也不要多寫一個可能被誤認的檔」,
|
||||
// 而且錯誤訊息會沿著 DirectResult 冒到使用者面前,比默默換位置更容易被修掉。
|
||||
func ensureWritable(absRoot, dest string) error {
|
||||
if absRoot == "" {
|
||||
return nil // 呼叫端沒有監看根概念(例如單元測試直寫),不在本閘範圍
|
||||
}
|
||||
if vdir, vt := VaultDirUnder(absRoot, dest); vt != VaultNone {
|
||||
return fmt.Errorf("拒絕寫入 %s:這個位置在使用者的 %s 筆記庫裡(%s),"+
|
||||
"機器產物不得寫進筆記庫的命名空間(arcrun-rag#60)", dest, vt, vdir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+5
-4
@@ -10,8 +10,9 @@ import (
|
||||
|
||||
// 目標不存在=正常首次落卡,行為不變。
|
||||
func TestSafeWriteCard_NewFile(t *testing.T) {
|
||||
dest := filepath.Join(t.TempDir(), "sub", "foo.md")
|
||||
if err := safeWriteCard(dest, []byte("# foo\n內容")); err != nil {
|
||||
dir := t.TempDir()
|
||||
dest := filepath.Join(dir, "sub", "foo.md")
|
||||
if err := safeWriteCard(dir, dest, []byte("# foo\n內容")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(dest)
|
||||
@@ -32,7 +33,7 @@ func TestSafeWriteCard_ExistingFile_BacksUpBeforeOverwrite(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := safeWriteCard(dest, []byte("# foo\n機器新產出的卡")); err != nil {
|
||||
if err := safeWriteCard(dir, dest, []byte("# foo\n機器新產出的卡")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -76,7 +77,7 @@ func TestSafeWriteCard_SameContent_NoOp(t *testing.T) {
|
||||
if err := os.WriteFile(dest, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := safeWriteCard(dest, []byte(content)); err != nil {
|
||||
if err := safeWriteCard(dir, dest, []byte(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
|
||||
@@ -70,8 +70,11 @@ type TidyItem struct {
|
||||
|
||||
// TidyReport=一次收拾的完整帳目。
|
||||
type TidyReport struct {
|
||||
Root string `json:"root"`
|
||||
VaultType VaultType `json:"vault_type"`
|
||||
Root string `json:"root"`
|
||||
VaultType VaultType `json:"vault_type"`
|
||||
// VaultRoot=筆記庫的根。與 Root 不同時代表「監看的是筆記庫底下的一層子資料夾」
|
||||
// ——第三輪修的正是這一種(arcrun-rag#60)。空字串=不在任何筆記庫裡。
|
||||
VaultRoot string `json:"vault_root,omitempty"`
|
||||
Applied bool `json:"applied"` // false=只看不動(dry-run)
|
||||
Items []TidyItem `json:"items"`
|
||||
}
|
||||
@@ -102,9 +105,16 @@ func isUnderCardDir(relSlash string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// collectCardItems 掃出卡片產物區裡「還沒帶標記」的檔案,算出它們該改成什麼名字。
|
||||
// 已經帶標記的不列入(冪等:跑第二次是空的)。
|
||||
// collectCardItems 掃出卡片產物區裡「不在它現在該在的位置/還沒帶標記」的檔案,
|
||||
// 算出它們該搬去哪、該叫什麼名字。已經到位又帶標記的不列入(冪等:跑第二次是空的)。
|
||||
//
|
||||
// 🔴 arcrun-rag#60 第三輪:判準從「有沒有帶標記」擴充成「**位置對不對** + 有沒有帶標記」。
|
||||
// 只看標記在第三輪不夠——監看根是 vault 子資料夾時,前兩輪的 daemon 已經把一批
|
||||
// **帶標記但落在可見目錄**(`system-dev/wiki/cards/arcrun-*.md`)的卡寫進使用者的
|
||||
// 筆記庫了。那些卡在 Logseq 眼裡照樣是一頁一頁的機器頁面,改名救不了,要搬進隱藏目錄。
|
||||
// 少了這一段,票上「已經寫進去的那一批要能收拾」對這一類就等於沒做。
|
||||
func collectCardItems(absRoot string) []TidyItem {
|
||||
want := cardsRelDirFor(absRoot)
|
||||
var items []TidyItem
|
||||
for _, relDir := range cardDirsToScan() {
|
||||
base := filepath.Join(absRoot, filepath.FromSlash(relDir))
|
||||
@@ -113,20 +123,21 @@ func collectCardItems(absRoot string) []TidyItem {
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
if IsMarked(name) {
|
||||
return nil // 已經是新的了
|
||||
}
|
||||
rel, rerr := filepath.Rel(absRoot, p)
|
||||
if rerr != nil {
|
||||
return nil
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
if IsMarked(name) && filepath.ToSlash(filepath.Dir(relSlash)) == want {
|
||||
return nil // 位置對、標記也有,已經是新的了
|
||||
}
|
||||
kind := TidyKindCard
|
||||
if bakSuffix.MatchString(name) {
|
||||
kind = TidyKindCardBak
|
||||
}
|
||||
items = append(items, TidyItem{
|
||||
Rel: filepath.ToSlash(rel),
|
||||
To: filepath.ToSlash(filepath.Join(filepath.Dir(rel), MarkName(name))),
|
||||
Rel: relSlash,
|
||||
To: filepath.ToSlash(filepath.Join(want, MarkName(name))),
|
||||
Kind: kind,
|
||||
})
|
||||
return nil
|
||||
@@ -181,11 +192,15 @@ func collectTemplateItems(absRoot string, isVault bool) []TidyItem {
|
||||
return items
|
||||
}
|
||||
|
||||
// MigrateCardNames 把卡片產物區裡沒帶標記的舊卡就地改名。daemon 每輪自動呼叫。
|
||||
// 回傳實際改名的筆數。**只碰卡片產物區**,其餘一概不動。
|
||||
// MigrateCardNames 把卡片產物區裡「位置不對或沒帶標記」的舊卡歸位。daemon 每輪自動呼叫。
|
||||
// 回傳實際動到的筆數。**只碰卡片產物區**,其餘一概不動。
|
||||
//
|
||||
// 為什麼可以自動:那兩個目錄從頭到尾只有 daemon 會寫(見 cardsRelDirFor 的註解),
|
||||
// 不存在「誤把使用者的檔案改名」的可能。目標已存在就跳過,不覆蓋。
|
||||
//
|
||||
// 🔴 第三輪起也負責**搬移**(不只改名):監看根在 vault 裡時,舊版寫在可見目錄的卡
|
||||
// 會被搬進 `.arcrun-rag/wiki/cards/`。這是自動的,因為紅線寫著「不准要使用者去設定
|
||||
// 什麼開關才能保護自己的筆記」——他不該為了收拾機器留下的東西去學一個新指令。
|
||||
func MigrateCardNames(absRoot string) int {
|
||||
n := 0
|
||||
for _, it := range collectCardItems(absRoot) {
|
||||
@@ -194,6 +209,9 @@ func MigrateCardNames(absRoot string) int {
|
||||
if _, err := os.Stat(to); err == nil {
|
||||
continue // 新名字已經有東西了,不覆蓋
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := os.Rename(from, to); err == nil {
|
||||
n++
|
||||
}
|
||||
@@ -215,11 +233,19 @@ func Tidy(root string, apply bool) (*TidyReport, error) {
|
||||
return nil, fmt.Errorf("%s 不是資料夾", absRoot)
|
||||
}
|
||||
|
||||
vt := DetectVaultType(absRoot)
|
||||
rep := &TidyReport{Root: absRoot, VaultType: vt, Applied: apply}
|
||||
// 🔴 兩個判準,回答兩個不同的問題,不要合併(arcrun-rag#60 第三輪):
|
||||
//
|
||||
// · ctx(含往上找)=「我寫東西會不會落進誰的筆記庫」——決定卡片該在哪,也是報告顯示的身分。
|
||||
// · DetectVaultType(absRoot)(只看這一層)=「這個資料夾**本身**是不是筆記庫」——
|
||||
// 只有它才夠格授權「把 template 殘留搬走」。理由:一個住在筆記庫底下的
|
||||
// **使用者自己的開發 repo**(`KB/some-repo/system-dev/…`)在 ctx 眼中也「在 vault 裡」,
|
||||
// 但那些 template 檔是他的真檔案,不是 daemon 鋪的。搬了就是弄壞他的 repo。
|
||||
// 分不出來的時候只列不動——這條在第二輪就寫了,第三輪擴大偵測範圍時更要守住。
|
||||
ctx := DetectVaultContext(absRoot)
|
||||
rep := &TidyReport{Root: absRoot, VaultType: ctx.Type, VaultRoot: ctx.Root, Applied: apply}
|
||||
|
||||
items := collectCardItems(absRoot)
|
||||
items = append(items, collectTemplateItems(absRoot, vt != VaultNone)...)
|
||||
items = append(items, collectTemplateItems(absRoot, DetectVaultType(absRoot) != VaultNone)...)
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Rel < items[j].Rel })
|
||||
|
||||
for _, it := range items {
|
||||
@@ -227,7 +253,10 @@ func Tidy(root string, apply bool) (*TidyReport, error) {
|
||||
rep.Items = append(rep.Items, it)
|
||||
continue
|
||||
}
|
||||
willMove := it.Kind == TidyKindTemplate
|
||||
// 換了目錄就是「搬走」,只換名字才是「改名」——報告要說實話,
|
||||
// 使用者才知道去哪裡找他的東西(第三輪起卡片也會換目錄)。
|
||||
willMove := it.Kind == TidyKindTemplate ||
|
||||
filepath.ToSlash(filepath.Dir(it.Rel)) != filepath.ToSlash(filepath.Dir(it.To))
|
||||
from := filepath.Join(absRoot, filepath.FromSlash(it.Rel))
|
||||
to := filepath.Join(absRoot, filepath.FromSlash(it.To))
|
||||
|
||||
@@ -294,7 +323,11 @@ func runTidy(args []string) int {
|
||||
return 0
|
||||
}
|
||||
kind := "一般資料夾"
|
||||
if rep.VaultType != VaultNone {
|
||||
switch {
|
||||
case rep.VaultType != VaultNone && rep.VaultRoot != rep.Root:
|
||||
// 第三輪的那一種擺法——講清楚是誰的庫,不然使用者看不懂為什麼要搬。
|
||||
kind = fmt.Sprintf("%s 筆記庫「%s」底下的子資料夾", rep.VaultType, rep.VaultRoot)
|
||||
case rep.VaultType != VaultNone:
|
||||
kind = string(rep.VaultType) + " 筆記庫"
|
||||
}
|
||||
fmt.Printf("%s(%s)\n", rep.Root, kind)
|
||||
@@ -306,7 +339,11 @@ func runTidy(args []string) int {
|
||||
case TidyActionRenamed, TidyActionWillRename:
|
||||
fmt.Printf(" 改名 %s → %s\n", it.Rel, it.To)
|
||||
case TidyActionMoved, TidyActionWillMove:
|
||||
fmt.Printf(" 搬走 %s → %s(舊版 daemon 鋪的開發用檔案,不是你的筆記)\n", it.Rel, it.To)
|
||||
why := "舊版 daemon 鋪的開發用檔案,不是你的筆記"
|
||||
if it.Kind != TidyKindTemplate {
|
||||
why = "舊版寫在看得見的位置,你的筆記軟體會把它當成一頁——搬進隱藏目錄"
|
||||
}
|
||||
fmt.Printf(" 搬走 %s → %s(%s)\n", it.Rel, it.To, why)
|
||||
case TidyActionReport:
|
||||
fmt.Printf(" 略過 %s(%s)\n", it.Rel, it.Note)
|
||||
case TidyActionSkipped:
|
||||
|
||||
+14
-2
@@ -68,12 +68,23 @@ func TestTidy_RenamesLegacyCardsOnly(t *testing.T) {
|
||||
t.Fatalf("dry-run 竟然動了檔案:%s 不見了", rel)
|
||||
}
|
||||
}
|
||||
// 🔴 第三輪(2026-08-12)改了這裡的期望,這是**刻意的行為變更**:
|
||||
//
|
||||
// 本 fixture 的 root 是 vault,卻有一張卡躺在 `system-dev/wiki/cards/`——那是
|
||||
// **看得見的**目錄,Logseq/Obsidian 會把它當成一頁。第二輪只把它就地改名
|
||||
// (加上 arcrun- 前綴)就算收拾完,於是「不撞名」達成了、「不要在使用者的
|
||||
// 筆記庫裡多出機器頁面」沒達成。改名救不了位置。
|
||||
// ⇒ 現在位置不對的卡一律**搬**進隱藏的 vaultCardsRelDir,動作是 would-move。
|
||||
// 已經在隱藏目錄、只是少個前綴的,仍然是就地 would-rename(行為不變)。
|
||||
var previewed []string
|
||||
for _, it := range rep.Items {
|
||||
previewed = append(previewed, it.Rel+" → "+it.To+"("+it.Action+")")
|
||||
if it.Action != TidyActionWillRename {
|
||||
if it.Action != TidyActionWillRename && it.Action != TidyActionWillMove {
|
||||
t.Fatalf("dry-run 出現非預期動作:%+v", it)
|
||||
}
|
||||
if strings.HasPrefix(it.To, cardsRelDir+"/") {
|
||||
t.Fatalf("vault 裡的卡片被留在看得見的目錄:%+v", it)
|
||||
}
|
||||
}
|
||||
sort.Strings(previewed)
|
||||
t.Logf("dry-run 預覽:\n %s", strings.Join(previewed, "\n "))
|
||||
@@ -88,7 +99,8 @@ func TestTidy_RenamesLegacyCardsOnly(t *testing.T) {
|
||||
want := []string{
|
||||
".arcrun-rag/wiki/cards/arcrun-2026_08_10.md",
|
||||
".arcrun-rag/wiki/cards/arcrun-2026_08_10.md.bak-1723459200000000000",
|
||||
"system-dev/wiki/cards/arcrun-會議記錄.md",
|
||||
// 第三輪:這一張本來在 system-dev/wiki/cards/(看得見),現在搬進隱藏目錄。
|
||||
".arcrun-rag/wiki/cards/arcrun-會議記錄.md",
|
||||
}
|
||||
for _, rel := range want {
|
||||
if _, serr := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); serr != nil {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
// vault_subdir_test.go — arcrun-rag#60 第三輪(2026-08-12)。
|
||||
//
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// 前兩輪的驗法為什麼沒抓到這一個(票上第 6 條驗收,必答)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// 第二輪已經做對了一件很重要的事:把驗法從「點測某個函式回傳什麼」換成
|
||||
// **足跡測試**(跑之前 walk 一次、跑之後 walk 一次,比對多出什麼、動到什麼)。
|
||||
// 那張網對「寫檔點的數量」免疫——第三輪這個洞不是它漏抓,是**它看不到**。
|
||||
//
|
||||
// 漏的是另外兩件事,兩件都不是「網目太粗」,是**網架錯了地方**:
|
||||
//
|
||||
// ① 觀測窗 == 監看根。snapshotTree(root) 只 walk 監看根底下。
|
||||
// 而這一輪的災情發生在**監看根的外面、vault 的裡面**:監看根是
|
||||
// `KB/docs` 時,卡片落在 `KB/docs/system-dev/wiki/cards/`——在監看根裡面沒錯,
|
||||
// 但它同時也在 `KB` 這個 Logseq graph 裡面,而 Logseq 是**遞迴索引整個 graph** 的。
|
||||
// 要看見「使用者的筆記庫多了幾頁」,快照的邊界必須是**筆記庫**,不是監看根。
|
||||
// ⇒ 本檔的 fixture 一律回傳 vault 根,快照也一律拍 vault 根。
|
||||
//
|
||||
// ② fixture 只有一種擺法。前兩輪每一支測試都是 `root := vault`(監看根就是庫根),
|
||||
// 而那正好是唯一不會出事的擺法。判準函式只看一層,fixture 也只擺一層——
|
||||
// **測試與被測程式犯了同一個假設**,所以它永遠是綠的。
|
||||
// ⇒ 本檔把「監看根與庫根的關係」升格成測試維度,四種都擺:
|
||||
// 庫在上面/庫在下面/庫就是它/完全沒有庫;每種再乘 Logseq 與 Obsidian。
|
||||
//
|
||||
// 一句話:**第二輪問「daemon 多寫了什麼」,第三輪問「使用者的筆記庫多了什麼」。**
|
||||
// 後者才是 leo 打開 Logseq 時真正看到的東西。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 判準層:DetectVaultContext / VaultDirUnder
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// makeLogseqVault 造一個像樣的 Logseq graph(有 logseq/config.edn + journals/ + pages/)。
|
||||
func makeLogseqVault(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
mustMkdir(t, filepath.Join(dir, "logseq"))
|
||||
mustMkdir(t, filepath.Join(dir, "journals"))
|
||||
mustMkdir(t, filepath.Join(dir, "pages"))
|
||||
if err := os.WriteFile(filepath.Join(dir, "logseq", "config.edn"),
|
||||
[]byte("{:default-home {:page \"contents\"}}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func makeObsidianVault(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
mustMkdir(t, filepath.Join(dir, ".obsidian"))
|
||||
if err := os.WriteFile(filepath.Join(dir, ".obsidian", "app.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 監看根就是庫根=前兩輪處理好的那條路,不能被這次改動弄壞。
|
||||
func TestDetectVaultContext_SelfIsVault(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
make func(*testing.T, string)
|
||||
want VaultType
|
||||
}{
|
||||
{"logseq", makeLogseqVault, VaultLogseq},
|
||||
{"obsidian", makeObsidianVault, VaultObsidian},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
tc.make(t, root)
|
||||
ctx := DetectVaultContext(root)
|
||||
if ctx.Type != tc.want || !ctx.Self || ctx.Root != root {
|
||||
t.Fatalf("ctx=%+v,want Type=%q Self=true Root=%q", ctx, tc.want, root)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 第三輪的核心:庫在**上面**,監看根是它底下的子資料夾。
|
||||
func TestDetectVaultContext_RootInsideVault(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
make func(*testing.T, string)
|
||||
want VaultType
|
||||
}{
|
||||
{"logseq", makeLogseqVault, VaultLogseq},
|
||||
{"obsidian", makeObsidianVault, VaultObsidian},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
vault := filepath.Join(base, "KB")
|
||||
mustMkdir(t, vault)
|
||||
tc.make(t, vault)
|
||||
|
||||
// 一層、兩層都要認得——使用者可能監看 `KB/docs`,也可能監看 `KB/專案/A`。
|
||||
for _, sub := range []string{"docs", filepath.Join("專案", "A")} {
|
||||
watch := filepath.Join(vault, sub)
|
||||
mustMkdir(t, watch)
|
||||
ctx := DetectVaultContext(watch)
|
||||
if ctx.Type != tc.want {
|
||||
t.Fatalf("%s:ctx.Type=%q,want %q(監看根在 vault 裡卻沒被認出來)", sub, ctx.Type, tc.want)
|
||||
}
|
||||
if ctx.Self {
|
||||
t.Fatalf("%s:Self 應為 false(庫根是上層的 %s)", sub, vault)
|
||||
}
|
||||
if ctx.Root != vault {
|
||||
t.Fatalf("%s:ctx.Root=%q,want %q", sub, ctx.Root, vault)
|
||||
}
|
||||
if got := cardsRelDirFor(watch); got != vaultCardsRelDir {
|
||||
t.Fatalf("%s:卡片會落在 %q——那是可見目錄,筆記軟體會把它當成頁面", sub, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 回歸:沒有任何庫的普通資料夾,行為一個字都不能變。
|
||||
func TestDetectVaultContext_PlainFolder_Unchanged(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
watch := filepath.Join(base, "報告", "2026")
|
||||
mustMkdir(t, watch)
|
||||
if ctx := DetectVaultContext(watch); ctx.InVault() {
|
||||
t.Fatalf("普通資料夾被誤判成在 vault 裡:%+v", ctx)
|
||||
}
|
||||
if got := cardsRelDirFor(watch); got != cardsRelDir {
|
||||
t.Fatalf("cardsRelDirFor=%q,want %q(一般資料夾行為必須零改變)", got, cardsRelDir)
|
||||
}
|
||||
}
|
||||
|
||||
// 往上找要有佐證:光有一個叫 logseq 的資料夾不算數。
|
||||
//
|
||||
// 為什麼這條重要:`~/logseq`、專案裡放匯出檔的 `logseq/` 都很常見。沒有這條,
|
||||
// 那些人底下**每一個**監看根都會突然改變行為(卡片全部變隱藏)。
|
||||
func TestDetectVaultContext_BareLogseqDirUpstairs_NeedsCorroboration(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
fake := filepath.Join(base, "某個專案")
|
||||
mustMkdir(t, filepath.Join(fake, "logseq")) // 只有名字,沒有 config.edn/journals/pages
|
||||
watch := filepath.Join(fake, "sub")
|
||||
mustMkdir(t, watch)
|
||||
if ctx := DetectVaultContext(watch); ctx.InVault() {
|
||||
t.Fatalf("只有一個叫 logseq 的空資料夾就被判成筆記庫:%+v", ctx)
|
||||
}
|
||||
|
||||
// 補上 config.edn 之後就該認得了(真的 graph 一定有)。
|
||||
if err := os.WriteFile(filepath.Join(fake, "logseq", "config.edn"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx := DetectVaultContext(watch); ctx.Type != VaultLogseq {
|
||||
t.Fatalf("有 config.edn 了還是沒認出來:%+v", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// 監看根**自己**這一層仍走 install.sh 的寬鬆判準(兩邊必須同一個答案,vault.go 開頭的鐵律)。
|
||||
func TestDetectVaultContext_SelfKeepsInstallShRule(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustMkdir(t, filepath.Join(root, "logseq")) // 只有 logseq/,沒有佐證
|
||||
if got := DetectVaultType(root); got != VaultLogseq {
|
||||
t.Fatalf("DetectVaultType=%q,want logseq(這一層的判準要與 install.sh 逐字一致)", got)
|
||||
}
|
||||
if ctx := DetectVaultContext(root); ctx.Type != VaultLogseq || !ctx.Self {
|
||||
t.Fatalf("ctx=%+v,want logseq/Self(自己這一層不該套往上找的嚴格判準)", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// VaultDirUnder=往下擋:目標路徑中途踩進子筆記庫要被認出來。
|
||||
func TestVaultDirUnder(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sub := filepath.Join(root, "KB")
|
||||
makeLogseqVault(t, sub)
|
||||
|
||||
if dir, vt := VaultDirUnder(root, filepath.Join(sub, "pages", "x.md")); vt != VaultLogseq || dir != sub {
|
||||
t.Fatalf("VaultDirUnder=(%q,%q),want (%q,logseq)", dir, vt, sub)
|
||||
}
|
||||
// 正常落卡路徑(錨在監看根)不該命中。
|
||||
safe := filepath.Join(root, filepath.FromSlash(cardsRelDir), "arcrun-x.md")
|
||||
if dir, vt := VaultDirUnder(root, safe); vt != VaultNone {
|
||||
t.Fatalf("正常落卡路徑被誤擋:(%q,%q)", dir, vt)
|
||||
}
|
||||
}
|
||||
|
||||
// safeWriteCard 真的擋得住(機械閘,不是只有註解)。
|
||||
func TestSafeWriteCard_RefusesToWriteIntoSubVault(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sub := filepath.Join(root, "KB")
|
||||
makeLogseqVault(t, sub)
|
||||
|
||||
dest := filepath.Join(sub, "pages", "arcrun-偷渡.md")
|
||||
err := safeWriteCard(root, dest, []byte("# 偷渡"))
|
||||
if err == nil {
|
||||
t.Fatal("寫進子筆記庫竟然成功了——機械閘沒有作用")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "筆記庫") {
|
||||
t.Fatalf("錯誤訊息看不出原因:%v", err)
|
||||
}
|
||||
if _, serr := os.Stat(dest); serr == nil {
|
||||
t.Fatal("擋下來了卻還是留下了檔案")
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 足跡層:跑一輪真的同步,用**筆記庫**當觀測窗(不是監看根)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// runSyncOnce 跑一輪 direct 同步(全程假伺服器,不出網、不碰任何真實筆記庫)。
|
||||
func runSyncOnce(t *testing.T, watch string) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
defer gemmaEchoStub(t)()
|
||||
|
||||
cfg := &DirectConfig{
|
||||
WatchFolders: []string{watch},
|
||||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||||
Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
|
||||
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
results, exit, _ := RunDirectOnce(cfg, false)
|
||||
if exit != 0 {
|
||||
t.Fatalf("同步失敗 exit=%d results=%+v", exit, results)
|
||||
}
|
||||
}
|
||||
|
||||
// assertVaultUntouched 是本檔的共用判準,四條一起驗(缺一條這一票就還沒修好):
|
||||
//
|
||||
// ① 使用者原本的檔案,一個位元、一個 mtime 都不能變,也不能消失
|
||||
// ② 新出現的檔案,basename 一律帶 MachineMark(誰寫的,一眼看得出來)
|
||||
// ③ 新出現的檔案,一律落在隱藏路徑(`/.` 開頭的段)——筆記軟體不會收編成頁面
|
||||
// ④ 東西照樣要產出(沒產出=把功能關掉,那是紅線不是修好)
|
||||
func assertVaultUntouched(t *testing.T, vaultDir string, before, after map[string]footprintEntry) []string {
|
||||
t.Helper()
|
||||
|
||||
for rel, want := range before {
|
||||
got, still := after[rel]
|
||||
if !still {
|
||||
t.Fatalf("使用者的檔案不見了:%s", rel)
|
||||
}
|
||||
if got.hash != want.hash || got.size != want.size {
|
||||
t.Fatalf("使用者的檔案被改了:%s(size %d→%d)", rel, want.size, got.size)
|
||||
}
|
||||
if got.mtime != want.mtime {
|
||||
t.Fatalf("使用者的檔案 mtime 被動過:%s", rel)
|
||||
}
|
||||
}
|
||||
|
||||
var added []string
|
||||
for rel := range after {
|
||||
if _, existed := before[rel]; !existed {
|
||||
added = append(added, rel)
|
||||
}
|
||||
}
|
||||
sort.Strings(added)
|
||||
t.Logf("筆記庫裡新增的檔案(%d 個):%v", len(added), added)
|
||||
|
||||
for _, rel := range added {
|
||||
if !IsMarked(filepath.Base(rel)) {
|
||||
t.Fatalf("筆記庫裡多了一個沒帶 %q 標記的檔:%s", MachineMark, rel)
|
||||
}
|
||||
if !isHiddenRel(rel) {
|
||||
t.Fatalf("筆記庫裡多了一個**看得見**的機器產物:%s"+
|
||||
"(Logseq/Obsidian 會遞迴索引整個庫,這就是使用者看到的新頁面)", rel)
|
||||
}
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
// isHiddenRel:路徑上任何一段以 . 開頭=筆記軟體與 daemon 的 scan.go 都不會掃它。
|
||||
func isHiddenRel(rel string) bool {
|
||||
for _, seg := range strings.Split(filepath.ToSlash(rel), "/") {
|
||||
if strings.HasPrefix(seg, ".") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 🔴 主戲:庫在上面(監看根是 vault 的子資料夾)。Logseq 與 Obsidian 各驗一次。
|
||||
//
|
||||
// 修之前這裡會落下 `docs/system-dev/wiki/cards/arcrun-*.md`——在使用者的 graph 裡、
|
||||
// 而且看得見,Logseq 每一張都會變成一頁。
|
||||
func TestVaultFootprint_WatchRootInsideVault(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
make func(*testing.T, string)
|
||||
}{
|
||||
{"logseq", makeLogseqVault},
|
||||
{"obsidian", makeObsidianVault},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
vault := filepath.Join(base, "我的筆記庫")
|
||||
watch := filepath.Join(vault, "docs")
|
||||
mustMkdir(t, watch)
|
||||
tc.make(t, vault)
|
||||
|
||||
// 使用者自己的東西(含日期檔——那是最會撞名的一類)。
|
||||
writeFixture(t, vault, map[string]string{
|
||||
"journals/2026_08_12.md": "- 我自己寫的日記,不准被動到",
|
||||
"pages/讀書筆記.md": "- 我自己的頁面",
|
||||
// 🔴 同名情境(票上驗收條件③):故意在庫裡先擺一個「跟機器產物會撞名」的檔。
|
||||
// 機器萃 docs/會議記錄.md 會產出名為 `arcrun-會議記錄.md` 的卡,
|
||||
// 這裡先把那個名字佔走,看它會不會被覆蓋。
|
||||
"pages/arcrun-會議記錄.md": "- 這個檔名是我先取的,機器不准碰",
|
||||
"docs/會議記錄.md": "# 要被萃取的原稿",
|
||||
"docs/2026_08_12.md": "# 用日期當檔名的原稿",
|
||||
})
|
||||
|
||||
before := snapshotTree(t, vault)
|
||||
logTree(t, tc.name+"・跑之前(整個筆記庫)", before)
|
||||
runSyncOnce(t, watch)
|
||||
after := snapshotTree(t, vault)
|
||||
logTree(t, tc.name+"・跑之後(整個筆記庫)", after)
|
||||
|
||||
added := assertVaultUntouched(t, vault, before, after)
|
||||
if len(added) == 0 {
|
||||
t.Fatal("這一輪什麼都沒產出——紅線是「東西照樣產出、但不撞進使用者的命名空間」")
|
||||
}
|
||||
// 卡片確實落在 docs/.arcrun-rag/... 底下(在監看根裡,且是隱藏的)。
|
||||
wantPrefix := filepath.ToSlash(filepath.Join("docs", vaultCardsRelDir)) + "/"
|
||||
for _, rel := range added {
|
||||
if !strings.HasPrefix(rel, wantPrefix) {
|
||||
t.Fatalf("產物 %s 不在預期的隱藏卡片區 %s 底下", rel, wantPrefix)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 leo 派工單上的那條線索:庫在**下面**(監看根是普通資料夾,底下某一層是 vault)。
|
||||
//
|
||||
// 這一支的結論是「本來就沒破」——產物一律錨在監看根,不會落進子庫。
|
||||
// 但它必須永久留在測試裡:那個「本來就沒破」目前**沒有任何機制保證**,
|
||||
// 只要有人加一個「把卡寫在原稿旁邊」的寫檔點就會破,而那是很自然的想法。
|
||||
// 這支測試 + safeWriteCard 的機械閘是它的兩道保險。
|
||||
func TestVaultFootprint_SubVaultBelowWatchRoot(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
make func(*testing.T, string)
|
||||
}{
|
||||
{"logseq", makeLogseqVault},
|
||||
{"obsidian", makeObsidianVault},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
watch := t.TempDir()
|
||||
vault := filepath.Join(watch, "我的筆記庫")
|
||||
mustMkdir(t, vault)
|
||||
tc.make(t, vault)
|
||||
|
||||
writeFixture(t, watch, map[string]string{
|
||||
"我的筆記庫/journals/2026_08_12.md": "- 我自己寫的日記,不准被動到",
|
||||
"我的筆記庫/pages/讀書筆記.md": "- 我自己的頁面",
|
||||
// 同名情境:庫裡先擺一個會跟機器產物撞名的檔。
|
||||
"我的筆記庫/pages/arcrun-讀書筆記.md": "- 這個名字是我先取的",
|
||||
"我的筆記庫/會議記錄.md": "# 庫裡的原稿",
|
||||
"外面的報告.md": "# 監看根上的原稿",
|
||||
})
|
||||
|
||||
beforeVault := snapshotTree(t, vault)
|
||||
beforeAll := snapshotTree(t, watch)
|
||||
logTree(t, tc.name+"・跑之前(子筆記庫)", beforeVault)
|
||||
runSyncOnce(t, watch)
|
||||
afterVault := snapshotTree(t, vault)
|
||||
afterAll := snapshotTree(t, watch)
|
||||
logTree(t, tc.name+"・跑之後(子筆記庫)", afterVault)
|
||||
logTree(t, tc.name+"・跑之後(整個監看根)", afterAll)
|
||||
|
||||
// 子庫裡:一個檔都不准多、一個檔都不准變。
|
||||
for rel, want := range beforeVault {
|
||||
got, still := afterVault[rel]
|
||||
if !still {
|
||||
t.Fatalf("子筆記庫裡的檔案不見了:%s", rel)
|
||||
}
|
||||
if got.hash != want.hash || got.mtime != want.mtime {
|
||||
t.Fatalf("子筆記庫裡的檔案被動過:%s", rel)
|
||||
}
|
||||
}
|
||||
for rel := range afterVault {
|
||||
if _, existed := beforeVault[rel]; !existed {
|
||||
t.Fatalf("子筆記庫裡多出了機器產物:%s(Logseq/Obsidian 會把它當成一頁)", rel)
|
||||
}
|
||||
}
|
||||
|
||||
// 但東西照樣要產出——落在監看根上,不在子庫裡。
|
||||
produced := 0
|
||||
for rel := range afterAll {
|
||||
if _, existed := beforeAll[rel]; existed {
|
||||
continue
|
||||
}
|
||||
produced++
|
||||
if !IsMarked(filepath.Base(rel)) {
|
||||
t.Fatalf("監看根上多了一個沒帶標記的檔:%s", rel)
|
||||
}
|
||||
}
|
||||
if produced == 0 {
|
||||
t.Fatal("什麼都沒產出——那是把功能關掉,不是修好")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 回歸:監看根**本身**就是庫(前兩輪修好的那條路)不能被這次改動弄壞。
|
||||
func TestVaultFootprint_WatchRootIsVault_StillProtected(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
make func(*testing.T, string)
|
||||
}{
|
||||
{"logseq", makeLogseqVault},
|
||||
{"obsidian", makeObsidianVault},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
vault := t.TempDir()
|
||||
tc.make(t, vault)
|
||||
writeFixture(t, vault, map[string]string{
|
||||
"journals/2026_08_12.md": "- 我的日記",
|
||||
"會議記錄.md": "# 原稿",
|
||||
})
|
||||
before := snapshotTree(t, vault)
|
||||
runSyncOnce(t, vault)
|
||||
after := snapshotTree(t, vault)
|
||||
logTree(t, tc.name+"・監看根就是庫・跑之後", after)
|
||||
if added := assertVaultUntouched(t, vault, before, after); len(added) == 0 {
|
||||
t.Fatal("什麼都沒產出")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// writeFixture 依 rel→內容 造檔(rel 用斜線分隔)。
|
||||
func writeFixture(t *testing.T, root string, files map[string]string) {
|
||||
t.Helper()
|
||||
for rel, body := range files {
|
||||
p := filepath.Join(root, filepath.FromSlash(rel))
|
||||
mustMkdir(t, filepath.Dir(p))
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 收拾層:已經寫進去的那一批(票上「額外要處理的」)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// 舊版 daemon 在「vault 子資料夾」上留下的可見卡片,下一輪要能自己搬進隱藏目錄。
|
||||
// 使用者什麼都不必做——紅線:「不准要使用者去設定什麼開關才能保護自己的筆記」。
|
||||
func TestMigrateCardNames_RelocatesVisibleCardsInsideVault(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
vault := filepath.Join(base, "KB")
|
||||
watch := filepath.Join(vault, "docs")
|
||||
mustMkdir(t, watch)
|
||||
makeLogseqVault(t, vault)
|
||||
|
||||
// 舊版留下的兩種:帶標記但位置錯(第二輪的產物)、以及完全沒標記(第一輪的產物)。
|
||||
writeFixture(t, watch, map[string]string{
|
||||
"system-dev/wiki/cards/arcrun-會議記錄.md": "# 會議記錄",
|
||||
"system-dev/wiki/cards/2026_08_12.md": "# 2026_08_12",
|
||||
})
|
||||
|
||||
t.Logf("收拾前:%v", relList(t, watch))
|
||||
n := MigrateCardNames(watch)
|
||||
got := relList(t, watch)
|
||||
t.Logf("收拾後(搬了 %d 筆):%v", n, got)
|
||||
|
||||
if n != 2 {
|
||||
t.Fatalf("搬了 %d 筆,want 2", n)
|
||||
}
|
||||
for _, rel := range got {
|
||||
if !isHiddenRel(rel) {
|
||||
t.Fatalf("還有看得見的機器產物留在筆記庫裡:%s", rel)
|
||||
}
|
||||
if !IsMarked(filepath.Base(rel)) {
|
||||
t.Fatalf("還有沒帶標記的產物:%s", rel)
|
||||
}
|
||||
}
|
||||
|
||||
// 冪等:再跑一次不該有任何動作。
|
||||
if again := MigrateCardNames(watch); again != 0 {
|
||||
t.Fatalf("第二次跑又搬了 %d 筆,應為 0(冪等)", again)
|
||||
}
|
||||
}
|
||||
|
||||
// tidy 的預覽要說清楚是誰的庫,且**從不刪檔**、目標已存在就不覆蓋。
|
||||
func TestTidy_ReportsVaultRootForSubdirWatch(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
vault := filepath.Join(base, "KB")
|
||||
watch := filepath.Join(vault, "docs")
|
||||
mustMkdir(t, watch)
|
||||
makeLogseqVault(t, vault)
|
||||
writeFixture(t, watch, map[string]string{
|
||||
"system-dev/wiki/cards/arcrun-會議記錄.md": "# 會議記錄",
|
||||
})
|
||||
|
||||
rep, err := Tidy(watch, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep.VaultType != VaultLogseq || rep.VaultRoot != vault {
|
||||
t.Fatalf("報告沒認出上層筆記庫:VaultType=%q VaultRoot=%q(want logseq / %s)",
|
||||
rep.VaultType, rep.VaultRoot, vault)
|
||||
}
|
||||
if len(rep.Items) != 1 || rep.Items[0].Action != TidyActionWillMove {
|
||||
t.Fatalf("預覽內容不對:%+v", rep.Items)
|
||||
}
|
||||
t.Logf("tidy 預覽:%+v", rep.Items)
|
||||
}
|
||||
|
||||
func relList(t *testing.T, root string) []string {
|
||||
t.Helper()
|
||||
var out []string
|
||||
for rel := range snapshotTree(t, root) {
|
||||
out = append(out, rel)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
+80
-3
@@ -1,5 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# verify-60.sh — arcrun-rag#60 第二輪的五項驗收,一次跑完並把實測輸出印出來。
|
||||
# verify-60.sh — arcrun-rag#60 的驗收,一次跑完並把實測輸出印出來。
|
||||
#
|
||||
# 第三輪(2026-08-12)加了第 ⑤ 段:**監看根是筆記庫底下的子資料夾**。
|
||||
# 前兩輪的 fixture 一律是「監看根 == 庫根」,而那正好是唯一不會出事的擺法。
|
||||
#
|
||||
# 用法(在 repo 任何位置都可以):
|
||||
# bash collector/verify-60.sh 2>&1 | tee /tmp/verify-60.log
|
||||
@@ -29,8 +32,9 @@ echo "編譯:通過"
|
||||
hr "② 既有 Go 測試全綠(驗收條件⑤)"
|
||||
go test ./... || FAIL=1
|
||||
|
||||
hr "③ 足跡測試:真的有既有日誌檔的 vault,跑之前/跑之後的完整檔案清單(驗收條件①②④)"
|
||||
go test . -run 'Footprint' -v || FAIL=1
|
||||
hr "③ 足跡測試:跑之前/跑之後的完整檔案清單(驗收條件①②④)"
|
||||
# 第三輪起觀測窗是**筆記庫**而不是監看根——見 vault_subdir_test.go 開頭。
|
||||
go test . -run 'Footprint|VaultContext|VaultDirUnder|MigrateCardNames' -v || FAIL=1
|
||||
|
||||
hr "④ 舊產物收拾:在一個「已經被舊版 daemon 弄髒」的真 vault 上實跑(驗收條件③)"
|
||||
VAULT="$(mktemp -d)"
|
||||
@@ -113,6 +117,79 @@ echo
|
||||
echo "── 檢查四:冪等(再跑一次不該有任何動作) ──"
|
||||
go run ./cmd/collector tidy --folder "$VAULT" --apply || FAIL=1
|
||||
|
||||
hr "⑤ 第三輪:監看根是**筆記庫底下的子資料夾**(Logseq 與 Obsidian 各一組)"
|
||||
# 這一段用真的 CLI 在真的暫存筆記庫上跑,證明:
|
||||
# · 使用者在庫裡的檔案(journals/pages)一個都沒被動到
|
||||
# · 舊版留在看得見位置的機器產物被搬進隱藏目錄,庫裡不再有機器頁面
|
||||
# · 故意擺的同名檔(跟機器產物撞名的那個)原封不動
|
||||
subdir_case() {
|
||||
local kind="$1"
|
||||
local BASE; BASE="$(mktemp -d)"
|
||||
local V="$BASE/我的筆記庫"
|
||||
local W="$V/docs"
|
||||
mkdir -p "$W" "$V/journals" "$V/pages"
|
||||
|
||||
if [ "$kind" = "logseq" ]; then
|
||||
mkdir -p "$V/logseq"; echo '{:default-home {:page "contents"}}' > "$V/logseq/config.edn"
|
||||
else
|
||||
mkdir -p "$V/.obsidian"; echo '{}' > "$V/.obsidian/app.json"
|
||||
fi
|
||||
|
||||
# 使用者自己的東西,含一個**故意跟機器產物撞名**的檔
|
||||
echo '- 我自己寫的日記' > "$V/journals/2026_08_12.md"
|
||||
echo '- 我自己的讀書筆記' > "$V/pages/讀書筆記.md"
|
||||
echo '- 這個名字是我先取的' > "$V/pages/arcrun-會議記錄.md"
|
||||
# 舊版 daemon 留在**看得見**位置的機器產物(就在使用者的庫裡)
|
||||
mkdir -p "$W/system-dev/wiki/cards"
|
||||
echo '# 會議記錄' > "$W/system-dev/wiki/cards/arcrun-會議記錄.md"
|
||||
echo '# 2026_08_12' > "$W/system-dev/wiki/cards/2026_08_12.md"
|
||||
|
||||
echo "── [$kind] 收拾之前(整個筆記庫)──"
|
||||
(cd "$V" && find . -type f | sort)
|
||||
local MINE_BEFORE; MINE_BEFORE="$(find "$V/journals" "$V/pages" -type f -exec shasum {} \; | sed "s|$V/||" | sort)"
|
||||
|
||||
echo
|
||||
echo "── [$kind] collector tidy --folder <庫>/docs(先預覽)──"
|
||||
go run ./cmd/collector tidy --folder "$W" || FAIL=1
|
||||
echo
|
||||
echo "── [$kind] collector tidy --folder <庫>/docs --apply ──"
|
||||
go run ./cmd/collector tidy --folder "$W" --apply || FAIL=1
|
||||
|
||||
echo
|
||||
echo "── [$kind] 收拾之後(整個筆記庫)──"
|
||||
(cd "$V" && find . -type f | sort)
|
||||
|
||||
echo
|
||||
local MINE_AFTER; MINE_AFTER="$(find "$V/journals" "$V/pages" -type f -exec shasum {} \; | sed "s|$V/||" | sort)"
|
||||
if [ "$MINE_BEFORE" = "$MINE_AFTER" ]; then
|
||||
echo "✅ [$kind] 使用者在庫裡的檔案(含同名的 arcrun-會議記錄.md)內容與路徑完全未變"
|
||||
else
|
||||
echo "❌ [$kind] 使用者的檔案被動過:"; diff <(echo "$MINE_BEFORE") <(echo "$MINE_AFTER"); FAIL=1
|
||||
fi
|
||||
|
||||
# 庫裡不准再有「看得見的」機器產物:路徑中沒有任何 . 開頭的段、卻帶 arcrun- 前綴的檔
|
||||
local VISIBLE=0
|
||||
while IFS= read -r f; do
|
||||
case "$f" in */.*) continue;; esac # 隱藏路徑,筆記軟體不掃
|
||||
case "$(basename "$f")" in
|
||||
arcrun-會議記錄.md) [ "$f" = "./pages/arcrun-會議記錄.md" ] && continue;; # 這是使用者自己的
|
||||
esac
|
||||
case "$(basename "$f")" in
|
||||
arcrun-*) echo " ❌ [$kind] 庫裡還有看得見的機器產物:$f"; VISIBLE=1;;
|
||||
esac
|
||||
done < <(cd "$V" && find . -type f | sort)
|
||||
if [ "$VISIBLE" -eq 0 ]; then
|
||||
echo "✅ [$kind] 庫裡沒有任何看得見的機器產物(筆記軟體不會多出頁面)"
|
||||
else
|
||||
FAIL=1
|
||||
fi
|
||||
rm -rf "$BASE"
|
||||
}
|
||||
|
||||
subdir_case logseq
|
||||
echo
|
||||
subdir_case obsidian
|
||||
|
||||
hr "結果"
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
echo "✅ 全部通過"
|
||||
|
||||
Reference in New Issue
Block a user