129fe79ecb
## #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
274 lines
10 KiB
Go
274 lines
10 KiB
Go
// 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("策略=%s,want %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 被出貨用 worktree/templatefs 收十幾次——那是「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("策略=%s,want %s", plan.Mode, IngestDocsOnly)
|
||
}
|
||
want := []string{"README.md", "docs/報銷政策.md", "docs/請假規則.md"}
|
||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||
t.Fatalf("送了 %v,want %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("一般資料夾的策略=%s,want %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("一般資料夾送了 %v,want %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)
|
||
}
|
||
}
|