Files
arcrun-collector/vault_footprint_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

370 lines
14 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.
// vault_footprint_test.go — arcrun-rag#60 第二輪,驗收條件④「回頭看:上一批為什麼沒抓到」。
//
// ══════════════════════════════════════════════════════════════════════════
// 上一批(1e36bb1)為什麼漏掉這次這個?
// ══════════════════════════════════════════════════════════════════════════
//
// 上一批問的問題是:「**卡片落在哪個目錄?**」
// 於是它加了 cardsRelDirFor(),並且只寫了一支點測(TestCardsRelDirFor)去驗
// 「這個函式回傳的常數對不對」。它從頭到尾**沒有問過「檔名叫什麼」**——
// 而 Logseq/Obsidian 的頁名正是從 basename 來的,撞名撞的是名字不是目錄。
//
// 更根本的形狀問題:那批測試是**點測**(挑一個已知的寫檔點,驗它的回傳值)。
// 點測只能保護「測試作者當時想得到的那幾個寫檔點」,於是這些全部漏在網外:
//
// · snapshotCards() 仍硬寫 cardsRelDirvault 上 snapshot 到空目錄)
// · ExtractWithClaude 的檔名由 prompt 決定,Go 測試碰不到
// · safeWriteCard 的 .bak-<ts> 備份檔沒人問過它叫什麼名字
// · 之後任何人新增第 N 個寫檔點——沒有任何機制會提醒他要帶標記
//
// ⇒ 這正是「規則寫了、卻沒有機制驗證有沒有照做」的同款(頂層 CLAUDE.md 記過
//
// history-firstKBDB-firststage-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
}
// sortedFootprintKeys 讓 t.Log 印出來的清單順序穩定(人要拿它當實測輸出貼進 issue)。
// 名字不叫 sortedKeysconvert_table.go 已經有一支同名的(吃 map[string][]byte),
// 同一個 package 裡不能重名。
func sortedFootprintKeys(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 sortedFootprintKeys(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 !IsMachineOwnedRel(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("使用者原本的檔案被改了:%ssize %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 !IsMachineOwnedRel(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)
}
}