Files
arcrun-collector/tidy_test.go
T
Claude 129fe79ecb fix(collector): daemon 不再改用戶版控中的檔案,接 repo 只讀整理好的 wiki(#105/#104)
## #105 daemon 會改用戶的檔案(不可逆,先修)

2026-08-14 21:45 實撞:InkStoneCo 在看守清單裡,daemon 回報「已把 1 張舊卡片歸位」,
實際把 system-dev/wiki/cards/autonomy/ 整個子目錄壓平改名,16 個版控中的檔案變成刪除。

真兇是 MigrateCardNames 的一句假設:「那兩個目錄從頭到尾只有 daemon 會寫」。
那句話在 vault 上成立,在 repo 上不成立——system-dev/wiki/cards/ 是 template 的規約
路徑,而 template 就是要裝進開發者自己的 repo,那裡本來就有人家自己的檔案。

- 新增 repoguard.go:`.git` 判準(含 linked worktree),與 #104 共用同一個判準源
- 版控中的資料夾一個檔都不自動動;改成記帳+講給使用者聽,出口是 collector tidy --apply
- Blocked 只算「確定是我們寫的」(帶 arcrun- 標記)——報使用者自己的卡等於發假訊息
- 落卡與工作區改走 .arcrun-rag/(與 vault 同待遇),自帶 .gitignore(*) 讓它對 git 隱形
- 非版控資料夾(含 vault)行為與 #60 第三輪完全一致,不推翻前兩輪的成果

## #104 接上開發 repo 會把上萬個原始檔排進佇列

leo 的規格:「它要辨識這個庫已經有 wiki,那就直接 ingest 了」「只有文件要讀,程式碼不用讀」。

- 新增 ingestplan.go:掃描前先問「這個資料夾是什麼」——all/curated-wiki/docs-only
- 排除靠路徑身分不靠副檔名:依賴、建置產物、templatefs 範本、linked worktree、巢狀子 repo
- 策略與擋掉的數字經 TriggerPayload.Plan 走進 status.json,CLI 走 stderr(排除規則要看得見)
- 子專案自己的 wiki 刻意不收,但一定列出來讓使用者知道去哪裡找
- 與 TemplateOwns(把 system-dev/ 整棵當開發用的)的對撞用身分化解,不拿掉任一條:
  我們代裝的資料夾沒有 .git(舊規則照舊),他自己的 repo 有(收那份 wiki)

## 實測

#105:舊版 daemon 對真 repo 跑一輪 → git status 32 行(16 個 D + 16 個 ??,與 21:45
      撞到的一字不差);新版跑同一輪 → 0 行。另有真 git init 的端到端測試。
#104:造出 leo 那棵樹的形狀(InkStoneCo + products/arcrun-rag + 3 份出貨 worktree,
      967 個文件檔)→ 舊版送 91 個檔(多收的全是 -wt*ship/ 裡的 templatefs 範本、
      benchmark 結果、docs-site 產物),新版送 32 個,全部落在 system-dev/wiki/ 底下。

MachineMark 規約新增唯一例外 IsMachineOwnedRel:.arcrun-rag/ 底下的檔以目錄名為標記
(.gitignore 的檔名是 git 定的,改不得)。迴歸網判準同步換過去。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUwsLkFEGN8496bZTqhjFq
2026-08-14 14:41:08 +00:00

263 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}
}
// 🔴 第三輪(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 && 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 "))
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-rag/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.mdmistakes.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)
}
}
// MigrateCardNamesdaemon 每輪自動跑的那個)只碰卡片產物區。
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 mig := MigrateCardNames(root); mig.Moved != 1 {
t.Fatalf("改名筆數=%dwant 1", mig.Moved)
}
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)
}
}