From 129fe79ecb458ed3c8afd0f4c435b2f1b0a8c968 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 14:41:08 +0000 Subject: [PATCH] =?UTF-8?q?fix(collector):=20daemon=20=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E6=88=B6=E7=89=88=E6=8E=A7=E4=B8=AD=E7=9A=84?= =?UTF-8?q?=E6=AA=94=E6=A1=88=EF=BC=8C=E6=8E=A5=20repo=20=E5=8F=AA?= =?UTF-8?q?=E8=AE=80=E6=95=B4=E7=90=86=E5=A5=BD=E7=9A=84=20wiki=EF=BC=88#1?= =?UTF-8?q?05=EF=BC=8F#104=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## #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 Claude-Session: https://claude.ai/code/session_01UUwsLkFEGN8496bZTqhjFq --- direct.go | 43 +++++- extract.go | 12 +- ingestplan.go | 323 ++++++++++++++++++++++++++++++++++++++++ ingestplan_test.go | 273 +++++++++++++++++++++++++++++++++ machinemark.go | 41 +++++ main.go | 17 +++ repoguard.go | 124 +++++++++++++++ repoguard_test.go | 160 ++++++++++++++++++++ scan.go | 47 +++++- tidy.go | 60 ++++++-- tidy_test.go | 4 +- vault_footprint_test.go | 4 +- vault_subdir_test.go | 32 ++-- 13 files changed, 1104 insertions(+), 36 deletions(-) create mode 100644 ingestplan.go create mode 100644 ingestplan_test.go create mode 100644 repoguard.go create mode 100644 repoguard_test.go diff --git a/direct.go b/direct.go index 9409110..0caefe8 100644 --- a/direct.go +++ b/direct.go @@ -949,13 +949,30 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta // arcrun-rag#60:把上一版 daemon 落下的舊卡歸位——沒帶 arcrun- 標記的改名, // 位置不對的(第三輪:監看根在筆記庫裡,卡卻落在看得見的 system-dev/wiki/cards/)搬進隱藏目錄。 // 自動跑而不是叫人下指令——leo 的紅線:「他不該為了保護自己的筆記去學新選項」。 - // 只碰卡片產物區(那整個目錄只有我們會寫),目標已存在就跳過、永不刪檔,見 tidy.go。 + // 目標已存在就跳過、永不刪檔,見 tidy.go。 // template 殘留不在這裡處理:那要人確認「這是不是你自己的 repo」,走 `collector tidy`。 + // + // 🔴 arcrun-rag#105:監看根在版控裡時**一個檔都不動**(MigrateCardNames 自己擋), + // 這裡負責把「沒動、以及為什麼沒動」講出來——靜默跳過等於讓使用者猜。 if !dryRun { - if n := MigrateCardNames(absRoot); n > 0 { + // daemon 自己的工作區先自我忽略,之後落卡/收容/身分標記檔才不會弄髒使用者的 + // git status(#105 驗收條件就是「跑一輪 git status 必須乾淨」)。 + EnsureWorkspaceIgnored(absRoot) + mig := MigrateCardNames(absRoot) + if mig.Moved > 0 { results = append(results, DirectResult{ Type: "warning", Path: absRoot, Status: "skipped", - Error: fmt.Sprintf("已把 %d 張舊卡片歸位(加上 arcrun- 前綴/搬離筆記軟體看得到的位置)", n), + Error: fmt.Sprintf("已把 %d 張舊卡片歸位(加上 arcrun- 前綴/搬離筆記軟體看得到的位置)", mig.Moved), + }) + } + if mig.Blocked > 0 { + results = append(results, DirectResult{ + Type: "warning", Path: absRoot, Status: "skipped", + Error: fmt.Sprintf( + "這個資料夾在版本控制裡(%s),所以有 %d 個舊卡片我沒有自動整理"+ + "——改名搬移會變成你 git status 上的刪除。要整理請自己跑:"+ + "collector tidy --folder %s(先看清單,確認後再加 --apply)", + mig.RepoRoot, mig.Blocked, absRoot), }) } } @@ -973,6 +990,23 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta for k, v := range m.Entries { preScanEntries[k] = v } + // arcrun-rag#104:走訪之前先問「這個資料夾是什麼」——是開發專案就只讀它整理好的 + // wiki(沒有 wiki 才退到文件區),是一般資料夾/筆記庫才全收。見 ingestplan.go。 + plan := PlanIngest(absRoot) + + // 🔴 #104 的一個必然後果:curated-wiki 模式要收的正是 `system-dev/wiki/`, + // 而 daemon-beta task 2 為了「template 代裝的產物區不要被當原稿」把整個 + // `system-dev` 列進 SkipDirNames——兩者直接對撞,不處理的話這個模式會一個檔都收不到。 + // + // 解法不是拿掉那條保護,是**看它保護的是誰**:task 2 擋的是「**我們自己**代裝進 + // 使用者資料夾的 template 產物」;curated-wiki 模式的前提則是「**使用者自己** + // 在他的 repo 裡整理好的知識庫」——同一個路徑,兩種身分,由 PlanIngest 分辨 + // (他的 repo 有 `.git`,我們代裝的資料夾沒有)。所以只在 curated-wiki 模式解除。 + skipDirNames := map[string]bool{"system-dev": true} + if plan.Mode == IngestCuratedWiki && strings.HasPrefix(plan.WikiRelDir, "system-dev/") { + skipDirNames = map[string]bool{} + } + payload, err := Scan(absRoot, m, ScanOptions{ MaxRemovedRatio: cfg.MaxRemoved, SkipPaths: map[string]bool{ @@ -981,7 +1015,8 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta filepath.Join(absRoot, "CLAUDE.md"): true, }, // template 代裝後 system-dev/(wiki 產物區)不得被當原稿掃進 ingest(task 2) - SkipDirNames: map[string]bool{"system-dev": true}, + SkipDirNames: skipDirNames, + Plan: plan, }) if err != nil { return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil, rootProgress{} diff --git a/extract.go b/extract.go index fe9cbb3..c85092e 100644 --- a/extract.go +++ b/extract.go @@ -73,15 +73,21 @@ const cardsRelDir = "system-dev/wiki/cards" const vaultCardsRelDir = ".arcrun-rag/wiki/cards" // cardsRelDirFor 決定某次萃取的卡片該落在哪個相對路徑: -// 監看根不在任何筆記庫範圍內=cardsRelDir(一般資料夾行為,零改變); -// 落在筆記庫範圍內=vaultCardsRelDir(隱藏目錄)。 +// 監看根既不在筆記庫、也不在版控裡=cardsRelDir(一般資料夾行為,零改變); +// 落在筆記庫或版控範圍內=vaultCardsRelDir(隱藏目錄)。 // // 🔴 arcrun-rag#60 第三輪:判準從 IsVault(只看監看根**這一層**)換成 DetectVaultContext // (監看根**在不在**某個筆記庫裡)。前者在「監看根是 vault 底下的子資料夾」時回 false, // 於是卡片落回 `<監看根>/system-dev/wiki/cards/`——那個位置就在使用者的 vault 裡面, // 而且不是隱藏目錄,Logseq/Obsidian 會把每一張卡收編成一頁。全文見 vault.go 第三輪那段。 +// +// 🔴 arcrun-rag#105:**版控中的資料夾與 vault 同待遇**。理由是同一個形狀—— +// `system-dev/wiki/cards/` 在 repo 裡不是空地,那是 template 規約路徑, +// 使用者自己的卡就住在那裡;而且不管撞不撞名,往那裡寫東西就是往他的 git status +// 裡塞 untracked 檔。落進 `.arcrun-rag/`(配 EnsureWorkspaceIgnored 自我忽略) +// =寫得進去、又不弄髒他的版控。 func cardsRelDirFor(absRoot string) string { - if DetectVaultContext(absRoot).InVault() { + if DetectVaultContext(absRoot).InVault() || UnderVersionControl(absRoot) { return vaultCardsRelDir } return cardsRelDir diff --git a/ingestplan.go b/ingestplan.go new file mode 100644 index 0000000..ebaf659 --- /dev/null +++ b/ingestplan.go @@ -0,0 +1,323 @@ +// ingestplan.go — 掃描之前先問一句「這個資料夾是什麼」,再決定要讀哪些檔 +// (arcrun-rag#104,2026-08-14 leo 規格)。 +// +// 🔴 leo 的原話就是這支檔的規格,順序不能顛倒: +// +// 「這些庫都早就萃好了,所以它要**辨識這個庫已經有 wiki,那就直接 ingest 了**」 +// 「它應該**第一步查看 wiki 裡有沒有內容**,再去辨識哪些檔案是需要讀的, +// **只有文件要讀,程式碼不用讀**」 +// +// 🔴 為什麼非做不可(實測,非推測):leo 把 5 個資料夾接上去,App 顯示 +// 12,022 份檔案、11,183 卡在佇列。光 `InkStoneCo` 一個就 8,339 份—— +// **而那個 repo 真正整理好的知識庫只有 `system-dev/wiki/` 的幾十張,差兩個數量級。** +// 更糟的是掃到 45 個名為 `wiki` 的目錄(8 份 `.claude/worktrees`、5 份出貨用 worktree、 +// 8 份 `templatefs` 範本、還有 `.next`/`.vercel` 建置產物)⇒ **同一份被收十幾次**, +// 與 2026-08-13 查明的「48 萬筆廢資料」是同款成因(重複放大)。 +// +// ⇒ 本檔回答一個問題:**這個監看根該收哪些檔**。三種答案: +// +// IngestAll 一般資料夾/筆記庫——收全部(行為與先前完全一致,零改變) +// IngestCuratedWiki 版控中的專案,而且**已經有整理好的 wiki** ⇒ 只收那一份 +// IngestDocsOnly 版控中的專案,但沒有現成 wiki ⇒ 只收文件區,程式碼一律不讀 +// +// 🔴 「不要用副檔名白名單當唯一判準」(票上的紅線):本檔的主判準是**路徑身分** +// (這個目錄在這個 repo 裡扮演什麼角色),副檔名只是最後一道。`.md` 在 repo 裡 +// 大多不是知識——是 README、是 CHANGELOG、是 template 範本、是建置產物。 +// +// 🔴 「排除規則要看得見」(票上的紅線,同 #121):本檔算出來的每一條理由都會經由 +// TriggerPayload.Plan 走進 status.json ⇒ 使用者看得到「有 8,000 個檔沒被收,因為它們是程式碼」, +// 而不是安靜地少收。 +package collector + +import ( + "os" + "path/filepath" + "sort" + "strings" +) + +// IngestMode=這個監看根的收檔策略。 +type IngestMode string + +const ( + IngestAll IngestMode = "all" // 收全部(一般資料夾/筆記庫) + IngestCuratedWiki IngestMode = "curated-wiki" // 只收現成的整理好的 wiki + IngestDocsOnly IngestMode = "docs-only" // 只收文件區 +) + +// IngestPlan=掃描前算出來的策略,連同「講給使用者聽的理由」。 +// +// 🔴 Reason 不是 debug 訊息,是**產品文案**:使用者看到「12,022 個檔只送了 32 個」 +// 的當下,唯一能讓他不慌的東西就是這句話。用他的話寫,不要寫路徑術語。 +type IngestPlan struct { + Mode IngestMode `json:"mode"` + // RepoRoot=版控工作目錄的根(Mode != IngestAll 時非空)。 + RepoRoot string `json:"repo_root,omitempty"` + // WikiRelDir=現成 wiki 的相對路徑(僅 IngestCuratedWiki)。 + WikiRelDir string `json:"wiki_rel_dir,omitempty"` + // DocRelDirs=要收的文件目錄(僅 IngestDocsOnly;根層 .md 另由 keepsRootDoc 放行)。 + DocRelDirs []string `json:"doc_rel_dirs,omitempty"` + // Reason=一句話講給使用者聽的「為什麼只收這些」。 + Reason string `json:"reason"` + // OtherWikiDirs=這個 repo 底下**其他**子專案自己的 wiki(相對路徑)。 + // 刻意不收(見 wantsPath 的說明),但一定要列出來——不然使用者只會覺得東西不見了。 + OtherWikiDirs []string `json:"other_wiki_dirs,omitempty"` +} + +// curatedWikiCandidates=「整理好的知識庫」慣例位置,依優先序。 +// `system-dev/wiki` 是 system-dev-template 的規約(leo 全部的 repo 都是這個), +// 其餘兩個是一般開源專案的慣例。 +var curatedWikiCandidates = []string{"system-dev/wiki", "docs/wiki", "wiki"} + +// docDirCandidates=沒有現成 wiki 時,「文件住在哪」的慣例位置。 +var docDirCandidates = []string{"docs", "doc", "documentation"} + +// noiseDirNames=任何模式下都整棵跳過的目錄名。 +// +// 分三類,全部都是「這個 repo 的零件,不是誰的知識」: +// - 依賴:別人的原始碼,不是使用者的 +// - 建置產物:從別的檔生出來的,收了就是同一份內容收兩次(#104 實據:`.next`/`.vercel`) +// - 範本/樣板:`templatefs` 是我們自己要鋪給別人的檔,收自己鋪的東西最荒謬 +// (#104 實據:8 份 `collector/templatefs/system-dev/wiki`) +var noiseDirNames = map[string]bool{ + // 依賴 + "node_modules": true, "vendor": true, "bower_components": true, + "site-packages": true, "venv": true, "virtualenv": true, "__pycache__": true, + "Pods": true, "Carthage": true, + // 建置產物/快取 + "dist": true, "build": true, "out": true, "target": true, "bin": true, "obj": true, + ".next": true, ".nuxt": true, ".vercel": true, ".output": true, ".turbo": true, + ".parcel-cache": true, "coverage": true, ".pytest_cache": true, ".gradle": true, + // 範本/樣板(我們自己鋪給別人的檔) + "templatefs": true, "template-fs": true, "skeleton": true, + // 測試素材(不是知識,是給程式吃的樣本) + "testdata": true, "fixtures": true, "__fixtures__": true, "__snapshots__": true, +} + +// PlanIngest 決定某個監看根的收檔策略。**這支是 #104 的入口,Scan 在走訪前呼叫一次。** +// +// 判斷順序刻意照 leo 的原話:先問「是不是專案」,再問「有沒有現成 wiki」,最後才退到文件區。 +// +// 為什麼「是不是專案」用版控(`.git`)判:那是唯一不必猜的訊號,而且與 #105 同一個判準 +// ——一個資料夾在版控裡,就代表裡面有人在追每個檔案的歷史,那幾乎必然是原始碼專案而不是 +// 誰的筆記本。判準只有一個地方(repoguard.go),兩張票共用,不會漂移。 +func PlanIngest(absRoot string) IngestPlan { + repoRoot := DetectRepoRoot(absRoot) + if repoRoot == "" { + return IngestPlan{ + Mode: IngestAll, + Reason: "這是一般資料夾,裡面的文件我全部都會讀。", + } + } + + others := otherWikiDirs(absRoot) + if wiki := findCuratedWiki(absRoot); wiki != "" { + return IngestPlan{ + Mode: IngestCuratedWiki, + RepoRoot: repoRoot, + WikiRelDir: wiki, + Reason: "這是一個開發專案,而且你已經整理好一份知識庫(" + wiki + ")——" + + "我直接讀那一份就好,不再把整個專案的原始碼與零散檔案重萃一次。", + OtherWikiDirs: others, + } + } + + docs := existingDocDirs(absRoot) + reason := "這是一個開發專案,我只讀文件、不讀程式碼。" + if len(docs) > 0 { + reason = "這是一個開發專案,我只讀文件(" + strings.Join(docs, "、") + ")與根目錄的說明檔,不讀程式碼。" + } + return IngestPlan{ + Mode: IngestDocsOnly, + RepoRoot: repoRoot, + DocRelDirs: docs, + Reason: reason, + OtherWikiDirs: others, + } +} + +// findCuratedWiki 回傳第一個「存在且真的有內容」的現成 wiki 目錄(相對路徑),沒有回空字串。 +// +// 「有內容」=底下至少有一個 `.md`。空目錄不算——不然一個剛跑完 template 安裝、 +// wiki 還沒寫的 repo 會被判成 curated-wiki,結果一個檔都不收(那比收太多更糟: +// 使用者會以為系統壞了)。 +func findCuratedWiki(absRoot string) string { + for _, rel := range curatedWikiCandidates { + dir := filepath.Join(absRoot, filepath.FromSlash(rel)) + if !isDir(dir) { + continue + } + if dirHasMarkdown(dir) { + return rel + } + } + return "" +} + +// dirHasMarkdown 淺層回答「這個目錄樹裡有沒有 .md」,找到一個就停(不走完整棵)。 +func dirHasMarkdown(dir string) bool { + found := false + _ = filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error { + if err != nil || found { + return nil + } + if d.IsDir() { + if p != dir && strings.HasPrefix(d.Name(), ".") { + return filepath.SkipDir + } + return nil + } + if strings.EqualFold(filepath.Ext(d.Name()), ".md") && !strings.HasPrefix(d.Name(), ".") { + found = true + } + return nil + }) + return found +} + +func existingDocDirs(absRoot string) []string { + var out []string + for _, rel := range docDirCandidates { + if isDir(filepath.Join(absRoot, filepath.FromSlash(rel))) { + out = append(out, rel) + } + } + return out +} + +// otherWikiDirs 找出監看根底下**其他**地方的 wiki(子專案自己的知識庫)。 +// +// 🔴 為什麼找出來卻不收:leo 的 `InkStoneCo` 底下有 `products/*`、`matrix/*` 這些 +// **各自獨立的 repo**,每一個都有自己的 `system-dev/wiki`。它們是**別的專案**的知識, +// 混進這個資料夾的知識庫裡,AR-Mira 搜一個主題就會回一堆分不清屬於誰的東西。 +// 要收哪一個,是使用者的決定——把那個子專案自己加進看守清單即可。 +// +// 但**一定要講出來**:使用者接了一個 monorepo 卻只看到 32 張卡,不告訴他其餘的在哪, +// 他只會覺得東西不見了(票上的紅線:不要讓他猜)。 +// +// 走訪時套用與 Scan 相同的跳過規則(隱藏目錄、noise、linked worktree、 +// 已知的 curated 位置自己),所以出貨用 worktree 與 templatefs 的那十幾份不會列進來。 +func otherWikiDirs(absRoot string) []string { + seen := map[string]bool{} + for _, rel := range curatedWikiCandidates { + seen[rel] = true + } + var out []string + _ = filepath.WalkDir(absRoot, func(p string, d os.DirEntry, err error) error { + if err != nil || !d.IsDir() || p == absRoot { + return nil + } + name := d.Name() + if strings.HasPrefix(name, ".") || noiseDirNames[name] { + return filepath.SkipDir + } + if IsLinkedWorktree(p) { + return filepath.SkipDir // 同一個 repo 的第二份簽出,內容重複 + } + rel, rerr := filepath.Rel(absRoot, p) + if rerr != nil { + return nil + } + relSlash := filepath.ToSlash(rel) + if name == "wiki" && !seen[relSlash] && dirHasMarkdown(p) { + out = append(out, relSlash) + return filepath.SkipDir + } + return nil + }) + sort.Strings(out) + return out +} + +// ───────────────────────────────────────────────────────────────────────────── +// 走訪判準:Scan 在 WalkDir 裡逐目錄/逐檔問這兩支 +// ───────────────────────────────────────────────────────────────────────────── + +// SkipsDir 回答「走訪時要不要整棵跳過這個目錄」。relSlash 是相對監看根的路徑。 +// +// 三類跳過,全模式適用: +// 1. noise(依賴/建置產物/範本)——名字判準,見 noiseDirNames +// 2. linked worktree——`.git` 是檔案。#104 實據裡的 `.claude/worktrees/…` 與 +// `products/arcrun-rag-wt60ship` 全是這一種:主 repo 的第二份簽出,收了就是重複 +// 3. 巢狀 repo(自己有 `.git` 的子目錄)——那是別的專案,見 otherWikiDirs 的說明 +// +// 再加上模式限定的:curated-wiki 只走那一份 wiki 的路;docs-only 只走文件目錄的路。 +// (隱藏目錄由 Scan 自己擋,那條規則比本檔更早存在,不搬過來。) +func (p IngestPlan) SkipsDir(relSlash, absPath string) bool { + name := filepath.Base(relSlash) + if noiseDirNames[name] { + return true + } + if IsLinkedWorktree(absPath) { + return true + } + // 巢狀 repo:監看根自己不算(relSlash == "." 走不到這裡,Scan 只對子目錄呼叫)。 + if IsRepoRoot(absPath) { + return true + } + switch p.Mode { + case IngestCuratedWiki: + // 只有「通往那份 wiki 的路」與「那份 wiki 底下」要走。 + return !onPathTo(relSlash, p.WikiRelDir) + case IngestDocsOnly: + for _, d := range p.DocRelDirs { + if onPathTo(relSlash, d) { + return false + } + } + return true + } + return false +} + +// KeepsFile 回答「這個檔要不要收」。relSlash 是相對監看根的路徑。 +// +// curated-wiki:只收那份 wiki 底下的檔。 +// docs-only:收文件目錄底下的檔,外加**根層的說明檔**(README/CONTRIBUTING 那些是 +// 專案唯一的入門文件,把它們漏掉,一個只有 README 的 repo 會變成一個檔都不收)。 +// all:全收,判準交回 Scan 原本的副檔名白名單。 +func (p IngestPlan) KeepsFile(relSlash string) bool { + switch p.Mode { + case IngestCuratedWiki: + return strings.HasPrefix(relSlash, p.WikiRelDir+"/") + case IngestDocsOnly: + for _, d := range p.DocRelDirs { + if strings.HasPrefix(relSlash, d+"/") { + return true + } + } + return !strings.Contains(relSlash, "/") // 根層說明檔 + } + return true +} + +// OverridesTemplateOwned 回答「這個檔雖然被 TemplateOwns 認成『開發用的』, +// 但本次策略仍然要收它嗎」。 +// +// 🔴 只有 curated-wiki 模式、且只針對那份 wiki 底下的檔會回 true。 +// +// 為什麼需要這個開關:`TemplateOwns` 把 `system-dev/` 整棵當成「開發用的、不該進知識庫」 +// (2026-08-06,理由是「我們代裝進使用者資料夾的 template 產物不是他的知識」)。 +// 但 leo 2026-08-14 的規格要的正是那個位置——`system-dev/wiki/` 在**他自己的 repo** 裡 +// 是他親手整理的知識庫,而且是唯一該收的東西。 +// +// 兩條規則對撞,用**身分**化解而不是拿掉任何一條: +// - 我們代裝的資料夾沒有 `.git` ⇒ PlanIngest 回 IngestAll ⇒ 這裡回 false ⇒ 舊規則照舊 +// - 他自己的 repo 有 `.git` 且有現成 wiki ⇒ curated-wiki ⇒ 這裡回 true ⇒ 收那份 wiki +func (p IngestPlan) OverridesTemplateOwned(relSlash string) bool { + if p.Mode != IngestCuratedWiki { + return false + } + return strings.HasPrefix(relSlash, p.WikiRelDir+"/") +} + +// onPathTo 回答「relSlash 是不是 target 的祖先、target 自己、或 target 的子孫」 +// ——也就是走訪時「這條路要不要繼續走下去」。 +func onPathTo(relSlash, target string) bool { + if relSlash == target || + strings.HasPrefix(relSlash, target+"/") || + strings.HasPrefix(target, relSlash+"/") { + return true + } + return false +} diff --git a/ingestplan_test.go b/ingestplan_test.go new file mode 100644 index 0000000..3613d10 --- /dev/null +++ b/ingestplan_test.go @@ -0,0 +1,273 @@ +// 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) + } +} diff --git a/machinemark.go b/machinemark.go index de7bc5f..0572009 100644 --- a/machinemark.go +++ b/machinemark.go @@ -55,3 +55,44 @@ func IsMarked(base string) bool { func UnmarkName(base string) string { return strings.TrimPrefix(base, MachineMark) } + +// IsMachineOwnedRel 回答「這個相對路徑是不是我們寫的東西」——**規約的完整判準**, +// 迴歸網(vault_footprint_test.go/vault_subdir_test.go)用的就是這一支。 +// +// 兩條路,第二條是規約唯一認可的例外: +// +// ① basename 帶 MachineMark ⇒ 走在使用者的檔案之間也一眼分得出(規約主體) +// ② 路徑上**任何一層**是 `.arcrun-rag/` ⇒ **那個目錄名自己就是標記**,且它是隱藏目錄 +// +// ②「任何一層」而不是「開頭」:呼叫端給的相對起點不一定是監看根——迴歸網比對整個 +// 筆記庫時,同一個檔的相對路徑是 `docs/.arcrun-rag/…`(監看根是 vault 底下的 docs/)。 +// 判「誰擁有這個檔」不該受呼叫端從哪裡起算影響。 +// +// 🔴 為什麼需要 ②(arcrun-rag#105):我們得在使用者的 repo 裡放一份 `.gitignore` +// (讓整個工作區對 git 隱形,他跑一輪 `git status` 才會是乾淨的)。那個檔名是 +// **git 定的,我們改不得**——叫 `arcrun-.gitignore` 的話 git 根本不認,功能等於沒做。 +// +// 這不是替規約開後門:規約的目的是「打開資料夾一眼分得出哪些是機器寫的」, +// 而 `.arcrun-rag/` 這個目錄名把整叢東西一次講完,比逐檔前綴**更強**。 +// ⚠️ 例外只有這一個。監看根底下**其他任何位置**的新檔,一律得帶前綴—— +// 想在別處寫一個「名字不能改」的檔時,先想清楚那個檔為什麼不能住進 `.arcrun-rag/`。 +func IsMachineOwnedRel(relSlash string) bool { + if IsMarked(pathBase(relSlash)) { + return true + } + for _, seg := range strings.Split(relSlash, "/") { + if seg == workspaceRelDir { + return true + } + } + return false +} + +// pathBase 取斜線路徑的最後一段(不用 filepath.Base:這裡的輸入一律是斜線分隔的 +// 相對路徑,不該受執行平台的分隔符影響)。 +func pathBase(relSlash string) string { + if i := strings.LastIndex(relSlash, "/"); i >= 0 { + return relSlash[i+1:] + } + return relSlash +} diff --git a/main.go b/main.go index 236c924..12a7f89 100644 --- a/main.go +++ b/main.go @@ -163,13 +163,30 @@ func run(args []string, mode runMode) int { if err != nil { return fail(err) } + // arcrun-rag#104:CLI 與 daemon 走同一套收檔策略——兩邊對同一個資料夾得出不同答案 + // 本身就是 bug(同 vault.go 開頭那條「安裝器與 daemon 必須一致」)。 + // 這裡刻意**不**動 CLI 原有的 SkipDirNames 行為(它本來就沒有;`system-dev/` 由 + // TemplateOwns 擋,curated-wiki 模式的解除也走 Plan 那條路,見 scan.go)。 + plan := PlanIngest(absRoot) payload, err := Scan(absRoot, m, ScanOptions{ MaxRemovedRatio: *ratio, SkipPaths: map[string]bool{absManifest: true}, // manifest 若住在 root 底下,不掃自己 + Plan: plan, }) if err != nil { return fail(err) } + // 策略走 stderr:stdout 是給程式吃的 payload JSON,不能污染。 + // 印出來不是 debug 訊息,是 #104 的紅線——排除規則要看得見。 + fmt.Fprintf(os.Stderr, "收檔策略:%s — %s\n", plan.Mode, plan.Reason) + if payload.ExcludedByPlan > 0 { + fmt.Fprintf(os.Stderr, "依這個策略跳過了 %d 個檔案。\n", payload.ExcludedByPlan) + } + if len(plan.OtherWikiDirs) > 0 { + fmt.Fprintf(os.Stderr, + "這個資料夾底下還有 %d 個子專案有自己的知識庫,我沒有收(要收請個別加進看守清單):%v\n", + len(plan.OtherWikiDirs), plan.OtherWikiDirs) + } exitCode := 0 diff --git a/repoguard.go b/repoguard.go new file mode 100644 index 0000000..34bc58c --- /dev/null +++ b/repoguard.go @@ -0,0 +1,124 @@ +// repoguard.go — 認出「這個監看根是版本控制中的工作目錄」,並據此把 daemon 的手綁起來 +// (arcrun-rag#105,2026-08-14 leo 實撞)。 +// +// 🔴 為什麼要有這支檔: +// +// 2026-08-14 21:45,`InkStoneCo` 在看守清單裡,daemon 回報「已把 1 張舊卡片歸位」, +// 實際做的事是把 `system-dev/wiki/cards/autonomy/` 整個子目錄**壓平+改名**: +// `cards/autonomy/X.md` → `cards/arcrun-X.md`,**16 個版控中的檔案顯示為刪除**。 +// 那些是 leo 自己寫的知識卡,不是 daemon 的產物。 +// +// 程式沒有做錯它以為自己在做的事——`MigrateCardNames` 的註解白紙黑字寫著 +// 「那兩個目錄從頭到尾只有 daemon 會寫」。**錯的是那個假設**: +// `system-dev/wiki/cards/` 是 system-dev-template 的規約路徑,而 template 的用途 +// 正是「裝進開發者自己的 repo」——所以在**任何裝過 template 的 repo 裡**, +// 那個目錄裡本來就有人家自己的檔案。「只有我們會寫」在 vault 上成立,在 repo 上不成立。 +// +// 🔴 判準:`.git` 的存在。這夠不夠?(#105 明著問了這題) +// +// 夠,而且它是我們手上**唯一**不必猜的訊號: +// `.git` 在=這裡的每個檔案都有人在追它的歷史,改名/搬移都會變成 `git status` 上的 +// 刪除+新增,使用者一個 `git add -A` 就把我們的手筆 commit 進他的歷史。 +// 它會不會漏(hg/svn/沒版控但很珍貴的資料夾)?會。但漏判的代價是「少整理一次」, +// 誤判的代價是「改掉別人版控中的檔案」——**代價不對稱,就往安全那邊倒**。 +// +// ⇒ 兩條規則,本檔負責第一條,落點那條在 extract.go: +// +// ① 版控中的資料夾:**一個檔都不自動動**(改名/搬移一律停手,改成報告給使用者)。 +// ② daemon 自己要寫的東西(卡片、工作區):落進 `.arcrun-rag/`,並讓那個目錄 +// 對 git 完全隱形(見 EnsureWorkspaceIgnored)⇒ 跑完一輪 `git status` 是乾淨的。 +package collector + +import ( + "os" + "path/filepath" +) + +// workspaceRelDir=daemon 在監看根底下唯一有權寫入的目錄。 +// 卡片產物區(vaultCardsRelDir)與收容處(legacyTemplateRelDir)都住在它底下。 +const workspaceRelDir = ".arcrun-rag" + +// DetectRepoRoot 從 absPath 自己開始往上找最近的版控工作目錄,回傳它的根; +// 找不到回空字串。 +// +// 認的是 `.git` **存在**,不分目錄或檔案: +// - 目錄=一般 clone。 +// - 檔案=linked worktree(`git worktree add` 產生,內容是 `gitdir: …`)。 +// 那一種同樣是版控中的工作目錄,同樣不能亂動;而且 #104 的實據裡 +// (`.claude/worktrees/…`、`products/arcrun-rag-wt60ship`)滿地都是。 +// +// 停止條件與 DetectVaultContext 同一套保守原則:走到檔案系統根或使用者家目錄就停 +// (家目錄本身若是個 repo——有人會 `git init ~`——不拿它把底下每個監看根都判成版控, +// 那種病態擺法的漏判,遠比誤判所有人便宜)。 +func DetectRepoRoot(absPath string) string { + dir := filepath.Clean(absPath) + home := "" + if h, err := os.UserHomeDir(); err == nil && h != "" { + home = filepath.Clean(h) + } + for { + if home != "" && dir == home { + return "" // 家目錄(含)以上不猜 + } + if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + return "" // 檔案系統根 + } + dir = parent + } +} + +// UnderVersionControl 是 DetectRepoRoot 的布林簡寫。 +func UnderVersionControl(absPath string) bool { return DetectRepoRoot(absPath) != "" } + +// IsLinkedWorktree 回答「這個目錄是不是 git 的 linked worktree 根」 +// (`.git` 是檔案而非目錄)。#104 用它整棵跳過出貨用 worktree—— +// 那些是主 repo 的第二份簽出,內容與主 repo 相同,收進去就是同一份收十幾次。 +func IsLinkedWorktree(absDir string) bool { + info, err := os.Lstat(filepath.Join(absDir, ".git")) + return err == nil && !info.IsDir() +} + +// IsRepoRoot 回答「這一層自己就是版控工作目錄的根」(不往上找)。 +// #104 用它認出巢狀的子專案:監看根底下的獨立 repo 有自己的知識, +// 但那是**另一個專案**,要收請把它自己加進看守清單。 +func IsRepoRoot(absDir string) bool { + _, err := os.Lstat(filepath.Join(absDir, ".git")) + return err == nil +} + +// workspaceIgnoreBody=寫進 `.arcrun-rag/.gitignore` 的內容。 +// +// `*` 讓這個目錄底下的一切(含 .gitignore 自己)對 git 隱形 ⇒ 整個 `.arcrun-rag/` +// 不會出現在 `git status` 的 untracked 清單裡。**這正是 #105 驗收條件的機制**: +// daemon 要有地方放東西,但使用者的 `git status` 必須乾淨。 +// +// 為什麼不去改使用者的 `.gitignore`:那是他版控中的檔案,改它就是我們自己犯的錯 +// (同一條紅線)。自帶一份放在自己的目錄裡,git 一樣認,而且他刪掉整個 +// `.arcrun-rag/` 就等於把我們留下的痕跡清乾淨,不留殘渣在他的檔案裡。 +const workspaceIgnoreBody = "# 這個資料夾是 Arcrun RAG 同步小幫手的工作區,不是你的檔案。\n" + + "# `*` 讓整個目錄對 git 隱形,你的 git status 不會因為我們跑過而變髒。\n" + + "# 整個資料夾可以安全刪除(下次同步會重建,已同步的紀錄會重來一次)。\n" + + "*\n" + +// EnsureWorkspaceIgnored 確保 `/.arcrun-rag/.gitignore` 存在。 +// +// 只在監看根落在版控範圍內時才需要(非版控資料夾沒有 git status 可弄髒), +// 但無條件呼叫也無害——多一個隱藏檔,不影響 vault(點開頭目錄筆記軟體本來就不掃)。 +// +// 冪等且不覆蓋:檔案已存在就什麼都不做(使用者可能自己改過內容)。 +// 失敗一律靜默——寫不進去最多是 git status 多一行 untracked,不值得中斷同步。 +func EnsureWorkspaceIgnored(absRoot string) { + dir := filepath.Join(absRoot, workspaceRelDir) + target := filepath.Join(dir, ".gitignore") + if _, err := os.Stat(target); err == nil { + return + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return + } + _ = os.WriteFile(target, []byte(workspaceIgnoreBody), 0o644) +} diff --git a/repoguard_test.go b/repoguard_test.go new file mode 100644 index 0000000..a3d5fed --- /dev/null +++ b/repoguard_test.go @@ -0,0 +1,160 @@ +// repoguard_test.go — arcrun-rag#105 的驗收網:**把一個 git repo 加進看守清單跑一輪, +// git status 必須乾淨。** +// +// 這張網刻意分兩層: +// - 單元層(不需要 git 執行檔):MigrateCardNames 在版控資料夾裡一個檔都沒動。 +// 這是 2026-08-14 那件事的直接迴歸——當時被壓平改名的正是 +// `system-dev/wiki/cards/autonomy/*.md`。 +// - 端到端層(需要 git):真的 `git init`、真的 commit、真的跑一輪同步、 +// 真的 `git status --porcelain` ⇒ 必須是空的。沒有 git 就 skip,不假裝驗過。 +package collector + +import ( + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "testing" +) + +// gitOrSkip 準備一個「已 commit 乾淨」的 repo;環境沒有 git 就 skip。 +func gitOrSkip(t *testing.T, dir string) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("這台機器沒有 git,跳過端到端層(單元層仍然有跑)") + } + for _, args := range [][]string{ + {"init", "-q"}, + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "test"}, + {"add", "-A"}, + {"commit", "-q", "-m", "初始"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v 失敗:%v\n%s", args, err, out) + } + } +} + +func gitStatus(t *testing.T, dir string) []string { + t.Helper() + cmd := exec.Command("git", "status", "--porcelain") + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git status 失敗:%v\n%s", err, out) + } + var lines []string + for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if strings.TrimSpace(l) != "" { + lines = append(lines, l) + } + } + return lines +} + +// 🔴 2026-08-14 21:45 那件事的直接迴歸:leo 的 `InkStoneCo` 在看守清單裡, +// daemon 把 `system-dev/wiki/cards/autonomy/` 整個子目錄壓平改名, +// 16 個版控中的檔案顯示為刪除。 +func TestMigrateCardNames_VersionedRepoUntouched(t *testing.T) { + root := t.TempDir() + // leo 自己寫的知識卡,住在 template 規約的卡片產物區底下(還有自己開的子目錄)。 + writeFixture(t, root, map[string]string{ + "system-dev/wiki/cards/autonomy/自動派工心法.md": "# 我自己寫的", + "system-dev/wiki/cards/autonomy/接關術.md": "# 我自己寫的", + "system-dev/wiki/cards/決策紀錄.md": "# 我自己寫的", + }) + // 這個資料夾在版控裡(只放 .git 目錄就夠——判準不需要 git 執行檔)。 + mustMkdir(t, filepath.Join(root, ".git")) + + before := snapshotTree(t, root) + mig := MigrateCardNames(root) + after := snapshotTree(t, root) + + if mig.Moved != 0 { + t.Fatalf("在版控資料夾裡動了 %d 個檔——這正是 #105 的病", mig.Moved) + } + if mig.RepoRoot != root { + t.Fatalf("沒認出這是版控資料夾(RepoRoot=%q,want %q)", mig.RepoRoot, root) + } + // Blocked 只算「確定是我們寫的」——使用者自己的卡不該被算進去報一個假數字。 + if mig.Blocked != 0 { + t.Fatalf("Blocked=%d,但這三張都是使用者自己的卡(沒帶 arcrun- 標記),"+ + "報進去等於告訴他「有 3 個舊卡片沒整理」,那是假訊息", mig.Blocked) + } + + if len(before) != len(after) { + t.Fatalf("檔案數變了:%d → %d", len(before), len(after)) + } + for rel, want := range before { + got, still := after[rel] + if !still { + t.Fatalf("版控中的檔案不見了:%s(這就是 leo 看到的那 16 個刪除)", rel) + } + if got.hash != want.hash { + t.Fatalf("版控中的檔案被改了:%s", rel) + } + } +} + +// 我們自己寫的卡(帶標記、只是位置舊)在版控資料夾裡同樣不自動動, +// 但**要被算進 Blocked 並講給使用者聽**——那個數字是誠實的,因為那些確實是我們的。 +func TestMigrateCardNames_VersionedRepoReportsOwnCards(t *testing.T) { + root := t.TempDir() + writeFixture(t, root, map[string]string{ + "system-dev/wiki/cards/arcrun-舊卡.md": "# 我們寫的,位置舊了", + "system-dev/wiki/cards/他自己的卡.md": "# 使用者的", + }) + mustMkdir(t, filepath.Join(root, ".git")) + + mig := MigrateCardNames(root) + if mig.Moved != 0 { + t.Fatalf("版控資料夾裡不該動任何檔,卻動了 %d 個", mig.Moved) + } + if mig.Blocked != 1 { + t.Fatalf("Blocked=%d,want 1(只有那張帶 arcrun- 標記的是我們的)", mig.Blocked) + } +} + +// 非版控資料夾的行為必須與 #60 第三輪完全一致——本次改動不准把那兩輪的成果弄壞。 +func TestMigrateCardNames_NonRepoStillMigrates(t *testing.T) { + root := t.TempDir() + writeFixture(t, root, map[string]string{ + "system-dev/wiki/cards/舊卡.md": "# 舊", + }) + mig := MigrateCardNames(root) + if mig.Moved != 1 { + t.Fatalf("非版控資料夾應照舊自動歸位,Moved=%d want 1", mig.Moved) + } + if _, err := os.Stat(filepath.Join(root, "system-dev", "wiki", "cards", "arcrun-舊卡.md")); err != nil { + t.Fatalf("舊卡沒被改名:%v", err) + } +} + +// 🔴 #105 的驗收條件本身:**把一個 git repo 加進看守清單跑一輪,git status 必須乾淨。** +func TestSyncOnce_GitRepoStaysClean(t *testing.T) { + root := t.TempDir() + writeFixture(t, root, map[string]string{ + "system-dev/wiki/cards/autonomy/自動派工心法.md": "# 我自己寫的知識卡", + "system-dev/wiki/status.md": "# 現況", + "docs/請假規則.md": "# 特休 14 天", + "README.md": "# 這個專案", + "main.go": "package main", + }) + gitOrSkip(t, root) + + if dirty := gitStatus(t, root); len(dirty) > 0 { + t.Fatalf("前置條件就不乾淨:%v", dirty) + } + + runSyncOnce(t, root) + + if dirty := gitStatus(t, root); len(dirty) > 0 { + sort.Strings(dirty) + t.Fatalf("跑完一輪後 git status 髒了(#105 的驗收條件就是這個):\n%s", + strings.Join(dirty, "\n")) + } +} diff --git a/scan.go b/scan.go index 0cec4ae..c92c33e 100644 --- a/scan.go +++ b/scan.go @@ -118,6 +118,13 @@ type TriggerPayload struct { // DuplicateFormats=本輪偵測到、同檔名主幹的多格式重複(2026-08-07,見 FormatDuplicate)。 // 同 Skipped:只給本機使用者看,不隨 payload 送雲端(schema additionalProperties:false 會擋)。 DuplicateFormats []FormatDuplicate `json:"-"` + + // Plan/ExcludedByPlan=這一輪用了什麼收檔策略、據此擋掉幾個檔(arcrun-rag#104)。 + // 同上,`json:"-"`:給本機使用者看的,不送雲端。 + // 🔴 這兩個欄位就是 #104 那條紅線的載體——「排除規則要看得見」。少了它們, + // 使用者接上一個一萬檔的 repo 只看到 32 個進度,會以為系統壞了。 + Plan IngestPlan `json:"-"` + ExcludedByPlan int `json:"-"` } // FormatDuplicate=同一份內容被偵測到有多種格式並存(同檔名主幹、不同副檔名)。 @@ -217,6 +224,9 @@ type ScanOptions struct { // SkipDirNames:目錄名黑名單(任一層命中整棵跳過)。daemon-beta task 2: // template 代裝後 `system-dev/`(wiki 產物區)不得被當成原稿掃進 ingest。 SkipDirNames map[string]bool + // Plan:收檔策略(arcrun-rag#104)。零值=IngestAll,行為與加這個欄位之前**完全一致** + // ——既有呼叫端與測試不必全部改。要拿到 #104 的效果就傳 PlanIngest(root)。 + Plan IngestPlan } const DefaultMaxRemovedRatio = 0.4 @@ -259,11 +269,21 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { var skipped []SkippedFile skippedOther := 0 var skippedOtherNames []string + // arcrun-rag#104:被策略擋掉的檔案數。**一定要數出來**——票上的紅線是 + // 「用戶要知道有 8,000 個檔沒被收,因為它們是程式碼」,不是安靜地少收。 + excludedByPlan := 0 err := filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error { if werr != nil { return werr } name := d.Name() + relOf := func() (string, bool) { + rel, rerr := filepath.Rel(root, p) + if rerr != nil { + return "", false + } + return filepath.ToSlash(rel), true + } if d.IsDir() { if p != root && strings.HasPrefix(name, ".") { return filepath.SkipDir // 隱藏目錄(.git、.obsidian…)整棵跳過 @@ -271,11 +291,23 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { if p != root && opts.SkipDirNames[name] { return filepath.SkipDir // 名單目錄(system-dev…)整棵跳過 } + // #104:依策略整棵跳過(依賴/建置產物/範本/worktree/巢狀 repo/ + // 以及非本次策略要收的區域)。整棵跳掉的檔不逐一計數—— + // 那個數字對使用者沒有意義,Plan.Reason 那句話才是他要的解釋。 + if p != root { + if rel, ok := relOf(); ok && opts.Plan.SkipsDir(rel, p) { + return filepath.SkipDir + } + } return nil } if strings.HasPrefix(name, ".") { return nil } + if rel, ok := relOf(); ok && !opts.Plan.KeepsFile(rel) { + excludedByPlan++ + return nil + } if abs, aerr := filepath.Abs(p); aerr == nil && opts.SkipPaths[abs] { return nil } @@ -284,8 +316,17 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { // template 本來就不是隱藏的,靠隱藏判斷會漏掉一大半。 // 也不計進「有 N 個檔案沒有被整理」——那是給使用者看他自己的檔案的, // 我們自己鋪的東西不該佔用他的注意力。 - if rel, rerr := filepath.Rel(root, p); rerr == nil && TemplateOwns(filepath.ToSlash(rel)) { - return nil + // + // 🔴 arcrun-rag#104 例外:curated-wiki 模式下,`system-dev/wiki/` **正是要收的那一份**。 + // 上面那條規則(2026-08-06)與 leo 2026-08-14 的規格直接對撞,衝突由身分化解: + // · 我們代裝 template 的資料夾(沒有 `.git`)⇒ 那些檔是**我們鋪的**,照舊不收 + // · 使用者自己的 repo(有 `.git`)⇒ 那份 wiki 是**他寫的**,正是他要我們讀的 + // 判準只有 PlanIngest 一個地方,見 ingestplan.go。 + if rel, rerr := filepath.Rel(root, p); rerr == nil { + relSlash := filepath.ToSlash(rel) + if TemplateOwns(relSlash) && !opts.Plan.OverridesTemplateOwned(relSlash) { + return nil + } } ext := strings.ToLower(filepath.Ext(name)) if !allowedExt[ext] { @@ -505,5 +546,7 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { SkippedOther: skippedOther, SkippedOtherNames: skippedOtherNames, DuplicateFormats: duplicateFormats, + Plan: opts.Plan, + ExcludedByPlan: excludedByPlan, }, nil } diff --git a/tidy.go b/tidy.go index a04b32c..abbd2f1 100644 --- a/tidy.go +++ b/tidy.go @@ -192,18 +192,56 @@ func collectTemplateItems(absRoot string, isVault bool) []TidyItem { return items } +// CardMigration=MigrateCardNames 一輪的帳目。 +// +// Blocked 不是「失敗」,是**刻意沒動**:那個資料夾在版控裡,我們沒有資格自己決定 +// 改別人版控中的檔案。它要被講出來(direct.go 收進 results ⇒ status.json ⇒ App), +// 不是靜默跳過——#105 的紅線與 #104 同一條:「不要讓用戶猜」。 +type CardMigration struct { + Moved int // 真的改名/搬移了幾個 + Blocked int // 認出是舊卡、但因為在版控裡而沒動的 + RepoRoot string // Blocked > 0 時:那個版控工作目錄的根 +} + // MigrateCardNames 把卡片產物區裡「位置不對或沒帶標記」的舊卡歸位。daemon 每輪自動呼叫。 -// 回傳實際動到的筆數。**只碰卡片產物區**,其餘一概不動。 +// **只碰卡片產物區**,其餘一概不動。 // -// 為什麼可以自動:那兩個目錄從頭到尾只有 daemon 會寫(見 cardsRelDirFor 的註解), -// 不存在「誤把使用者的檔案改名」的可能。目標已存在就跳過,不覆蓋。 +// 🔴 第四輪=arcrun-rag#105(2026-08-14 實撞):**版控中的資料夾一個檔都不動。** // -// 🔴 第三輪起也負責**搬移**(不只改名):監看根在 vault 裡時,舊版寫在可見目錄的卡 -// 會被搬進 `.arcrun-rag/wiki/cards/`。這是自動的,因為紅線寫著「不准要使用者去設定 -// 什麼開關才能保護自己的筆記」——他不該為了收拾機器留下的東西去學一個新指令。 -func MigrateCardNames(absRoot string) int { - n := 0 - for _, it := range collectCardItems(absRoot) { +// 前三輪(#60,f71e5a2/578ac2b)修的都是同一個場景的深度——「卡片會不會汙染 +// 使用者的**筆記庫**」,方向一路往「更自動」走,因為那條紅線是「不准要使用者 +// 去設定什麼開關才能保護自己的筆記」。**本輪不推翻那個方向**:非版控資料夾 +// (含 vault)的行為與 578ac2b 完全一致。 +// +// 本輪修的是另一個場景——「監看的是他的 **git repo**」。上一版這裡的理由是 +// 「那兩個目錄從頭到尾只有 daemon 會寫,不存在誤把使用者的檔案改名的可能」。 +// 那句話在 repo 上錯了,而且錯得很貴:`system-dev/wiki/cards/` 是 +// system-dev-template 的規約路徑,而 template 就是要裝進**開發者自己的 repo**—— +// leo 的 `InkStoneCo` 裡那個目錄底下是他自己寫的知識卡(還有 `autonomy/` 這種 +// 他自己開的子目錄)。daemon 把它們壓平改名,**16 個版控中的檔案當場變成刪除**。 +// +// ⇒ 判準加一問「這裡有沒有 `.git`」(見 repoguard.go 對「這夠不夠」的討論)。 +// 有=停手、記帳、講給使用者聽,要整理請他自己跑 `collector tidy --apply` +// (那條路預設 dry-run,會先把要動什麼列給他看)。 +// 兩條紅線不衝突:「不要他學新選項才能保護筆記」管的是筆記庫; +// repo 那邊 leo 的原話是「**預設應該是不動,要動要有出口**」。 +func MigrateCardNames(absRoot string) CardMigration { + items := collectCardItems(absRoot) + if repoRoot := DetectRepoRoot(absRoot); repoRoot != "" { + // 只把**確定是我們寫的**(帶 arcrun- 標記、只是位置不對)算進 Blocked。 + // 沒帶標記的在 repo 裡預設就是使用者自己的檔案——把它們算進來,訊息就會變成 + // 「有 40 個舊卡片沒整理」,而那 40 張是 leo 自己寫的知識卡, + // 報一個全錯的數字比不報更糟(#105 要的是誠實,不是熱心)。 + blocked := 0 + for _, it := range items { + if IsMarked(filepath.Base(it.Rel)) { + blocked++ + } + } + return CardMigration{Blocked: blocked, RepoRoot: repoRoot} + } + out := CardMigration{} + for _, it := range items { from := filepath.Join(absRoot, filepath.FromSlash(it.Rel)) to := filepath.Join(absRoot, filepath.FromSlash(it.To)) if _, err := os.Stat(to); err == nil { @@ -213,10 +251,10 @@ func MigrateCardNames(absRoot string) int { continue } if err := os.Rename(from, to); err == nil { - n++ + out.Moved++ } } - return n + return out } // Tidy 掃出所有舊產物;apply=false 只報告,apply=true 才真的改名/搬移。 diff --git a/tidy_test.go b/tidy_test.go index 858ff95..1b71f11 100644 --- a/tidy_test.go +++ b/tidy_test.go @@ -250,8 +250,8 @@ func TestMigrateCardNames_TouchesOnlyCardDirs(t *testing.T) { t.Fatal(err) } - if n := MigrateCardNames(root); n != 1 { - t.Fatalf("改名筆數=%d,want 1", n) + if mig := MigrateCardNames(root); mig.Moved != 1 { + t.Fatalf("改名筆數=%d,want 1", mig.Moved) } if _, err := os.Stat(filepath.Join(cardDir, "arcrun-舊卡.md")); err != nil { t.Fatalf("舊卡沒被改名:%v", err) diff --git a/vault_footprint_test.go b/vault_footprint_test.go index fbb47a8..f645598 100644 --- a/vault_footprint_test.go +++ b/vault_footprint_test.go @@ -213,7 +213,7 @@ func TestVaultFootprint_EveryNewFileIsMarked(t *testing.T) { continue } added = append(added, rel) - if !IsMarked(filepath.Base(rel)) { + if !IsMachineOwnedRel(rel) { unmarked = append(unmarked, rel) } } @@ -312,7 +312,7 @@ func TestPlainFolderFootprint_EveryNewFileIsMarked(t *testing.T) { continue } newCount++ - if !IsMarked(filepath.Base(rel)) { + if !IsMachineOwnedRel(rel) { t.Fatalf("一般資料夾也不准有沒帶標記的新檔:%s", rel) } } diff --git a/vault_subdir_test.go b/vault_subdir_test.go index 891ec71..c876d46 100644 --- a/vault_subdir_test.go +++ b/vault_subdir_test.go @@ -262,7 +262,7 @@ func assertVaultUntouched(t *testing.T, vaultDir string, before, after map[strin t.Logf("筆記庫裡新增的檔案(%d 個):%v", len(added), added) for _, rel := range added { - if !IsMarked(filepath.Base(rel)) { + if !IsMachineOwnedRel(rel) { t.Fatalf("筆記庫裡多了一個沒帶 %q 標記的檔:%s", MachineMark, rel) } if !isHiddenRel(rel) { @@ -324,11 +324,19 @@ func TestVaultFootprint_WatchRootInsideVault(t *testing.T) { if len(added) == 0 { t.Fatal("這一輪什麼都沒產出——紅線是「東西照樣產出、但不撞進使用者的命名空間」") } - // 卡片確實落在 docs/.arcrun-rag/... 底下(在監看根裡,且是隱藏的)。 - wantPrefix := filepath.ToSlash(filepath.Join("docs", vaultCardsRelDir)) + "/" + // 產物一律落在 docs/.arcrun-rag/ 底下(在監看根裡,且是隱藏的), + // 其中**卡片**還要再進一層 wiki/cards/。 + // 分兩層檢查是因為工作區裡不只有卡片:#105 起還有一份 `.gitignore` + // (讓整個工作區對 git 隱形)。它同樣是隱藏的、同樣不撞使用者的命名空間, + // 但它本來就不該住在 cards/ 裡。 + workspacePrefix := filepath.ToSlash(filepath.Join("docs", workspaceRelDir)) + "/" + cardsPrefix := filepath.ToSlash(filepath.Join("docs", vaultCardsRelDir)) + "/" for _, rel := range added { - if !strings.HasPrefix(rel, wantPrefix) { - t.Fatalf("產物 %s 不在預期的隱藏卡片區 %s 底下", rel, wantPrefix) + if !strings.HasPrefix(rel, workspacePrefix) { + t.Fatalf("產物 %s 不在預期的隱藏工作區 %s 底下", rel, workspacePrefix) + } + if strings.HasSuffix(rel, ".md") && !strings.HasPrefix(rel, cardsPrefix) { + t.Fatalf("卡片 %s 不在預期的隱藏卡片區 %s 底下", rel, cardsPrefix) } } }) @@ -396,7 +404,7 @@ func TestVaultFootprint_SubVaultBelowWatchRoot(t *testing.T) { continue } produced++ - if !IsMarked(filepath.Base(rel)) { + if !IsMachineOwnedRel(rel) { t.Fatalf("監看根上多了一個沒帶標記的檔:%s", rel) } } @@ -466,12 +474,12 @@ func TestMigrateCardNames_RelocatesVisibleCardsInsideVault(t *testing.T) { }) t.Logf("收拾前:%v", relList(t, watch)) - n := MigrateCardNames(watch) + mig := MigrateCardNames(watch) got := relList(t, watch) - t.Logf("收拾後(搬了 %d 筆):%v", n, got) + t.Logf("收拾後(搬了 %d 筆):%v", mig.Moved, got) - if n != 2 { - t.Fatalf("搬了 %d 筆,want 2", n) + if mig.Moved != 2 { + t.Fatalf("搬了 %d 筆,want 2", mig.Moved) } for _, rel := range got { if !isHiddenRel(rel) { @@ -483,8 +491,8 @@ func TestMigrateCardNames_RelocatesVisibleCardsInsideVault(t *testing.T) { } // 冪等:再跑一次不該有任何動作。 - if again := MigrateCardNames(watch); again != 0 { - t.Fatalf("第二次跑又搬了 %d 筆,應為 0(冪等)", again) + if again := MigrateCardNames(watch); again.Moved != 0 { + t.Fatalf("第二次跑又搬了 %d 筆,應為 0(冪等)", again.Moved) } }