fix(collector): 機器寫進筆記庫的檔案一律帶前綴,不再跟使用者的頁面撞名(arcrun-rag#60)
leo 2026-08-12:「我的 Logseq 又被覆蓋⋯⋯**不只是加上 journal,可能所有的檔案都加一個前後綴,比如「wiki」**。」 不是資料被蓋掉,是機器產出用了跟他一樣的命名空間(status.md、日期檔那些) ⇒ 他打開資料夾分不出哪些是自己的。**心理上的覆蓋跟實際覆蓋一樣糟。**
This commit is contained in:
@@ -946,6 +946,18 @@ 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- 標記的舊卡就地改名。
|
||||
// 自動跑而不是叫人下指令——leo 的紅線:「他不該為了保護自己的筆記去學新選項」。
|
||||
// 只碰卡片產物區(那整個目錄只有我們會寫),目標已存在就跳過、永不刪檔,見 tidy.go。
|
||||
// template 殘留不在這裡處理:那要人確認「這是不是你自己的 repo」,走 `collector tidy`。
|
||||
if !dryRun {
|
||||
if n := MigrateCardNames(absRoot); n > 0 {
|
||||
results = append(results, DirectResult{
|
||||
Type: "warning", Path: absRoot, Status: "skipped",
|
||||
Error: fmt.Sprintf("已把 %d 張舊卡片改名加上 arcrun- 前綴(避免與你自己的筆記同名)", n),
|
||||
})
|
||||
}
|
||||
}
|
||||
m, err := LoadManifest(absManifest, absRoot)
|
||||
if err != nil {
|
||||
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil, rootProgress{}
|
||||
@@ -1125,8 +1137,14 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
// path 帶「原檔路徑」不是卡片路徑(07-24 真機第五枚坑):
|
||||
// source_uri=kb://<path> 是 takedown 的比對鍵,也是 B4 溯源該指的原文——
|
||||
// 帶卡片路徑會讓「刪原檔→下架」永遠 0 命中。
|
||||
// 🔴 arcrun-rag#60 第二輪:page_name 必須跟著**原稿**走,不是跟著卡片檔名走。
|
||||
// 卡片檔名這一輪加了 `arcrun-` 前綴(machinemark.go),若這裡繼續用
|
||||
// pageNameOf(cardRel),雲端頁名會變成 `arcrun-<原頁名>`,而下架分支用的是
|
||||
// pageNameOf(ev.Path)(原稿頁名,不帶前綴)⇒ 兩邊從此對不上,
|
||||
// 「刪原檔→下架」永遠 0 命中,跟 07-24 那枚 source_uri 的坑同一個形狀。
|
||||
// 改成原稿頁名後,**雲端看到的頁名與改版前完全相同**(本次只動本機檔名)。
|
||||
cardBody := map[string]any{
|
||||
"page_name": pageNameOf(cardRel),
|
||||
"page_name": pageNameOf(ev.Path),
|
||||
"path": ev.Path,
|
||||
"card_content": string(cardData),
|
||||
"library": cfg.libraryFor(absRoot),
|
||||
@@ -1212,13 +1230,13 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
// (不是 Scan() rebuild 時就拿掉——那時只是「偵測到不見了」,不是「已下架」)。
|
||||
delete(m.Entries, ev.Path)
|
||||
saveManifest()
|
||||
// t15:extractor 模式雲端下架成功後,同步清掉本地萃出的卡
|
||||
//(system-dev/wiki/cards/<頁名>.md 或 vault 時的 .arcrun-rag/wiki/cards/<頁名>.md,
|
||||
// arcrun-rag#60:清除路徑要跟落卡路徑用同一個 cardsRelDirFor,否則 vault 目標
|
||||
// 清不到卡、留孤兒檔案),保持本地 wiki 與雲端一致。
|
||||
// t15:extractor 模式雲端下架成功後,同步清掉本地萃出的卡,保持本地與雲端一致。
|
||||
// arcrun-rag#60:清除路徑必須跟落卡路徑**同一個函式**算出來(cardRelFor)——
|
||||
// 目錄或檔名任一邊不同步就清不到卡、留下孤兒檔。第一輪對齊了目錄,
|
||||
// 第二輪加了檔名前綴,所以連「拼檔名」這件事也一起收進 cardRelFor。
|
||||
// 存在才刪;刪失敗只記 warning 不擋(下架本體已成功)。
|
||||
if cfg.Extractor != "" {
|
||||
cardAbs := filepath.Join(absRoot, filepath.FromSlash(cardsRelDirFor(absRoot)), pageNameOf(ev.Path)+".md")
|
||||
cardAbs := filepath.Join(absRoot, filepath.FromSlash(cardRelFor(absRoot, pageNameOf(ev.Path))))
|
||||
if _, serr := os.Stat(cardAbs); serr == nil {
|
||||
if rerr := os.Remove(cardAbs); rerr != nil {
|
||||
results = append(results, DirectResult{
|
||||
|
||||
@@ -88,7 +88,7 @@ func TestDirectExtractorModeE2E(t *testing.T) {
|
||||
t.Fatalf("results=%+v", results)
|
||||
}
|
||||
// 卡片落地本地(用戶看得到自己的 wiki)
|
||||
if _, err := os.Stat(filepath.Join(root, "system-dev", "wiki", "cards", "報銷規則.md")); err != nil {
|
||||
if _, err := os.Stat(filepath.Join(root, "system-dev", "wiki", "cards", "arcrun-報銷規則.md")); err != nil {
|
||||
t.Fatalf("卡片未落地:%v", err)
|
||||
}
|
||||
// 上雲的是卡片、不是原文
|
||||
@@ -106,6 +106,12 @@ func TestDirectExtractorModeE2E(t *testing.T) {
|
||||
if p, _ := posted[0]["path"].(string); p != "報銷規則.md" {
|
||||
t.Fatalf("path=%q(應為原檔路徑)", p)
|
||||
}
|
||||
// 🔴 arcrun-rag#60 第二輪:本機卡片檔名加了 arcrun- 前綴,但**上雲的 page_name 不准跟著變**。
|
||||
// 下架分支用的是原稿頁名(見下一支測試斷言 takedown page_name=="報銷規則"),
|
||||
// 這裡若跟著卡片檔名變成 "arcrun-報銷規則",兩邊就永遠對不上、刪原檔再也下架不掉。
|
||||
if pn, _ := posted[0]["page_name"].(string); pn != "報銷規則" {
|
||||
t.Fatalf("page_name=%q(應為原稿頁名,不含 arcrun- 前綴,否則下架對不上)", pn)
|
||||
}
|
||||
// 第二輪:原稿沒變 → 不重萃不重送
|
||||
results2, exit2, _ := RunDirectOnce(cfg, false)
|
||||
if exit2 != 0 || len(results2) != 0 || len(posted) != 1 {
|
||||
@@ -151,7 +157,7 @@ func TestDirectExtractorRemovedClearsLocalCard(t *testing.T) {
|
||||
if _, exit, _ := RunDirectOnce(cfg, false); exit != 0 {
|
||||
t.Fatalf("第一輪 ingest 失敗 exit=%d", exit)
|
||||
}
|
||||
cardPath := filepath.Join(root, "system-dev", "wiki", "cards", "報銷規則.md")
|
||||
cardPath := filepath.Join(root, "system-dev", "wiki", "cards", "arcrun-報銷規則.md")
|
||||
if _, err := os.Stat(cardPath); err != nil {
|
||||
t.Fatalf("前置失敗:卡片未落地 %v", err)
|
||||
}
|
||||
@@ -201,7 +207,7 @@ func TestDirectExtractorRemovedNoLocalCardOK(t *testing.T) {
|
||||
t.Fatal("第一輪失敗")
|
||||
}
|
||||
// 模擬用戶已手動清走本地卡 → removed 分支「存在才刪」不應報錯或多出 warning
|
||||
if err := os.Remove(filepath.Join(root, "system-dev", "wiki", "cards", "a.md")); err != nil {
|
||||
if err := os.Remove(filepath.Join(root, "system-dev", "wiki", "cards", "arcrun-a.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(root, "a.md")); err != nil {
|
||||
|
||||
+85
-14
@@ -81,21 +81,47 @@ func cardsRelDirFor(absRoot string) string {
|
||||
return cardsRelDir
|
||||
}
|
||||
|
||||
// snapshotCards 記 cards/ 目前每檔 mtime+size(偵測萃取後的新卡/變卡)。
|
||||
// cardRelFor 回傳「這份原稿萃出來的卡片」相對監看根的完整路徑。
|
||||
//
|
||||
// 🔴 arcrun-rag#60 第二輪:**daemon 在監看根底下產生的檔名一律經過這裡**,
|
||||
// 誰都不准自己拼 `pageName + ".md"`。目前三個呼叫端必須拿到同一個答案,
|
||||
// 少一個對齊就會留下孤兒檔或清不掉的卡:
|
||||
//
|
||||
// ① 落卡(extract_gemma.go/extract_workersai.go)
|
||||
// ② 下架時清本地卡(direct.go 的 removed 分支)
|
||||
// ③ 萃取前後的快照比對(snapshotCards,claude 路用)
|
||||
//
|
||||
// 檔名帶 MachineMark 前綴=Logseq/Obsidian 的頁名變成 `arcrun-<原頁名>`,
|
||||
// 與使用者自己的頁面**永不同名**(上一輪漏的正是這一層,見 machinemark.go 開頭)。
|
||||
func cardRelFor(absRoot, pageName string) string {
|
||||
return filepath.ToSlash(filepath.Join(cardsRelDirFor(absRoot), MarkName(pageName+".md")))
|
||||
}
|
||||
|
||||
// snapshotCards 記卡片產物區目前每檔 mtime+size(偵測萃取後的新卡/變卡)。
|
||||
//
|
||||
// 🔴 這裡原本硬寫 cardsRelDir 一個目錄,兩邊都不對:
|
||||
// - vault 上,落卡其實去了 .arcrun-rag/wiki/cards/ ⇒ snapshot 到一個永遠是空的目錄,
|
||||
// diffCards 算不到新卡,claude 路必定誤報「跑完但沒有新卡片」。
|
||||
// - 而 claude 路的**寫檔位置是 skill(prompt)決定的**,它硬寫 system-dev/wiki/cards/,
|
||||
// 連在 vault 上也一樣 ⇒ 只看 cardsRelDirFor 那一個目錄同樣會漏。
|
||||
//
|
||||
// ⇒ 兩個目錄都掃。誰寫到哪裡都逃不掉,之後由 enforceCardMarks 統一歸位+補標記。
|
||||
func snapshotCards(absRoot string) map[string]fileState {
|
||||
out := map[string]fileState{}
|
||||
base := filepath.Join(absRoot, filepath.FromSlash(cardsRelDir))
|
||||
_ = filepath.Walk(base, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || filepath.Ext(p) != ".md" {
|
||||
for _, relDir := range []string{cardsRelDir, vaultCardsRelDir} {
|
||||
base := filepath.Join(absRoot, filepath.FromSlash(relDir))
|
||||
_ = filepath.Walk(base, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || filepath.Ext(p) != ".md" {
|
||||
return nil
|
||||
}
|
||||
rel, rerr := filepath.Rel(absRoot, p)
|
||||
if rerr != nil {
|
||||
return nil
|
||||
}
|
||||
out[filepath.ToSlash(rel)] = fileState{size: info.Size(), mtime: info.ModTime().UnixNano()}
|
||||
return nil
|
||||
}
|
||||
rel, rerr := filepath.Rel(absRoot, p)
|
||||
if rerr != nil {
|
||||
return nil
|
||||
}
|
||||
out[filepath.ToSlash(rel)] = fileState{size: info.Size(), mtime: info.ModTime().UnixNano()}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -144,7 +170,52 @@ func ExtractWithClaude(binPath, absRoot, relPath string) ([]string, error) {
|
||||
|
||||
cards := diffCards(before, snapshotCards(absRoot))
|
||||
if len(cards) == 0 {
|
||||
return nil, fmt.Errorf("claude 跑完但 %s 沒有新卡片(輸出尾段:%.200s)", cardsRelDir, string(out))
|
||||
return nil, fmt.Errorf("claude 跑完但 %s 沒有新卡片(輸出尾段:%.200s)", cardsRelDirFor(absRoot), string(out))
|
||||
}
|
||||
return cards, nil
|
||||
return enforceCardMarks(absRoot, cards), nil
|
||||
}
|
||||
|
||||
// enforceCardMarks 把「不是我們親手命名」的卡片**歸位並補上標記**。
|
||||
//
|
||||
// 🔴 為什麼這條路需要它:claude 路的檔名與寫入目錄都是**skill 檔(prompt)決定的**,
|
||||
// 不是 Go 決定的(/rag-extract-file 自己寫 `system-dev/wiki/cards/<頁名>.md`,
|
||||
// 而且不認得 vault)。若放著不管,同一個 daemon 就會出現「gemma 路的卡有前綴、
|
||||
// claude 路的卡沒有」,而且在 vault 上還會落在**可見目錄**——正好踩中
|
||||
// 「一部分加一部分不加,比全不加更難分」那條紅線,且破口在 prompt 裡,改 Go 永遠修不到。
|
||||
//
|
||||
// ⇒ **命名與落點的契約收回 Go 這一側強制執行**:不管上游寫成什麼、寫到哪,
|
||||
// 離開這個函式時一定是 cardRelFor 算出來的那個位置與名字。
|
||||
// 目標已被佔用或搬不動時,照實回報原路徑(不覆蓋、不假裝成功),剩下的交給 tidy。
|
||||
func enforceCardMarks(absRoot string, cards []string) []string {
|
||||
want := cardsRelDirFor(absRoot)
|
||||
out := make([]string, 0, len(cards))
|
||||
for _, rel := range cards {
|
||||
base := filepath.Base(rel)
|
||||
dir := filepath.ToSlash(filepath.Dir(rel))
|
||||
if IsMarked(base) && dir == want {
|
||||
out = append(out, rel)
|
||||
continue
|
||||
}
|
||||
newRel := cardRelFor(absRoot, UnmarkName(strings.TrimSuffix(base, ".md")))
|
||||
if newRel == rel {
|
||||
out = append(out, rel)
|
||||
continue
|
||||
}
|
||||
from := filepath.Join(absRoot, filepath.FromSlash(rel))
|
||||
to := filepath.Join(absRoot, filepath.FromSlash(newRel))
|
||||
if _, err := os.Stat(to); err == nil {
|
||||
out = append(out, rel) // 新位置已被佔用,不覆蓋
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil {
|
||||
out = append(out, rel)
|
||||
continue
|
||||
}
|
||||
if err := os.Rename(from, to); err != nil {
|
||||
out = append(out, rel) // 搬不動就照實回報原路徑,不假裝成功
|
||||
continue
|
||||
}
|
||||
out = append(out, newRel)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
+4
-3
@@ -124,9 +124,10 @@ func ExtractWithGemma(apiKey, model, absRoot, relPath string) ([]string, error)
|
||||
if !strings.HasPrefix(card, "# ") {
|
||||
return nil, fmt.Errorf("萃出內容不像卡片(未以 # 開頭):%.120s", card)
|
||||
}
|
||||
// arcrun-rag#60:非 vault 落 system-dev/wiki/cards/、vault 改落隱藏目錄,避免污染筆記軟體的頁面清單;
|
||||
// 落地前先查目標存不存在、不無條件覆蓋(safeWriteCard),兩條都是同一票的紅線。
|
||||
cardRel := filepath.ToSlash(filepath.Join(cardsRelDirFor(absRoot), pageName+".md"))
|
||||
// arcrun-rag#60:路徑與檔名都由 cardRelFor 決定——非 vault 落 system-dev/wiki/cards/、
|
||||
// vault 落隱藏目錄,且**檔名一律帶 arcrun- 前綴**(第二輪:光換目錄擋不住撞名,
|
||||
// 因為 Logseq 的頁名是 basename)。落地前先查目標存不存在、不無條件覆蓋(safeWriteCard)。
|
||||
cardRel := cardRelFor(absRoot, pageName)
|
||||
dest := filepath.Join(absRoot, filepath.FromSlash(cardRel))
|
||||
if err := safeWriteCard(dest, []byte(card)); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -48,10 +48,10 @@ func TestExtractWithGemmaThinkingModel(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cards) != 1 || cards[0] != "system-dev/wiki/cards/會議記錄.md" {
|
||||
if len(cards) != 1 || cards[0] != "system-dev/wiki/cards/arcrun-會議記錄.md" {
|
||||
t.Fatalf("cards=%v", cards)
|
||||
}
|
||||
data, _ := os.ReadFile(filepath.Join(root, "system-dev", "wiki", "cards", "會議記錄.md"))
|
||||
data, _ := os.ReadFile(filepath.Join(root, "system-dev", "wiki", "cards", "arcrun-會議記錄.md"))
|
||||
if !strings.HasPrefix(string(data), "# 會議記錄") {
|
||||
t.Fatalf("卡片未淨化(應從最後的 # 頁名 起):%.80s", string(data))
|
||||
}
|
||||
@@ -122,8 +122,8 @@ func TestExtractWithGemma_VaultDoesNotGainPages(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cards) != 1 || cards[0] != ".arcrun-rag/wiki/cards/會議記錄.md" {
|
||||
t.Fatalf("vault 目標的卡片路徑不對:%v,want [.arcrun-rag/wiki/cards/會議記錄.md]", cards)
|
||||
if len(cards) != 1 || cards[0] != ".arcrun-rag/wiki/cards/arcrun-會議記錄.md" {
|
||||
t.Fatalf("vault 目標的卡片路徑不對:%v,want [.arcrun-rag/wiki/cards/arcrun-會議記錄.md]", cards)
|
||||
}
|
||||
|
||||
pagesAfter := countMD(t, filepath.Join(root, "pages")) + countMD(t, filepath.Join(root, "journals")) + countTopLevelMD(t, root)
|
||||
@@ -141,7 +141,7 @@ func TestExtractWithGemma_VaultDoesNotGainPages(t *testing.T) {
|
||||
}
|
||||
|
||||
// 卡片確實落在隱藏目錄,且是「監看根底下」(呼叫端 absRoot-relative 假設仍成立)。
|
||||
cardAbs := filepath.Join(root, ".arcrun-rag", "wiki", "cards", "會議記錄.md")
|
||||
cardAbs := filepath.Join(root, ".arcrun-rag", "wiki", "cards", "arcrun-會議記錄.md")
|
||||
if _, err := os.Stat(cardAbs); err != nil {
|
||||
t.Fatalf("卡片沒有落在預期的隱藏目錄:%v", err)
|
||||
}
|
||||
@@ -158,7 +158,7 @@ func TestExtractWithGemma_VaultExistingCardNotClobbered(t *testing.T) {
|
||||
cardDir := filepath.Join(root, ".arcrun-rag", "wiki", "cards")
|
||||
mustMkdir(t, cardDir)
|
||||
preexisting := "# x\n這份是先前就存在的內容"
|
||||
cardPath := filepath.Join(cardDir, "x.md")
|
||||
cardPath := filepath.Join(cardDir, "arcrun-x.md")
|
||||
if err := os.WriteFile(cardPath, []byte(preexisting), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -183,7 +183,7 @@ func TestExtractWithGemma_VaultExistingCardNotClobbered(t *testing.T) {
|
||||
}
|
||||
var foundBackup bool
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), "x.md.bak-") {
|
||||
if strings.HasPrefix(e.Name(), "arcrun-x.md.bak-") {
|
||||
foundBackup = true
|
||||
data, _ := os.ReadFile(filepath.Join(cardDir, e.Name()))
|
||||
if string(data) != preexisting {
|
||||
|
||||
+38
-2
@@ -39,8 +39,44 @@ printf '# 我的筆記\n\n## 摘要\nstub 卡\n' > "system-dev/wiki/cards/我的
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cards) != 1 || cards[0] != "system-dev/wiki/cards/我的筆記.md" {
|
||||
t.Fatalf("cards=%v", cards)
|
||||
// stub(模擬 /rag-extract-file skill)寫的是**沒有前綴**的檔名——這正是實況:
|
||||
// 那條路的檔名由 prompt 決定,不歸 Go 管。enforceCardMarks 必須把它補正,
|
||||
// 讓 claude 路與 gemma/workers-ai 路交出同一種名字(arcrun-rag#60 紅線:只准一種)。
|
||||
if len(cards) != 1 || cards[0] != "system-dev/wiki/cards/arcrun-我的筆記.md" {
|
||||
t.Fatalf("cards=%v,want [system-dev/wiki/cards/arcrun-我的筆記.md]", cards)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "system-dev", "wiki", "cards", "arcrun-我的筆記.md")); err != nil {
|
||||
t.Fatalf("補正後的卡片檔不存在:%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "system-dev", "wiki", "cards", "我的筆記.md")); err == nil {
|
||||
t.Fatal("沒帶前綴的原檔還在——補正應該是改名,不是複製一份")
|
||||
}
|
||||
}
|
||||
|
||||
// arcrun-rag#60 第二輪:vault 上,skill 仍會把卡寫進**可見的** system-dev/wiki/cards/
|
||||
// (那個位置寫死在 prompt 裡,Go 改不到它)⇒ Go 這一側必須把它歸位到隱藏目錄並補標記。
|
||||
// 這是「破口在 prompt 裡、只能在 Go 收口」的那一類,不補就等於 claude 路照樣污染筆記庫。
|
||||
func TestExtractWithClaude_VaultRelocatesAndMarks(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustMkdir(t, filepath.Join(root, "logseq")) // 這是個 Logseq vault
|
||||
stub := writeStubClaude(t, t.TempDir(), `
|
||||
mkdir -p system-dev/wiki/cards
|
||||
printf '# 我的筆記\n\n## 摘要\nstub 卡\n' > "system-dev/wiki/cards/我的筆記.md"
|
||||
`)
|
||||
cards, err := ExtractWithClaude(stub, root, "我的筆記.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := ".arcrun-rag/wiki/cards/arcrun-我的筆記.md"
|
||||
if len(cards) != 1 || cards[0] != want {
|
||||
t.Fatalf("cards=%v,want [%s]", cards, want)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(want))); err != nil {
|
||||
t.Fatalf("歸位後的卡片不存在:%v", err)
|
||||
}
|
||||
// 可見目錄裡不准留下任何東西——vault 的頁面數不能因為 daemon 跑過而增加。
|
||||
if _, err := os.Stat(filepath.Join(root, "system-dev", "wiki", "cards", "我的筆記.md")); err == nil {
|
||||
t.Fatal("卡片還留在可見的 system-dev/wiki/cards/,vault 會多出一頁")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -121,9 +121,10 @@ func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string) ([]string,
|
||||
if !strings.HasPrefix(card, "# ") {
|
||||
return nil, fmt.Errorf("萃出內容不像卡片(未以 # 開頭):%.120s", card)
|
||||
}
|
||||
// arcrun-rag#60:非 vault 落 system-dev/wiki/cards/、vault 改落隱藏目錄,避免污染筆記軟體的頁面清單;
|
||||
// 落地前先查目標存不存在、不無條件覆蓋(safeWriteCard),兩條都是同一票的紅線。
|
||||
cardRel := filepath.ToSlash(filepath.Join(cardsRelDirFor(absRoot), pageName+".md"))
|
||||
// arcrun-rag#60:路徑與檔名都由 cardRelFor 決定——非 vault 落 system-dev/wiki/cards/、
|
||||
// vault 落隱藏目錄,且**檔名一律帶 arcrun- 前綴**(第二輪:光換目錄擋不住撞名,
|
||||
// 因為 Logseq 的頁名是 basename)。落地前先查目標存不存在、不無條件覆蓋(safeWriteCard)。
|
||||
cardRel := cardRelFor(absRoot, pageName)
|
||||
dest := filepath.Join(absRoot, filepath.FromSlash(cardRel))
|
||||
if err := safeWriteCard(dest, []byte(card)); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -26,10 +26,11 @@ func TestExtractWithWorkersAI_VaultRedirectsAndDoesNotClobber(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 隱藏卡片目錄裡先放一份既有內容,驗證不會被無聲蓋掉。
|
||||
// 檔名帶 arcrun- 前綴=第二輪之後卡片真正的名字(machinemark.go)。
|
||||
cardDir := filepath.Join(root, ".arcrun-rag", "wiki", "cards")
|
||||
mustMkdir(t, cardDir)
|
||||
preexisting := "# note\n既有內容"
|
||||
if err := os.WriteFile(filepath.Join(cardDir, "note.md"), []byte(preexisting), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(cardDir, "arcrun-note.md"), []byte(preexisting), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -48,8 +49,8 @@ func TestExtractWithWorkersAI_VaultRedirectsAndDoesNotClobber(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cards) != 1 || cards[0] != ".arcrun-rag/wiki/cards/note.md" {
|
||||
t.Fatalf("vault 目標的卡片路徑不對:%v", cards)
|
||||
if len(cards) != 1 || cards[0] != ".arcrun-rag/wiki/cards/arcrun-note.md" {
|
||||
t.Fatalf("vault 目標的卡片路徑不對:%v,want [.arcrun-rag/wiki/cards/arcrun-note.md]", cards)
|
||||
}
|
||||
|
||||
// pages/.obsidian 之外沒有新增任何非隱藏 .md(Obsidian 不掃描 .arcrun-rag/)。
|
||||
@@ -65,7 +66,7 @@ func TestExtractWithWorkersAI_VaultRedirectsAndDoesNotClobber(t *testing.T) {
|
||||
}
|
||||
var foundBackup bool
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), "note.md.bak-") {
|
||||
if strings.HasPrefix(e.Name(), "arcrun-note.md.bak-") {
|
||||
foundBackup = true
|
||||
}
|
||||
}
|
||||
@@ -74,7 +75,11 @@ func TestExtractWithWorkersAI_VaultRedirectsAndDoesNotClobber(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 非 vault:既有行為不變(回歸)——卡片仍落 system-dev/wiki/cards/。
|
||||
// 非 vault:卡片仍落 system-dev/wiki/cards/(目錄不變),但檔名同樣帶標記。
|
||||
//
|
||||
// 🔴 第二輪刻意讓「vault 與非 vault 用同一條命名規則」:紅線是「前綴只准一種、
|
||||
// 不要一部分加一部分不加」。若只在 vault 加前綴,一般資料夾的使用者照樣分不出
|
||||
// 哪些檔是機器寫的——而 system-dev/wiki/cards/ 在他的檔案總管裡是**看得見**的。
|
||||
func TestExtractWithWorkersAI_NonVaultUnchanged(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
srcRel := "note.md"
|
||||
@@ -93,7 +98,7 @@ func TestExtractWithWorkersAI_NonVaultUnchanged(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cards) != 1 || cards[0] != "system-dev/wiki/cards/note.md" {
|
||||
t.Fatalf("非 vault 行為變了:%v,want [system-dev/wiki/cards/note.md]", cards)
|
||||
if len(cards) != 1 || cards[0] != "system-dev/wiki/cards/arcrun-note.md" {
|
||||
t.Fatalf("非 vault 卡片路徑不對:%v,want [system-dev/wiki/cards/arcrun-note.md]", cards)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// machinemark.go — daemon 寫進使用者資料夾的每一個檔案,檔名都帶同一個標記
|
||||
// (arcrun-rag#60 第二輪,2026-08-12 leo 實撞)。
|
||||
//
|
||||
// 🔴 為什麼上一輪不夠:
|
||||
//
|
||||
// 第一輪(1e36bb1)只改了「卡片**落在哪個目錄**」——vault 就改落隱藏的
|
||||
// `.arcrun-rag/wiki/cards/`。但 Logseq/Obsidian 的**頁名是從 basename 來的**,
|
||||
// 而卡片的 basename 一直是「原稿的 basename」:使用者的 `journals/2026_08_10.md`
|
||||
// 萃出來的卡就叫 `2026_08_10.md`,跟他自己的日誌頁**同名**。
|
||||
// ⇒ 只要那個目錄有任何一刻被看見(他自己翻資料夾、備份工具攤平、之後改成非隱藏、
|
||||
// 或是**非 vault 的一般資料夾**——那裡卡片本來就是可見的),撞名就回來了。
|
||||
// leo 原話:「不只是加上 journal,**可能所有的檔案都加一個前後綴,比如「wiki」**。」
|
||||
//
|
||||
// 🔴 規約(本檔是唯一真相源,不准有第二種標記):
|
||||
//
|
||||
// **daemon 在使用者的監看根底下產生的每一個檔案,basename 一律以 MachineMark 開頭。**
|
||||
//
|
||||
// 一種就是一種——「一部分加一部分不加」比全不加更難分辨(leo 的紅線)。
|
||||
// 所以任何新的寫檔點都必須經過 MarkName(),不得自己拼檔名。
|
||||
// 迴歸網=vault_footprint_test.go:它 walk 整個監看根,只要有任何新檔沒帶標記就紅,
|
||||
// **與寫檔點的數量無關**——這正是上一輪缺的那張網(上一輪只點測了一個常數)。
|
||||
//
|
||||
// 為什麼是「前綴」不是「後綴」:`ls`/Finder/Logseq 的頁面清單都是按名字排序的,
|
||||
// 前綴會讓機器寫的東西**全部聚成一叢**,一眼就掃得完;後綴則散落在他自己的檔案之間,
|
||||
// 等於還是要一個一個看。leo 要的是「打開資料夾一眼分得出」,那就是前綴。
|
||||
//
|
||||
// 為什麼標記字串是 `arcrun-` 而不是 leo 舉例的 `wiki`:`wiki` 是他自己筆記裡會用的
|
||||
// 普通詞(`wiki-整理術.md` 是他的、不是我們的),拿它當標記等於製造新的誤認;
|
||||
// `arcrun-` 直接說出「這是 Arcrun 寫的」,且與既有的 `.arcrun-rag/` 目錄同一組字。
|
||||
// 這是一行常數,leo 覺得該用別的字就改這裡一個地方(連舊產物一起 tidy 重跑即可)。
|
||||
package collector
|
||||
|
||||
import "strings"
|
||||
|
||||
// MachineMark 是 daemon 產物的**唯一**標記。改這裡=改全部(含 tidy 的認人判準)。
|
||||
const MachineMark = "arcrun-"
|
||||
|
||||
// MarkName 把一個 basename 加上標記。冪等:已經帶標記的原樣回傳,
|
||||
// 不會疊成 `arcrun-arcrun-x.md`(tidy 重跑、daemon 重跑都會走到這條)。
|
||||
func MarkName(base string) string {
|
||||
if IsMarked(base) {
|
||||
return base
|
||||
}
|
||||
return MachineMark + base
|
||||
}
|
||||
|
||||
// IsMarked 回答「這個 basename 是不是機器寫的」。
|
||||
// ⚠️ 只吃 basename,不吃路徑——路徑中間的目錄名不算數(目錄是不是機器的,
|
||||
// 由 cardsRelDirFor 那組常數決定,不由這裡猜)。
|
||||
func IsMarked(base string) bool {
|
||||
return strings.HasPrefix(base, MachineMark)
|
||||
}
|
||||
|
||||
// UnmarkName 去掉標記,回傳原本的名字。tidy 拿來對照「這張卡對應哪一份原稿」。
|
||||
func UnmarkName(base string) string {
|
||||
return strings.TrimPrefix(base, MachineMark)
|
||||
}
|
||||
@@ -87,6 +87,8 @@ func Run(args []string) int {
|
||||
return runLint(args[1:])
|
||||
case "template-install":
|
||||
return runTemplateInstall(args[1:])
|
||||
case "tidy":
|
||||
return runTidy(args[1:])
|
||||
default:
|
||||
usage()
|
||||
return 2
|
||||
@@ -104,6 +106,8 @@ func usage() {
|
||||
collector upload --root <dir> --manifest <file> [--max-removed-ratio 0.4] [--dry-run]
|
||||
collector sync --root <dir> --manifest <file> [--max-removed-ratio 0.4] [--dry-run]
|
||||
collector direct --config <config.json> [--once] [--dry-run]
|
||||
collector tidy --folder <dir> [--apply] [--json]
|
||||
tidy: 找出以前寫進資料夾、還沒帶 arcrun- 標記的舊產物並改名/搬走(預設只列出不動手,加 --apply 才執行;從不刪檔)
|
||||
upload 需環境變數: CF_ACCOUNT_ID / CF_API_TOKEN / R2_BUCKET
|
||||
sync 另需: ARCRUN_TRIGGER_URL(named-webhook 觸發完整 URL)
|
||||
direct(無 R2): 監看資料夾 → 讀檔內容直送實例 rag_ingest_direct workflow;設定走 --config JSON`)
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
// tidy.go — 認出並收拾「daemon 以前寫進使用者資料夾、但還沒帶標記」的舊產物
|
||||
// (arcrun-rag#60 第二輪驗收條件③)。
|
||||
//
|
||||
// 🔴 為什麼光「以後不再撞名」不夠:
|
||||
//
|
||||
// leo 打開資料夾的當下,裡面已經有一批舊產物了(`status.md`/`mistakes.md`/
|
||||
// 日期檔那些)。修好未來不會讓現在變乾淨——**他抱怨的是他現在看到的東西**。
|
||||
// 所以這一輪必須同時交出「一次認出來/一次改名」的辦法。
|
||||
//
|
||||
// 🔴 這是別人的資料,所以本檔只做兩件可回收的事:**改名**與**搬移**。
|
||||
//
|
||||
// 一行 os.Remove 都沒有。誤判時他把檔案搬回去就好,不會有東西不見。
|
||||
// 同理,目標檔名已經存在時一律**跳過並報告**,不覆蓋(safewrite.go 同一條紅線)。
|
||||
//
|
||||
// 兩個入口,責任不同:
|
||||
//
|
||||
// ① MigrateCardNames():daemon 每輪自動跑,只碰**卡片產物區**(那整個目錄是我們寫的,
|
||||
// 百分之百不會誤傷)。leo 不必知道它存在——「不該為了保護自己的筆記去學新選項」。
|
||||
// ② Tidy() / `collector tidy`:人工跑,多處理一類——**template 殘留**。
|
||||
// 那一類不能自動搬,因為同樣那幾個路徑在「leo 自己的開發 repo」裡是**他的真檔案**
|
||||
// (TemplateOwns 是路徑身分判準,分不出「daemon 鋪的」還是「repo 本來就有的」)。
|
||||
// ⇒ 只有在**確定是 vault**(沒有人會把 system-dev template 放進 Logseq 筆記庫)
|
||||
// 時才動手,其餘一律只列出來讓人自己決定。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 產物分類(報告用;也是「動不動手」的判準)。
|
||||
const (
|
||||
TidyKindCard = "card" // 卡片本體
|
||||
TidyKindCardBak = "card-backup" // safeWriteCard 產生的 .bak-<unixnano>
|
||||
TidyKindTemplate = "template-leftover" // 舊版 daemon 代裝的 system-dev template 殘留
|
||||
)
|
||||
|
||||
// 動作(報告用)。dry-run 時只會出現 would-* 與 report-only。
|
||||
const (
|
||||
TidyActionRenamed = "renamed"
|
||||
TidyActionMoved = "moved"
|
||||
TidyActionWillRename = "would-rename"
|
||||
TidyActionWillMove = "would-move"
|
||||
TidyActionReport = "report-only" // 我不動它,請人自己判斷
|
||||
TidyActionSkipped = "skipped" // 想動但不能動(目標已存在/搬不動)
|
||||
)
|
||||
|
||||
// legacyTemplateRelDir 是 template 殘留的收容處:監看根底下的隱藏目錄,
|
||||
// 保留原本的相對路徑結構(`.arcrun-rag/legacy-template/system-dev/wiki/status.md`)。
|
||||
// 刻意留在同一個資料夾裡而不是丟到 ~/ 或垃圾桶——東西還在他手上,要還原只是搬回去。
|
||||
const legacyTemplateRelDir = ".arcrun-rag/legacy-template"
|
||||
|
||||
// bakSuffix 認 safeWriteCard 的備份檔:`<原名>.bak-<unixnano>`。
|
||||
var bakSuffix = regexp.MustCompile(`\.bak-\d+$`)
|
||||
|
||||
// TidyItem=一筆產物與我們對它做了(或會做)什麼。
|
||||
type TidyItem struct {
|
||||
Rel string `json:"rel"` // 相對監看根的路徑(斜線分隔)
|
||||
To string `json:"to,omitempty"` // 改名/搬移的目的地(同樣相對監看根)
|
||||
Kind string `json:"kind"` // TidyKind*
|
||||
Action string `json:"action"` // TidyAction*
|
||||
Note string `json:"note,omitempty"` // 沒動它的原因(白話)
|
||||
}
|
||||
|
||||
// TidyReport=一次收拾的完整帳目。
|
||||
type TidyReport struct {
|
||||
Root string `json:"root"`
|
||||
VaultType VaultType `json:"vault_type"`
|
||||
Applied bool `json:"applied"` // false=只看不動(dry-run)
|
||||
Items []TidyItem `json:"items"`
|
||||
}
|
||||
|
||||
// Counts 依 action 統計,給人看的一行摘要用。
|
||||
func (r *TidyReport) Counts() map[string]int {
|
||||
out := map[string]int{}
|
||||
for _, it := range r.Items {
|
||||
out[it.Action]++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cardDirsToScan 回傳「卡片可能住過的所有目錄」(相對監看根)。
|
||||
// 刻意兩個都掃,不看目前是不是 vault——舊產物可能是在資料夾還沒變成 vault 之前落下的,
|
||||
// 也可能是第一輪修復前落在 system-dev/wiki/cards/ 的。認人要認全,不能只認現在這一種。
|
||||
func cardDirsToScan() []string {
|
||||
return []string{cardsRelDir, vaultCardsRelDir}
|
||||
}
|
||||
|
||||
// isUnderCardDir 回答「這個相對路徑是不是住在卡片產物區底下」。
|
||||
func isUnderCardDir(relSlash string) bool {
|
||||
for _, d := range cardDirsToScan() {
|
||||
if strings.HasPrefix(relSlash, d+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// collectCardItems 掃出卡片產物區裡「還沒帶標記」的檔案,算出它們該改成什麼名字。
|
||||
// 已經帶標記的不列入(冪等:跑第二次是空的)。
|
||||
func collectCardItems(absRoot string) []TidyItem {
|
||||
var items []TidyItem
|
||||
for _, relDir := range cardDirsToScan() {
|
||||
base := filepath.Join(absRoot, filepath.FromSlash(relDir))
|
||||
_ = filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
if IsMarked(name) {
|
||||
return nil // 已經是新的了
|
||||
}
|
||||
rel, rerr := filepath.Rel(absRoot, p)
|
||||
if rerr != nil {
|
||||
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))),
|
||||
Kind: kind,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// collectTemplateItems 掃出 template 殘留(TemplateOwns 命中的路徑)。
|
||||
//
|
||||
// vault=可以搬(沒人會把開發用 template 放進筆記庫,判定為 daemon 舊版鋪的)。
|
||||
// 非 vault=**只列不動**:那可能正是使用者自己的開發 repo,搬了會把他的 repo 弄壞。
|
||||
func collectTemplateItems(absRoot string, isVault bool) []TidyItem {
|
||||
var items []TidyItem
|
||||
_ = filepath.WalkDir(absRoot, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
// 隱藏目錄整棵跳過——收容處自己就在 .arcrun-rag/ 底下,
|
||||
// 不跳過的話第二次跑會把搬過去的東西再搬一次。
|
||||
if p != absRoot && strings.HasPrefix(d.Name(), ".") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rel, rerr := filepath.Rel(absRoot, p)
|
||||
if rerr != nil {
|
||||
return nil
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
if !TemplateOwns(relSlash) {
|
||||
return nil
|
||||
}
|
||||
// 🔴 卡片產物區就住在 `system-dev/wiki/cards/` 底下,而 TemplateOwns 是**目錄前綴**
|
||||
// 判準(`system-dev/` 整棵都算 template)⇒ 不排除的話,同一張卡會同時被認成
|
||||
// 「卡片(該改名)」與「template 殘留(該搬走)」,兩個動作搶同一個檔,
|
||||
// 先跑的改完名、後跑的就 Stat 不到 ⇒ 報告出現假的失敗。卡片一律由 collectCardItems 管。
|
||||
if isUnderCardDir(relSlash) {
|
||||
return nil
|
||||
}
|
||||
it := TidyItem{Rel: relSlash, Kind: TidyKindTemplate}
|
||||
if isVault {
|
||||
it.To = filepath.ToSlash(filepath.Join(legacyTemplateRelDir, relSlash))
|
||||
} else {
|
||||
it.Action = TidyActionReport
|
||||
it.Note = "這不是 vault,可能是你自己的開發 repo(template 本來就該在)——我不動它,要清請自己確認"
|
||||
}
|
||||
items = append(items, it)
|
||||
return nil
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
// MigrateCardNames 把卡片產物區裡沒帶標記的舊卡就地改名。daemon 每輪自動呼叫。
|
||||
// 回傳實際改名的筆數。**只碰卡片產物區**,其餘一概不動。
|
||||
//
|
||||
// 為什麼可以自動:那兩個目錄從頭到尾只有 daemon 會寫(見 cardsRelDirFor 的註解),
|
||||
// 不存在「誤把使用者的檔案改名」的可能。目標已存在就跳過,不覆蓋。
|
||||
func MigrateCardNames(absRoot string) int {
|
||||
n := 0
|
||||
for _, it := range collectCardItems(absRoot) {
|
||||
from := filepath.Join(absRoot, filepath.FromSlash(it.Rel))
|
||||
to := filepath.Join(absRoot, filepath.FromSlash(it.To))
|
||||
if _, err := os.Stat(to); err == nil {
|
||||
continue // 新名字已經有東西了,不覆蓋
|
||||
}
|
||||
if err := os.Rename(from, to); err == nil {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Tidy 掃出所有舊產物;apply=false 只報告,apply=true 才真的改名/搬移。
|
||||
func Tidy(root string, apply bool) (*TidyReport, error) {
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := os.Stat(absRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("看不到這個資料夾(%s):%w", absRoot, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("%s 不是資料夾", absRoot)
|
||||
}
|
||||
|
||||
vt := DetectVaultType(absRoot)
|
||||
rep := &TidyReport{Root: absRoot, VaultType: vt, Applied: apply}
|
||||
|
||||
items := collectCardItems(absRoot)
|
||||
items = append(items, collectTemplateItems(absRoot, vt != VaultNone)...)
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Rel < items[j].Rel })
|
||||
|
||||
for _, it := range items {
|
||||
if it.Action == TidyActionReport { // 已經判定只列不動
|
||||
rep.Items = append(rep.Items, it)
|
||||
continue
|
||||
}
|
||||
willMove := it.Kind == TidyKindTemplate
|
||||
from := filepath.Join(absRoot, filepath.FromSlash(it.Rel))
|
||||
to := filepath.Join(absRoot, filepath.FromSlash(it.To))
|
||||
|
||||
if _, serr := os.Stat(to); serr == nil {
|
||||
it.Action = TidyActionSkipped
|
||||
it.Note = "目的地已經有同名檔案了,我不覆蓋——請自己看一眼哪一份要留"
|
||||
rep.Items = append(rep.Items, it)
|
||||
continue
|
||||
}
|
||||
if !apply {
|
||||
it.Action = TidyActionWillRename
|
||||
if willMove {
|
||||
it.Action = TidyActionWillMove
|
||||
}
|
||||
rep.Items = append(rep.Items, it)
|
||||
continue
|
||||
}
|
||||
if merr := os.MkdirAll(filepath.Dir(to), 0o755); merr != nil {
|
||||
it.Action, it.Note = TidyActionSkipped, "建目的地資料夾失敗:"+merr.Error()
|
||||
rep.Items = append(rep.Items, it)
|
||||
continue
|
||||
}
|
||||
if rerr := os.Rename(from, to); rerr != nil {
|
||||
it.Action, it.Note = TidyActionSkipped, "搬不動:"+rerr.Error()
|
||||
rep.Items = append(rep.Items, it)
|
||||
continue
|
||||
}
|
||||
it.Action = TidyActionRenamed
|
||||
if willMove {
|
||||
it.Action = TidyActionMoved
|
||||
}
|
||||
rep.Items = append(rep.Items, it)
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// runTidy 是 `collector tidy` 子命令主體。
|
||||
// 預設**只看不動**(要真的動手得自己加 --apply)——別人的資料夾,預設值就該是安全的那一邊。
|
||||
func runTidy(args []string) int {
|
||||
fs2 := newFlagSet()
|
||||
folder := fs2.String("folder", "", "要收拾的資料夾(必填,就是 daemon 在看守的那個)")
|
||||
apply := fs2.Bool("apply", false, "真的動手改名/搬移(不加=只列出會動到什麼)")
|
||||
asJSON := fs2.Bool("json", false, "輸出 JSON(給程式讀;不加=給人看的清單)")
|
||||
if err := fs2.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
if *folder == "" {
|
||||
fmt.Fprintln(os.Stderr, "錯誤:--folder 為必填")
|
||||
return 2
|
||||
}
|
||||
rep, err := Tidy(*folder, *apply)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "collector tidy:", err)
|
||||
return 1
|
||||
}
|
||||
if *asJSON {
|
||||
data, _ := json.MarshalIndent(rep, "", " ")
|
||||
fmt.Println(string(data))
|
||||
return 0
|
||||
}
|
||||
|
||||
if len(rep.Items) == 0 {
|
||||
fmt.Printf("%s:沒有找到任何沒帶標記的舊產物,這個資料夾是乾淨的。\n", rep.Root)
|
||||
return 0
|
||||
}
|
||||
kind := "一般資料夾"
|
||||
if rep.VaultType != VaultNone {
|
||||
kind = string(rep.VaultType) + " 筆記庫"
|
||||
}
|
||||
fmt.Printf("%s(%s)\n", rep.Root, kind)
|
||||
if !rep.Applied {
|
||||
fmt.Println("※ 這只是預覽,什麼都還沒動。確認清單沒問題後,同一行指令加上 --apply 才會真的執行。")
|
||||
}
|
||||
for _, it := range rep.Items {
|
||||
switch it.Action {
|
||||
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)
|
||||
case TidyActionReport:
|
||||
fmt.Printf(" 略過 %s(%s)\n", it.Rel, it.Note)
|
||||
case TidyActionSkipped:
|
||||
fmt.Printf(" 沒動 %s(%s)\n", it.Rel, it.Note)
|
||||
}
|
||||
}
|
||||
c := rep.Counts()
|
||||
fmt.Printf("合計:改名 %d、搬走 %d、只列出 %d、沒動 %d\n",
|
||||
c[TidyActionRenamed]+c[TidyActionWillRename],
|
||||
c[TidyActionMoved]+c[TidyActionWillMove],
|
||||
c[TidyActionReport], c[TidyActionSkipped])
|
||||
fmt.Println("(本指令從不刪除任何東西:只有改名與搬移,覺得不對就自己搬回去。)")
|
||||
return 0
|
||||
}
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
// tidy_test.go — arcrun-rag#60 驗收條件③:已經寫進去的舊產物要有辦法一次認出來/改名。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarkName_Idempotent(t *testing.T) {
|
||||
if got := MarkName("status.md"); got != "arcrun-status.md" {
|
||||
t.Fatalf("MarkName=%q", got)
|
||||
}
|
||||
// 冪等:daemon 每輪自動跑、tidy 也可能重跑,不准疊成 arcrun-arcrun-。
|
||||
twice := MarkName(MarkName("status.md"))
|
||||
if twice != "arcrun-status.md" {
|
||||
t.Fatalf("重複加標記疊起來了:%q", twice)
|
||||
}
|
||||
if !IsMarked("arcrun-x.md") || IsMarked("x.md") {
|
||||
t.Fatal("IsMarked 判斷錯")
|
||||
}
|
||||
if got := UnmarkName("arcrun-x.md"); got != "x.md" {
|
||||
t.Fatalf("UnmarkName=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 舊產物:卡片與備份檔都要被認出來並改名;使用者自己的檔一個都不准動。
|
||||
func TestTidy_RenamesLegacyCardsOnly(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustMkdir(t, filepath.Join(root, "logseq"))
|
||||
|
||||
// 使用者自己的東西(絕對不准動)
|
||||
mine := map[string]string{
|
||||
"journals/2026_08_10.md": "我的日記",
|
||||
"pages/讀書筆記.md": "我的筆記",
|
||||
}
|
||||
for rel, body := range mine {
|
||||
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 落下的產物(兩個卡片目錄各放一份,加一個備份檔)
|
||||
legacy := []string{
|
||||
".arcrun-rag/wiki/cards/2026_08_10.md",
|
||||
".arcrun-rag/wiki/cards/2026_08_10.md.bak-1723459200000000000",
|
||||
"system-dev/wiki/cards/會議記錄.md",
|
||||
}
|
||||
for _, rel := range legacy {
|
||||
p := filepath.Join(root, filepath.FromSlash(rel))
|
||||
mustMkdir(t, filepath.Dir(p))
|
||||
if err := os.WriteFile(p, []byte("機器寫的"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ① dry-run:什麼都不該動
|
||||
rep, err := Tidy(root, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, rel := range legacy {
|
||||
if _, serr := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); serr != nil {
|
||||
t.Fatalf("dry-run 竟然動了檔案:%s 不見了", rel)
|
||||
}
|
||||
}
|
||||
var previewed []string
|
||||
for _, it := range rep.Items {
|
||||
previewed = append(previewed, it.Rel+" → "+it.To+"("+it.Action+")")
|
||||
if it.Action != TidyActionWillRename {
|
||||
t.Fatalf("dry-run 出現非預期動作:%+v", it)
|
||||
}
|
||||
}
|
||||
sort.Strings(previewed)
|
||||
t.Logf("dry-run 預覽:\n %s", strings.Join(previewed, "\n "))
|
||||
if len(rep.Items) != len(legacy) {
|
||||
t.Fatalf("認出來的舊產物數量不對:got %d want %d(%+v)", len(rep.Items), len(legacy), rep.Items)
|
||||
}
|
||||
|
||||
// ② --apply:真的改名
|
||||
if _, err := Tidy(root, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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",
|
||||
}
|
||||
for _, rel := range want {
|
||||
if _, serr := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); serr != nil {
|
||||
t.Fatalf("改名後的檔案不存在:%s", rel)
|
||||
}
|
||||
}
|
||||
for _, rel := range legacy {
|
||||
if _, serr := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); serr == nil {
|
||||
t.Fatalf("舊名字還在——應該是改名不是複製:%s", rel)
|
||||
}
|
||||
}
|
||||
// 使用者的檔案原封不動
|
||||
for rel, body := range mine {
|
||||
data, rerr := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel)))
|
||||
if rerr != nil {
|
||||
t.Fatalf("使用者的檔案不見了:%s(%v)", rel, rerr)
|
||||
}
|
||||
if string(data) != body {
|
||||
t.Fatalf("使用者的檔案被改了:%s → %q", rel, data)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 冪等:再跑一次沒有東西可做
|
||||
rep3, err := Tidy(root, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rep3.Items) != 0 {
|
||||
t.Fatalf("第二次跑不該再有動作:%+v", rep3.Items)
|
||||
}
|
||||
}
|
||||
|
||||
// vault 裡的 template 殘留(status.md/mistakes.md 那批)=搬走,不刪。
|
||||
func TestTidy_VaultTemplateLeftoversAreMovedNotDeleted(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustMkdir(t, filepath.Join(root, "logseq"))
|
||||
leftovers := map[string]string{
|
||||
"system-dev/wiki/status.md": "template 的 status",
|
||||
"system-dev/wiki/mistakes.md": "template 的 mistakes",
|
||||
"scripts/sdd-active-check.sh": "#!/bin/sh",
|
||||
}
|
||||
for rel, body := range leftovers {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
rep, err := Tidy(root, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
moved := 0
|
||||
for _, it := range rep.Items {
|
||||
if it.Kind == TidyKindTemplate {
|
||||
if it.Action != TidyActionMoved {
|
||||
t.Fatalf("vault 裡的 template 殘留應該被搬走:%+v", it)
|
||||
}
|
||||
moved++
|
||||
}
|
||||
}
|
||||
if moved != len(leftovers) {
|
||||
t.Fatalf("搬走的數量不對:got %d want %d", moved, len(leftovers))
|
||||
}
|
||||
for rel, body := range leftovers {
|
||||
// 原位不該還在
|
||||
if _, serr := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); serr == nil {
|
||||
t.Fatalf("原位的殘留還在:%s", rel)
|
||||
}
|
||||
// 但**內容必須還找得到**——本指令從不刪東西
|
||||
p := filepath.Join(root, filepath.FromSlash(legacyTemplateRelDir), filepath.FromSlash(rel))
|
||||
data, rerr := os.ReadFile(p)
|
||||
if rerr != nil {
|
||||
t.Fatalf("搬過去的檔案讀不到(東西被弄丟了?):%s(%v)", p, rerr)
|
||||
}
|
||||
if string(data) != body {
|
||||
t.Fatalf("搬移過程中內容變了:%s", rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 非 vault(很可能是使用者自己的開發 repo)=template 只列出來,一個都不准動。
|
||||
func TestTidy_PlainFolderTemplateOnlyReported(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
rel := "system-dev/wiki/status.md"
|
||||
p := filepath.Join(root, filepath.FromSlash(rel))
|
||||
mustMkdir(t, filepath.Dir(p))
|
||||
if err := os.WriteFile(p, []byte("這是我 repo 裡真正的 status"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rep, err := Tidy(root, true) // 即使 --apply 也不准動
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rep.Items) != 1 || rep.Items[0].Action != TidyActionReport {
|
||||
t.Fatalf("非 vault 的 template 應該只列出不動手:%+v", rep.Items)
|
||||
}
|
||||
data, rerr := os.ReadFile(p)
|
||||
if rerr != nil || string(data) != "這是我 repo 裡真正的 status" {
|
||||
t.Fatalf("非 vault 的 template 被動了:%v / %q", rerr, data)
|
||||
}
|
||||
}
|
||||
|
||||
// 目的地已經有同名檔=跳過並回報,絕不覆蓋(safewrite.go 同一條紅線)。
|
||||
func TestTidy_DoesNotClobberExistingTarget(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cardDir := filepath.Join(root, filepath.FromSlash(cardsRelDir))
|
||||
mustMkdir(t, cardDir)
|
||||
if err := os.WriteFile(filepath.Join(cardDir, "x.md"), []byte("舊的"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(cardDir, "arcrun-x.md"), []byte("新的"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rep, err := Tidy(root, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rep.Items) != 1 || rep.Items[0].Action != TidyActionSkipped {
|
||||
t.Fatalf("目的地已存在時應跳過:%+v", rep.Items)
|
||||
}
|
||||
if data, _ := os.ReadFile(filepath.Join(cardDir, "arcrun-x.md")); string(data) != "新的" {
|
||||
t.Fatalf("既有目標被覆蓋了:%q", data)
|
||||
}
|
||||
if data, _ := os.ReadFile(filepath.Join(cardDir, "x.md")); string(data) != "舊的" {
|
||||
t.Fatalf("來源被動了:%q", data)
|
||||
}
|
||||
}
|
||||
|
||||
// MigrateCardNames(daemon 每輪自動跑的那個)只碰卡片產物區。
|
||||
func TestMigrateCardNames_TouchesOnlyCardDirs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustMkdir(t, filepath.Join(root, "logseq"))
|
||||
// 卡片區的舊卡(該改名)
|
||||
cardDir := filepath.Join(root, filepath.FromSlash(vaultCardsRelDir))
|
||||
mustMkdir(t, cardDir)
|
||||
if err := os.WriteFile(filepath.Join(cardDir, "舊卡.md"), []byte("c"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// template 殘留(自動路不准碰——那要人確認是不是他自己的 repo)
|
||||
tplDir := filepath.Join(root, "system-dev", "wiki")
|
||||
mustMkdir(t, tplDir)
|
||||
if err := os.WriteFile(filepath.Join(tplDir, "status.md"), []byte("s"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if n := MigrateCardNames(root); n != 1 {
|
||||
t.Fatalf("改名筆數=%d,want 1", n)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cardDir, "arcrun-舊卡.md")); err != nil {
|
||||
t.Fatalf("舊卡沒被改名:%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tplDir, "status.md")); err != nil {
|
||||
t.Fatalf("自動路不該碰 template 殘留,但它不見了:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
// vault_footprint_test.go — arcrun-rag#60 第二輪,驗收條件④「回頭看:上一批為什麼沒抓到」。
|
||||
//
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// 上一批(1e36bb1)為什麼漏掉這次這個?
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// 上一批問的問題是:「**卡片落在哪個目錄?**」
|
||||
// 於是它加了 cardsRelDirFor(),並且只寫了一支點測(TestCardsRelDirFor)去驗
|
||||
// 「這個函式回傳的常數對不對」。它從頭到尾**沒有問過「檔名叫什麼」**——
|
||||
// 而 Logseq/Obsidian 的頁名正是從 basename 來的,撞名撞的是名字不是目錄。
|
||||
//
|
||||
// 更根本的形狀問題:那批測試是**點測**(挑一個已知的寫檔點,驗它的回傳值)。
|
||||
// 點測只能保護「測試作者當時想得到的那幾個寫檔點」,於是這些全部漏在網外:
|
||||
//
|
||||
// · snapshotCards() 仍硬寫 cardsRelDir(vault 上 snapshot 到空目錄)
|
||||
// · ExtractWithClaude 的檔名由 prompt 決定,Go 測試碰不到
|
||||
// · safeWriteCard 的 .bak-<ts> 備份檔沒人問過它叫什麼名字
|
||||
// · 之後任何人新增第 N 個寫檔點——沒有任何機制會提醒他要帶標記
|
||||
//
|
||||
// ⇒ 這正是「規則寫了、卻沒有機制驗證有沒有照做」的同款(頂層 CLAUDE.md 記過
|
||||
// history-first/KBDB-first/stage-first 三個同形狀的事故)。
|
||||
//
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// 所以這一批補的是「足跡測試」,不是再多一支點測
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// 判準從「某個函式回傳什麼」換成「**跑完一輪之後,這個資料夾多出了什麼**」:
|
||||
//
|
||||
// 跑之前 walk 整個監看根拍一張全景快照 → 跑一輪真的同步 → 再拍一張 →
|
||||
// ① 所有**新出現**的檔案,basename 一律要帶 MachineMark
|
||||
// ② 所有**本來就在**的檔案,內容與 mtime 一個位元都不能變
|
||||
//
|
||||
// 這張網**與寫檔點的數量無關**:以後誰新增一個沒帶標記的寫檔點,不管它藏在
|
||||
// 哪支檔案、哪條分支,只要它真的寫進了使用者的資料夾就會被這裡抓到。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// gemmaEchoStub=會**看請求是哪一頁**、回傳對應那一頁合格卡片的 Gemini 替身。
|
||||
//
|
||||
// 為什麼不能沿用 gemmaCardStub(固定回同一張卡):這一支測試的 fixture 是「真的有
|
||||
// 既有頁面的 vault」,一輪會萃好幾份(journals、pages、丟進去的原稿)。固定回同一張卡
|
||||
// 會讓 pageName 對不上 cleanGemmaCard 的「# <頁名>」契約 ⇒ 被品質 lint 擋下 ⇒ 測試
|
||||
// 綠不了,而那是替身的問題不是產品的問題。頁名從 gemmaPrompt 的「# <頁名>」裡取回來。
|
||||
func gemmaEchoStub(t *testing.T) func() {
|
||||
t.Helper()
|
||||
return gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Contents []struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"contents"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
prompt := ""
|
||||
if len(req.Contents) > 0 && len(req.Contents[0].Parts) > 0 {
|
||||
prompt = req.Contents[0].Parts[0].Text
|
||||
}
|
||||
page := "未知頁"
|
||||
if i := strings.Index(prompt, "「# "); i >= 0 {
|
||||
rest := prompt[i+len("「# "):]
|
||||
if j := strings.Index(rest, "」"); j >= 0 {
|
||||
page = rest[:j]
|
||||
}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"candidates": []map[string]any{{
|
||||
"content": map[string]any{"parts": []map[string]any{
|
||||
{"text": cardFixture(page, "測試客體")},
|
||||
}},
|
||||
}},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// footprintEntry=快照裡的一筆(內容雜湊足以同時偵測「被改了」與「被截斷了」)。
|
||||
type footprintEntry struct {
|
||||
size int64
|
||||
mtime int64
|
||||
hash string
|
||||
}
|
||||
|
||||
// snapshotTree walk 整個 root(含隱藏目錄——機器產物就藏在那裡),回傳 rel → 狀態。
|
||||
func snapshotTree(t *testing.T, root string) map[string]footprintEntry {
|
||||
t.Helper()
|
||||
out := map[string]footprintEntry{}
|
||||
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, rerr := filepath.Rel(root, p)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
info, ierr := d.Info()
|
||||
if ierr != nil {
|
||||
return ierr
|
||||
}
|
||||
h, herr := hashFile(p)
|
||||
if herr != nil {
|
||||
return herr
|
||||
}
|
||||
out[filepath.ToSlash(rel)] = footprintEntry{size: info.Size(), mtime: info.ModTime().UnixNano(), hash: h}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("快照失敗:%v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sortedKeys 讓 t.Log 印出來的清單順序穩定(人要拿它當實測輸出貼進 issue)。
|
||||
func sortedKeys(m map[string]footprintEntry) []string {
|
||||
ks := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
return ks
|
||||
}
|
||||
|
||||
func logTree(t *testing.T, title string, snap map[string]footprintEntry) {
|
||||
t.Helper()
|
||||
t.Logf("──── %s(%d 個檔案)────", title, len(snap))
|
||||
for _, k := range sortedKeys(snap) {
|
||||
t.Logf(" %s", k)
|
||||
}
|
||||
}
|
||||
|
||||
// newVaultWithRealJournals 造一個「真的有既有日誌檔」的 Logseq vault。
|
||||
//
|
||||
// 🔴 日期檔是這一票的核心:使用者的 journals/2026_08_10.md 一旦被當成原稿萃取,
|
||||
// 舊版產出的卡片就叫 2026_08_10.md ⇒ 在 Logseq 眼裡跟他自己的日誌**同一頁**。
|
||||
// 所以 fixture 必須真的擺日期檔,否則測不到 leo 抱怨的那一類。
|
||||
func newVaultWithRealJournals(t *testing.T) (root string, preexisting map[string]string) {
|
||||
t.Helper()
|
||||
root = t.TempDir()
|
||||
mustMkdir(t, filepath.Join(root, "logseq"))
|
||||
mustMkdir(t, filepath.Join(root, "pages"))
|
||||
mustMkdir(t, filepath.Join(root, "journals"))
|
||||
|
||||
preexisting = map[string]string{
|
||||
"logseq/config.edn": "{:default-home {:page \"contents\"}}",
|
||||
"pages/contents.md": "- 我的目錄頁",
|
||||
"pages/讀書筆記.md": "- leo 自己寫的讀書筆記",
|
||||
"journals/2026_08_10.md": "- 今天想到的事,這是我自己寫的日記",
|
||||
"journals/2026_08_11.md": "- 昨天的日記",
|
||||
}
|
||||
for rel, body := range preexisting {
|
||||
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)
|
||||
}
|
||||
}
|
||||
return root, preexisting
|
||||
}
|
||||
|
||||
// 驗收條件①②:在一個真的有既有日誌檔的 vault 上跑一輪,
|
||||
// 新檔全部帶標記、他原本的檔一個都沒被動到。
|
||||
func TestVaultFootprint_EveryNewFileIsMarked(t *testing.T) {
|
||||
root, preexisting := newVaultWithRealJournals(t)
|
||||
|
||||
// 使用者丟進 vault 的原稿,**刻意用日期當檔名**——這是最會撞的那一種:
|
||||
// 舊行為會萃出 `2026_08_10.md`,跟 journals/2026_08_10.md 同頁名。
|
||||
srcRel := "2026_08_10.md"
|
||||
if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 要被萃取的原稿"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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)()
|
||||
|
||||
before := snapshotTree(t, root)
|
||||
logTree(t, "跑之前", before)
|
||||
|
||||
cfg := &DirectConfig{
|
||||
WatchFolders: []string{root},
|
||||
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)
|
||||
}
|
||||
|
||||
after := snapshotTree(t, root)
|
||||
logTree(t, "跑之後", after)
|
||||
|
||||
// ── ① 所有新出現的檔案,basename 一律帶標記 ──────────────────────────
|
||||
var unmarked, added []string
|
||||
for rel := range after {
|
||||
if _, existed := before[rel]; existed {
|
||||
continue
|
||||
}
|
||||
added = append(added, rel)
|
||||
if !IsMarked(filepath.Base(rel)) {
|
||||
unmarked = append(unmarked, rel)
|
||||
}
|
||||
}
|
||||
sort.Strings(added)
|
||||
sort.Strings(unmarked)
|
||||
t.Logf("新增的檔案:%v", added)
|
||||
if len(added) == 0 {
|
||||
t.Fatal("這一輪什麼都沒產出——紅線是「東西照樣產出、但不撞名」,沒產出等於沒測到")
|
||||
}
|
||||
if len(unmarked) > 0 {
|
||||
t.Fatalf("有 %d 個新檔沒帶 %q 標記,使用者分不出是誰寫的:%v",
|
||||
len(unmarked), MachineMark, unmarked)
|
||||
}
|
||||
|
||||
// ── ② 他原本的檔,一個位元都不能變 ────────────────────────────────
|
||||
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)
|
||||
}
|
||||
}
|
||||
// 內容也逐字核對一次(hash 相同已足夠,這裡是給人看的雙保險)。
|
||||
for rel, want := range preexisting {
|
||||
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel)))
|
||||
if err != nil {
|
||||
t.Fatalf("讀不回原本的檔案 %s:%v", rel, err)
|
||||
}
|
||||
if string(data) != want {
|
||||
t.Fatalf("原本的檔案內容變了:%s\n want %q\n got %q", rel, want, data)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ③ 撞名這件事本身:不准有任何機器產物的頁名等於使用者的頁名 ──────
|
||||
userPages := map[string]string{}
|
||||
for rel := range preexisting {
|
||||
if strings.HasSuffix(rel, ".md") {
|
||||
userPages[pageNameOf(rel)] = rel
|
||||
}
|
||||
}
|
||||
userPages[pageNameOf(srcRel)] = srcRel
|
||||
for _, rel := range added {
|
||||
if !strings.HasSuffix(rel, ".md") {
|
||||
continue
|
||||
}
|
||||
if owner, clash := userPages[pageNameOf(rel)]; clash {
|
||||
t.Fatalf("機器產物 %s 的頁名與使用者的 %s 撞在一起(Logseq 會當成同一頁)", rel, owner)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 非 vault 的一般資料夾,同一條規則同樣成立。
|
||||
//
|
||||
// 🔴 為什麼要有這一支:紅線是「前綴只准一種,不要一部分加一部分不加」。
|
||||
// 若只在 vault 加標記,一般資料夾的使用者照樣分不出哪些是機器寫的——
|
||||
// 而 system-dev/wiki/cards/ 在他的檔案總管裡是**看得見的**,不像 vault 那條是隱藏目錄。
|
||||
func TestPlainFolderFootprint_EveryNewFileIsMarked(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mine := filepath.Join(root, "我的報告.md")
|
||||
if err := os.WriteFile(mine, []byte("我自己的內容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srcRel := "報銷規則.md"
|
||||
if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 原稿"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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)()
|
||||
|
||||
before := snapshotTree(t, root)
|
||||
cfg := &DirectConfig{
|
||||
WatchFolders: []string{root},
|
||||
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,
|
||||
}
|
||||
if results, exit, _ := RunDirectOnce(cfg, false); exit != 0 {
|
||||
t.Fatalf("同步失敗 exit=%d results=%+v", exit, results)
|
||||
}
|
||||
after := snapshotTree(t, root)
|
||||
logTree(t, "一般資料夾・跑之後", after)
|
||||
|
||||
newCount := 0
|
||||
for rel := range after {
|
||||
if _, existed := before[rel]; existed {
|
||||
continue
|
||||
}
|
||||
newCount++
|
||||
if !IsMarked(filepath.Base(rel)) {
|
||||
t.Fatalf("一般資料夾也不准有沒帶標記的新檔:%s", rel)
|
||||
}
|
||||
}
|
||||
if newCount == 0 {
|
||||
t.Fatal("沒有任何產出,測不到東西")
|
||||
}
|
||||
if data, _ := os.ReadFile(mine); string(data) != "我自己的內容" {
|
||||
t.Fatalf("使用者原本的檔被動過:%q", data)
|
||||
}
|
||||
}
|
||||
|
||||
// 備份檔也在網內:既有卡片被改寫時產生的 .bak-<ts>,同樣必須帶標記。
|
||||
//
|
||||
// 這一條是「足跡測試比點測強」的最好例子——沒有人特地為備份檔寫過測試,
|
||||
// 但它確確實實是 daemon 寫進使用者資料夾的檔案。
|
||||
func TestVaultFootprint_BackupFilesAreMarkedToo(t *testing.T) {
|
||||
root, _ := newVaultWithRealJournals(t)
|
||||
srcRel := "會議記錄.md"
|
||||
if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 原稿 v1"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 先放一張同名舊卡(內容不同)→ 這一輪落卡會先備份它。
|
||||
cardDir := filepath.Join(root, filepath.FromSlash(vaultCardsRelDir))
|
||||
mustMkdir(t, cardDir)
|
||||
cardName := MarkName("會議記錄.md")
|
||||
if err := os.WriteFile(filepath.Join(cardDir, cardName), []byte("# 會議記錄\n舊的內容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer gemmaCardStub(t, cardFixture("會議記錄", "專案"))()
|
||||
if _, err := ExtractWithGemma("k-test", "gemma-test", root, srcRel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(cardDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
foundBackup := false
|
||||
for _, e := range entries {
|
||||
names = append(names, e.Name())
|
||||
if !IsMarked(e.Name()) {
|
||||
t.Fatalf("卡片目錄裡出現沒帶標記的檔案:%s(目錄內容:%v)", e.Name(), names)
|
||||
}
|
||||
if bakSuffix.MatchString(e.Name()) {
|
||||
foundBackup = true
|
||||
}
|
||||
}
|
||||
t.Logf("卡片目錄內容:%v", names)
|
||||
if !foundBackup {
|
||||
t.Fatalf("既有卡片沒有被備份就被改寫了,目錄內容:%v", names)
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env bash
|
||||
# verify-60.sh — arcrun-rag#60 第二輪的五項驗收,一次跑完並把實測輸出印出來。
|
||||
#
|
||||
# 用法(在 repo 任何位置都可以):
|
||||
# bash collector/verify-60.sh 2>&1 | tee /tmp/verify-60.log
|
||||
#
|
||||
# 這支腳本不碰網路、不碰任何真實資料夾——fixture 全建在自己的暫存目錄裡,
|
||||
# 跑完就刪。它印出的東西就是要貼進 issue #60 的證據。
|
||||
#
|
||||
# ⚠️ collector/ 自己是一個獨立的 Go module(module arcrun-rag/collector),
|
||||
# 所有 go 指令都必須在 collector/ 裡面跑,不是在 repo 根目錄。
|
||||
set -uo pipefail
|
||||
|
||||
cd "$(dirname "$0")" || exit 1 # ← collector/(module 根)
|
||||
FAIL=0
|
||||
|
||||
hr() { printf '\n══════════════════════════════════════════════════════════════\n%s\n══════════════════════════════════════════════════════════════\n' "$1"; }
|
||||
|
||||
hr "① 格式與編譯"
|
||||
if [ -n "$(gofmt -l .)" ]; then
|
||||
echo "⚠️ 下列檔案未照 gofmt 格式化,跑一次 gofmt -w ."; gofmt -l .
|
||||
else
|
||||
echo "gofmt:乾淨"
|
||||
fi
|
||||
go vet ./... || FAIL=1
|
||||
go build ./... || { echo "❌ 編譯失敗"; exit 1; }
|
||||
echo "編譯:通過"
|
||||
|
||||
hr "② 既有 Go 測試全綠(驗收條件⑤)"
|
||||
go test ./... || FAIL=1
|
||||
|
||||
hr "③ 足跡測試:真的有既有日誌檔的 vault,跑之前/跑之後的完整檔案清單(驗收條件①②④)"
|
||||
go test . -run 'Footprint' -v || FAIL=1
|
||||
|
||||
hr "④ 舊產物收拾:在一個「已經被舊版 daemon 弄髒」的真 vault 上實跑(驗收條件③)"
|
||||
VAULT="$(mktemp -d)"
|
||||
trap 'rm -rf "$VAULT"' EXIT
|
||||
|
||||
# ── 使用者自己的東西 ────────────────────────────────────────────
|
||||
mkdir -p "$VAULT/logseq" "$VAULT/pages" "$VAULT/journals"
|
||||
echo '{:default-home {:page "contents"}}' > "$VAULT/logseq/config.edn"
|
||||
echo '- 我自己的目錄頁' > "$VAULT/pages/contents.md"
|
||||
echo '- 我自己寫的讀書筆記' > "$VAULT/pages/讀書筆記.md"
|
||||
echo '- 我自己寫的日記' > "$VAULT/journals/2026_08_10.md"
|
||||
echo '- 昨天的日記' > "$VAULT/journals/2026_08_11.md"
|
||||
|
||||
# ── 舊版 daemon 已經弄進去的產物(leo 實際看到的那批)─────────────
|
||||
mkdir -p "$VAULT/system-dev/wiki/cards" "$VAULT/.arcrun-rag/wiki/cards" "$VAULT/scripts"
|
||||
echo '# status' > "$VAULT/system-dev/wiki/status.md"
|
||||
echo '# mistakes' > "$VAULT/system-dev/wiki/mistakes.md"
|
||||
echo '#!/bin/sh' > "$VAULT/scripts/sdd-active-check.sh"
|
||||
echo '# 2026_08_10' > "$VAULT/.arcrun-rag/wiki/cards/2026_08_10.md"
|
||||
echo '# 舊卡' > "$VAULT/.arcrun-rag/wiki/cards/2026_08_10.md.bak-1723459200000000000"
|
||||
echo '# 會議記錄' > "$VAULT/system-dev/wiki/cards/會議記錄.md"
|
||||
|
||||
# 使用者檔案的內容指紋,最後要逐一比對「一個都沒被動到」
|
||||
BEFORE_HASH="$(find "$VAULT/pages" "$VAULT/journals" "$VAULT/logseq" -type f -exec shasum {} \; | sed "s|$VAULT/||" | sort)"
|
||||
|
||||
echo "── 收拾之前的完整檔案清單 ──"
|
||||
LIST_BEFORE="$(cd "$VAULT" && find . -type f | sort)"
|
||||
echo "$LIST_BEFORE"
|
||||
|
||||
echo
|
||||
echo "── collector tidy(預設只看不動)──"
|
||||
go run ./cmd/collector tidy --folder "$VAULT" || FAIL=1
|
||||
|
||||
echo
|
||||
echo "── 確認 dry-run 真的什麼都沒動 ──"
|
||||
LIST_AFTER_DRYRUN="$(cd "$VAULT" && find . -type f | sort)"
|
||||
if [ "$LIST_BEFORE" = "$LIST_AFTER_DRYRUN" ]; then
|
||||
echo "✅ dry-run 後檔案清單與之前逐字相同"
|
||||
else
|
||||
echo "❌ dry-run 竟然動了東西:"; diff <(echo "$LIST_BEFORE") <(echo "$LIST_AFTER_DRYRUN"); FAIL=1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "── collector tidy --apply(真的動手)──"
|
||||
go run ./cmd/collector tidy --folder "$VAULT" --apply || FAIL=1
|
||||
|
||||
echo
|
||||
echo "── 收拾之後的完整檔案清單 ──"
|
||||
(cd "$VAULT" && find . -type f | sort)
|
||||
|
||||
echo
|
||||
echo "── 檢查一:使用者原本的檔案,一個都沒被動到 ──"
|
||||
AFTER_HASH="$(find "$VAULT/pages" "$VAULT/journals" "$VAULT/logseq" -type f -exec shasum {} \; | sed "s|$VAULT/||" | sort)"
|
||||
if [ "$BEFORE_HASH" = "$AFTER_HASH" ]; then
|
||||
echo "✅ 使用者的 5 個檔案內容與路徑完全未變"
|
||||
else
|
||||
echo "❌ 使用者的檔案被動過:"; diff <(echo "$BEFORE_HASH") <(echo "$AFTER_HASH"); FAIL=1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "── 檢查二:機器產物一律帶 arcrun- 前綴,且沒有任何一個與使用者頁面同名 ──"
|
||||
STRAY=0
|
||||
while IFS= read -r f; do
|
||||
base="$(basename "$f")"
|
||||
case "$base" in arcrun-*) ;; *) echo " ❌ 沒帶前綴:$f"; STRAY=1;; esac
|
||||
done < <(cd "$VAULT" && find ./.arcrun-rag/wiki/cards ./system-dev/wiki/cards -type f 2>/dev/null)
|
||||
[ "$STRAY" -eq 0 ] && echo "✅ 卡片產物區全部帶前綴" || FAIL=1
|
||||
|
||||
echo
|
||||
echo "── 檢查三:template 殘留被搬走而不是被刪掉(東西還在) ──"
|
||||
for f in system-dev/wiki/status.md system-dev/wiki/mistakes.md scripts/sdd-active-check.sh; do
|
||||
if [ -f "$VAULT/.arcrun-rag/legacy-template/$f" ]; then
|
||||
echo " ✅ $f → .arcrun-rag/legacy-template/$f(內容還在)"
|
||||
else
|
||||
echo " ❌ $f 不見了"; FAIL=1
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "── 檢查四:冪等(再跑一次不該有任何動作) ──"
|
||||
go run ./cmd/collector tidy --folder "$VAULT" --apply || FAIL=1
|
||||
|
||||
hr "結果"
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
echo "✅ 全部通過"
|
||||
else
|
||||
echo "❌ 有項目未通過,往上找 ❌"
|
||||
fi
|
||||
exit "$FAIL"
|
||||
Reference in New Issue
Block a user