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

274 lines
10 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.
// ingestplan_test.go — arcrun-rag#104 的驗收網:**接上一個開發用的 repo 時,
// 送進知識庫的應該是那幾十張整理好的卡,不是幾千個原始檔。**
//
// fixture 刻意照票上那份實據的形狀造:45 個名為 wiki 的目錄裡,8 份 `.claude/worktrees`、
// 5 份出貨用 worktree、8 份 `templatefs` 範本、還有 `.next``.vercel` 建置產物。
package collector
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
// eventPaths 取出本輪要送上去的檔案路徑(排序,好當實測輸出貼進 issue)。
func eventPaths(p *TriggerPayload) []string {
var out []string
for _, e := range p.Events {
out = append(out, e.Path)
}
sort.Strings(out)
return out
}
// scanWithPlan 照 daemon 的真實接法跑一輪掃描(PlanIngest curated-wiki 時解除
// system-dev 的 SkipDirNames,見 direct.go 那段註解)。
func scanWithPlan(t *testing.T, root string) (*TriggerPayload, IngestPlan) {
t.Helper()
plan := PlanIngest(root)
skip := map[string]bool{"system-dev": true}
if plan.Mode == IngestCuratedWiki && strings.HasPrefix(plan.WikiRelDir, "system-dev/") {
skip = map[string]bool{}
}
m := &Manifest{Entries: map[string]*ManifestEntry{}}
payload, err := Scan(root, m, ScanOptions{SkipDirNames: skip, Plan: plan})
if err != nil {
t.Fatalf("掃描失敗:%v", err)
}
return payload, plan
}
// makeMonorepoFixture 造一個「leo 的 InkStoneCo」形狀的 repo。
// 回傳「真正整理好的那份 wiki」有幾個檔——那就是唯一該被送上去的量。
func makeMonorepoFixture(t *testing.T) (root string, curatedCount int) {
t.Helper()
root = t.TempDir()
files := map[string]string{}
// ① 真正整理好的知識庫(唯一該收的)
curated := []string{"status.md", "principles.md", "mistakes.md", "decisions-summary.md", "INDEX.md"}
for _, n := range curated {
files["system-dev/wiki/"+n] = "# 整理好的知識:" + n
}
files["system-dev/wiki/cards/autonomy/自動派工心法.md"] = "# 我自己寫的卡"
curatedCount = len(curated) + 1
// ② 程式碼與一般專案檔(leo:「只有文件要讀,程式碼不用讀」)
for i := 0; i < 40; i++ {
files[fmt.Sprintf("collector/file%02d.go", i)] = "package collector"
files[fmt.Sprintf("collector/note%02d.md", i)] = "# 散落在程式碼旁邊的說明"
}
files["README.md"] = "# 專案說明"
files["CHANGELOG.md"] = "# 版本紀錄"
// ③ templatefs 範本:我們自己要鋪給別人的檔,不是誰的知識(實據:8 份)
for i := 0; i < 8; i++ {
files[fmt.Sprintf("collector/templatefs/system-dev/wiki/status%d.md", i)] = "# 範本"
}
// ④ 建置產物(實據:.next.vercel
files["landing/.next/server/pages/doc.md"] = "# 建置產物"
files["landing/.vercel/output/static/doc.md"] = "# 建置產物"
files["landing/node_modules/some-pkg/README.md"] = "# 別人的套件"
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)
}
}
// 監看根是版控中的專案
mustMkdir(t, filepath.Join(root, ".git"))
// ⑤ 出貨用 worktree`.git` 是**檔案**)——主 repo 的第二份簽出,內容重複(實據:5 份)
for i := 0; i < 5; i++ {
wt := filepath.Join(root, fmt.Sprintf("products/arcrun-rag-wt%dship", i))
mustMkdir(t, filepath.Join(wt, "system-dev", "wiki"))
if err := os.WriteFile(filepath.Join(wt, ".git"), []byte("gitdir: /elsewhere\n"), 0o644); err != nil {
t.Fatal(err)
}
for _, n := range curated {
if err := os.WriteFile(filepath.Join(wt, "system-dev", "wiki", n), []byte("# 同一份 wiki 的第 N 份副本"), 0o644); err != nil {
t.Fatal(err)
}
}
}
// ⑥ 巢狀的獨立子專案(自己有 .git 目錄)——那是別的專案的知識
sub := filepath.Join(root, "products", "arcrun-rag")
mustMkdir(t, filepath.Join(sub, ".git"))
mustMkdir(t, filepath.Join(sub, "system-dev", "wiki"))
if err := os.WriteFile(filepath.Join(sub, "system-dev", "wiki", "status.md"), []byte("# 子專案自己的知識"), 0o644); err != nil {
t.Fatal(err)
}
return root, curatedCount
}
// 🔴 #104 的驗收條件本身:接上一個開發 repo,送進去的是幾十張,不是幾千個。
func TestPlanIngest_MonorepoSendsOnlyCuratedWiki(t *testing.T) {
root, curatedCount := makeMonorepoFixture(t)
// 對照組:沒有策略時(也就是修好之前的行為)會送多少。
baseM := &Manifest{Entries: map[string]*ManifestEntry{}}
baseline, err := Scan(root, baseM, ScanOptions{SkipDirNames: map[string]bool{"system-dev": true}})
if err != nil {
t.Fatal(err)
}
payload, plan := scanWithPlan(t, root)
got := eventPaths(payload)
t.Logf("策略:%s%s", plan.Mode, plan.Reason)
t.Logf("修好之前會送:%d 個檔|現在送:%d 個檔(策略擋掉 %d 個)",
len(baseline.Events), len(got), payload.ExcludedByPlan)
for _, p := range got {
t.Logf(" → %s", p)
}
if plan.Mode != IngestCuratedWiki {
t.Fatalf("策略=%swant %s(這個 repo 有現成的 system-dev/wiki", plan.Mode, IngestCuratedWiki)
}
if len(got) != curatedCount {
t.Fatalf("送了 %d 個檔,want %d(只有整理好的那份 wiki):%v", len(got), curatedCount, got)
}
for _, p := range got {
if !strings.HasPrefix(p, "system-dev/wiki/") {
t.Fatalf("送了不該送的檔:%s", p)
}
}
// 量級檢查:這一票的實據是「差 115 倍」,修好之後不該只差一點點。
if len(baseline.Events) < len(got)*5 {
t.Fatalf("對照組只有 %d 個檔,fixture 沒造出「被淹沒」的形狀,這個測試證明不了什麼",
len(baseline.Events))
}
}
// 同一份 wiki 被出貨用 worktreetemplatefs 收十幾次——那是「48 萬筆廢資料」的同款成因。
func TestPlanIngest_NoDuplicateWikiCopies(t *testing.T) {
root, _ := makeMonorepoFixture(t)
payload, _ := scanWithPlan(t, root)
seen := map[string]int{}
for _, p := range eventPaths(payload) {
seen[filepath.Base(p)]++
}
for base, n := range seen {
if n > 1 {
t.Fatalf("%s 被收了 %d 次——同一份內容重複放大,AR-Mira 會搜出一堆一模一樣的東西", base, n)
}
}
for _, p := range eventPaths(payload) {
for _, bad := range []string{"templatefs/", "-wt0ship/", ".next/", ".vercel/", "node_modules/"} {
if strings.Contains(p, bad) {
t.Fatalf("收到了不該收的 %s(命中 %q", p, bad)
}
}
}
}
// 排除規則要看得見:使用者要知道「有幾千個檔沒被收、為什麼」,以及「其他子專案的 wiki 在哪」。
func TestPlanIngest_ExclusionsAreVisible(t *testing.T) {
root, _ := makeMonorepoFixture(t)
payload, plan := scanWithPlan(t, root)
if plan.Reason == "" {
t.Fatal("沒有給使用者一句話解釋——他只會覺得系統壞了(票上的紅線)")
}
if !strings.Contains(plan.Reason, plan.WikiRelDir) {
t.Fatalf("理由沒說出是讀了哪一份 wiki%q", plan.Reason)
}
if payload.ExcludedByPlan <= 0 {
t.Fatal("擋掉的檔案數是 0——那個數字就是「有 N 個檔沒被收」要顯示的東西")
}
// 子專案的 wiki 刻意不收,但一定要列出來讓使用者知道去哪裡找。
if len(plan.OtherWikiDirs) == 0 {
t.Fatal("沒列出子專案自己的 wiki——使用者接了 monorepo 只看到幾十張,會以為東西不見了")
}
t.Logf("理由:%s", plan.Reason)
t.Logf("擋掉 %d 個檔|其他子專案的 wiki%v", payload.ExcludedByPlan, plan.OtherWikiDirs)
}
// 沒有現成 wiki 的 repo:退到「只讀文件、不讀程式碼」(leo 明講的第三步)。
func TestPlanIngest_RepoWithoutWikiReadsDocsOnly(t *testing.T) {
root := t.TempDir()
files := map[string]string{
"README.md": "# 專案",
"docs/請假規則.md": "# 特休 14 天",
"docs/報銷政策.md": "# 每日 3000 元",
"src/main.go": "package main",
"src/說明.md": "# 散在程式碼旁邊",
"internal/notes.md": "# 也是程式碼旁邊",
}
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)
}
}
mustMkdir(t, filepath.Join(root, ".git"))
payload, plan := scanWithPlan(t, root)
got := eventPaths(payload)
t.Logf("策略:%s%s)|送:%v", plan.Mode, plan.Reason, got)
if plan.Mode != IngestDocsOnly {
t.Fatalf("策略=%swant %s", plan.Mode, IngestDocsOnly)
}
want := []string{"README.md", "docs/報銷政策.md", "docs/請假規則.md"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("送了 %vwant %v(程式碼旁邊的 .md 不該收)", got, want)
}
}
// 一般資料夾/筆記庫:行為與修這一票之前**完全一致**,全部照收。
// 這條是防止「修好 repo 卻把一般使用者的資料夾弄壞」——他們才是產品的主要客群。
func TestPlanIngest_PlainFolderUnchanged(t *testing.T) {
root := t.TempDir()
writeFixture(t, root, map[string]string{
"會議記錄.md": "# 會議",
"專案/請假規則.md": "# 特休",
"專案/深/一點/報銷.md": "# 報銷",
"src/main.go": "package main", // 非文件,本來就被副檔名白名單擋
"隨手/notes/2026.md": "# 隨手記",
})
plan := PlanIngest(root)
if plan.Mode != IngestAll {
t.Fatalf("一般資料夾的策略=%swant %s——不准因為修 repo 而改變一般使用者的行為",
plan.Mode, IngestAll)
}
payload, _ := scanWithPlan(t, root)
got := eventPaths(payload)
want := []string{"會議記錄.md", "專案/深/一點/報銷.md", "專案/請假規則.md", "隨手/notes/2026.md"}
sort.Strings(want)
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("一般資料夾送了 %vwant %v", got, want)
}
if payload.ExcludedByPlan != 0 {
t.Fatalf("一般資料夾不該有任何檔被策略擋掉,卻擋了 %d 個", payload.ExcludedByPlan)
}
}
// 空的 wiki 目錄不算「已經整理好」——不然剛裝完 template 的 repo 會一個檔都不收,
// 那比收太多更糟(使用者會以為系統壞了)。
func TestPlanIngest_EmptyWikiFallsBackToDocs(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "system-dev", "wiki"))
mustMkdir(t, filepath.Join(root, ".git"))
writeFixture(t, root, map[string]string{"docs/說明.md": "# 說明"})
plan := PlanIngest(root)
if plan.Mode != IngestDocsOnly {
t.Fatalf("wiki 是空的,策略應退到 %s,卻是 %s", IngestDocsOnly, plan.Mode)
}
}