diff --git a/direct.go b/direct.go index 4fb53a9..c06ecbf 100644 --- a/direct.go +++ b/direct.go @@ -632,6 +632,10 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge skippedOther := 0 var skippedOtherNames []string + // arcrun-rag#104:每個看守資料夾這一輪的收檔策略與「少收了什麼」。 + // key=資料夾路徑(同一個根被多帳號看守時後寫覆蓋——策略只看資料夾,與帳號無關)。 + folderPlans := map[string]FolderPlanStatus{} + // t210:跨帳號、跨資料夾累加的總量進度(見 rootProgress 註解)。 var totalProgress SyncProgress var stuckReasons []string @@ -732,6 +736,16 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge skippedOther += p.SkippedOther // 同理,每一根的檔名都要收(上限在寫進 status 時才裁)。 skippedOtherNames = append(skippedOtherNames, p.SkippedOtherNames...) + // #104:這一根用了什麼策略、少收了什麼 —— 以前到這裡就被丟掉了 + // (只有 CLI 的 stderr 講得出來,App 走的這條路一個字都不說)。 + folderPlans[root] = FolderPlanStatus{ + Mode: string(p.Plan.Mode), + Reason: p.Plan.Reason, + ExcludedFiles: p.ExcludedByPlan, + ExcludedDirs: p.ExcludedDirs, + ExcludedDirCount: p.ExcludedDirCount, + OtherWikiDirs: p.Plan.OtherWikiDirs, + } } } @@ -808,6 +822,10 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge skippedOtherNames = skippedOtherNames[:maxOtherNames] } st.SkippedOtherNames = skippedOtherNames + // #104:收檔策略與被排除的東西 —— 使用者要知道「有幾千個檔沒被收、為什麼」。 + if len(folderPlans) > 0 { + st.FolderPlans = folderPlans + } st.SkippedDocCount = len(skippedSeen) for _, sf := range skippedSeen { st.SkippedDocs = append(st.SkippedDocs, sf) @@ -1220,19 +1238,13 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta // 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{} - } - + // 🔴 2026-08-16:這裡以前還手捏了**第二張**排除表 + //(`skipDirNames := {"system-dev": true}`,daemon-beta task 2 的 template 產物區保護), + // 而 #104 的排除清單住在 ingestplan.go。**兩張表分居兩處** ⇒ 2026-08-16 讀源碼的人 + // 只看到這一張,就把「排除清單根本沒接上」寫成了真兇——實際上兩張都接上了 + //(下面那行 `Plan: plan` 就是)。誤判本身正是「同一件事有兩個地方管」的代價。 + // ⇒ 那條保護已搬進 IngestPlan(templateOwnedDirNames),連同「curated-wiki 模式 + // 要收的正是 system-dev/wiki」這個例外一起 ⇒ **判準只剩一個地方,沒有第二張表可漏看。** payload, err := Scan(absRoot, m, ScanOptions{ MaxRemovedRatio: cfg.MaxRemoved, SkipPaths: map[string]bool{ @@ -1240,9 +1252,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta // template 代裝的根層 CLAUDE.md 是 CC 設定檔,永遠不是用戶知識(task 2) filepath.Join(absRoot, "CLAUDE.md"): true, }, - // template 代裝後 system-dev/(wiki 產物區)不得被當原稿掃進 ingest(task 2) - SkipDirNames: skipDirNames, - Plan: plan, + Plan: plan, }) if err != nil { return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil, rootProgress{} diff --git a/ignorerules.go b/ignorerules.go new file mode 100644 index 0000000..c72260a --- /dev/null +++ b/ignorerules.go @@ -0,0 +1,262 @@ +// ignorerules.go — 把使用者自己寫的 `.gitignore` 當成「他已經宣告過的排除清單」 +// (arcrun-rag#104,2026-08-16 leo 實撞)。 +// +// 🔴 為什麼要有這支檔: +// +// 2026-08-16 leo 把 `tech_projects/pms` 掛上同步,九分鐘後停掉。產出的 27 張卡裡 +// 16 張是 undici 這個套件的 API 文件、5 張是別人的 MIT 授權條款,他自己的只有 5 張。 +// 而 `pms/.gitignore` **第一行就是 `node_modules/`**。 +// ⇒ 他早就講過那不是他的東西了,是我們沒讀。 +// +// 🔴 但它不能是**唯一**判準(票上的紅線):不是每個資料夾都是 git repo +// +// (同日另一個試驗品 `Logseq-plugin` 就沒有 `.git`),一般筆記庫更不會有 `.gitignore`。 +// ⇒ 本檔是**三種排除理由裡最有力的那一種**,不是全部。另兩種在 ingestplan.go: +// ① 使用者自己宣告過(本檔) +// ② 名字本身就不是人話(node_modules 這一類,見 toolOwnedDirNames) +// ③ 泛用名(build/dist/out…)+ 有佐證這是程式專案(見 ambiguousBuildDirNames) +// +// 支援的語法(刻意是 git 的子集,不支援的一律「不排除」——漏判只是多收一個資料夾, +// 誤判是把使用者的東西弄不見,代價不對稱就往安全那邊倒): +// +// # 註解、空行 +// name 任何深度的 name(檔或目錄) +// name/ 只有目錄 +// /name 綁在這份 .gitignore 所在的目錄 +// a/b 含 `/` ⇒ 同樣綁在 .gitignore 所在的目錄 +// *.log ? [abc] 萬用字元(`*` 不跨 `/`) +// **/x x/** 任意深度 +// !name 反向(把上面排除掉的救回來) +// +// 未支援:`\` 跳脫、大小寫不敏感檔案系統的特例、`.git/info/exclude`、全域 gitignore。 +package collector + +import ( + "bufio" + "os" + "path/filepath" + "regexp" + "strings" +) + +// IgnoreRules=一棵樹裡所有 `.gitignore` 的合集。 +// +// 巢狀 `.gitignore` 照 git 的規矩:深的那份優先於淺的,同一份裡「最後命中的那條贏」。 +type IgnoreRules struct { + sets []ignoreSet // 依所在目錄深度由淺至深 + // Present=這棵樹裡到底有沒有 `.gitignore`。 + // 🔴 **只供診斷/測試,不准拿來當任何判準的門檻**(leo 2026-08-16: + // 他的 KB 筆記庫也有 git,版控相關的訊號分辨不出「筆記庫 vs 軟體專案」)。 + // 沒有 `.gitignore` 的資料夾照樣要被正確處理,有的也不代表它是軟體專案。 + Present bool +} + +type ignoreSet struct { + dir string // 這份 .gitignore 所在目錄,相對監看根("" =根) + pats []ignorePattern +} + +type ignorePattern struct { + negate bool + dirOnly bool + // re=命中「這個路徑本身」;reUnder=命中「這個路徑底下的東西」。 + // + // 🔴 為什麼要拆成兩支:`node_modules/` 是 dirOnly,但它排除的**不只是那個目錄** + // ——底下的每一個檔案也都被排除了(git 的語意)。只用一支正規表示式、 + // 再靠 `dirOnly && !isDir` 一律跳過,會讓 + // `node_modules/undici/docs/api/Pool.md` 逃過去——那正是這一票的頭號實據。 + re *regexp.Regexp + reUnder *regexp.Regexp + raw string +} + +// matches 回答這條樣式命不命中。dirOnly 的樣式只有在「路徑本身是目錄」 +// 或「路徑在它底下」時才算。 +func (p ignorePattern) matches(rel string, isDir bool) bool { + if p.reUnder.MatchString(rel) { + return true // 在被排除的目錄底下——與它自己是不是目錄無關 + } + if p.dirOnly && !isDir { + return false + } + return p.re.MatchString(rel) +} + +// maxIgnoreScanDepth=找 `.gitignore` 時最多往下鑽幾層。 +// +// 為什麼要有上限:找 ignore 檔本身也要走訪,而**走訪整棵樹正是這一票要避免的事** +// (2,127 個檔、78% 在依賴目錄底下)。真實專案的 `.gitignore` 幾乎都在根或第一、二層 +// (monorepo 的 `packages/*/`、`workers/*/`);再深的漏掉,代價只是那一層少一種判準, +// 上面還有 toolOwned/ambiguous 兩道接著擋。 +const maxIgnoreScanDepth = 3 + +// LoadIgnoreRules 從監看根往下收集 `.gitignore`(深度上限 maxIgnoreScanDepth)。 +// +// 收集時就套用 toolOwnedDirNames 與隱藏目錄的規則——不然光是為了找 ignore 檔, +// 就得先走進 `node_modules` 一趟,那正是我們要省掉的那件事。 +func LoadIgnoreRules(absRoot string) *IgnoreRules { + r := &IgnoreRules{} + var walk func(absDir, relDir string, depth int) + walk = func(absDir, relDir string, depth int) { + if pats := parseIgnoreFile(filepath.Join(absDir, ".gitignore")); len(pats) > 0 { + r.sets = append(r.sets, ignoreSet{dir: relDir, pats: pats}) + r.Present = true + } + if depth >= maxIgnoreScanDepth { + return + } + entries, err := os.ReadDir(absDir) + if err != nil { + return + } + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + if strings.HasPrefix(name, ".") || toolOwnedDirNames[name] { + continue + } + child := name + if relDir != "" { + child = relDir + "/" + name + } + walk(filepath.Join(absDir, name), child, depth+1) + } + } + walk(absRoot, "", 0) + return r +} + +// Ignores 回答「使用者的 .gitignore 有沒有說不要這一個」。relSlash 相對監看根。 +// +// 語意照 git:先看淺的、再看深的;每一層裡最後命中的那條贏(所以 `!` 救得回來)。 +func (r *IgnoreRules) Ignores(relSlash string, isDir bool) bool { + if r == nil || len(r.sets) == 0 { + return false + } + ignored := false + for _, set := range r.sets { + rel, ok := relativeTo(relSlash, set.dir) + if !ok { + continue // 這份 .gitignore 管不到這條路徑 + } + for _, p := range set.pats { + if p.matches(rel, isDir) { + ignored = !p.negate // 同一份裡「最後命中的那條贏」(所以 `!` 救得回來) + } + } + } + return ignored +} + +// relativeTo 把 relSlash 換算成「相對於 base 目錄」的路徑;不在 base 底下回 false。 +func relativeTo(relSlash, base string) (string, bool) { + if base == "" { + return relSlash, true + } + if relSlash == base { + return "", false + } + if strings.HasPrefix(relSlash, base+"/") { + return strings.TrimPrefix(relSlash, base+"/"), true + } + return "", false +} + +func parseIgnoreFile(path string) []ignorePattern { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + var out []ignorePattern + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimRight(sc.Text(), " \t") + if line == "" || strings.HasPrefix(line, "#") { + continue + } + p, ok := compileIgnorePattern(line) + if ok { + out = append(out, p) + } + } + return out +} + +// compileIgnorePattern 把一條 gitignore 樣式編成 regexp。 +// 看不懂的樣式回 false(=不排除任何東西),理由見檔頭的「代價不對稱」。 +func compileIgnorePattern(line string) (ignorePattern, bool) { + p := ignorePattern{raw: line} + if strings.HasPrefix(line, "!") { + p.negate = true + line = line[1:] + } + if strings.HasPrefix(line, "\\") { // 跳脫未支援 + return p, false + } + if strings.HasSuffix(line, "/") { + p.dirOnly = true + line = strings.TrimSuffix(line, "/") + } + // 含 `/`(非結尾)或以 `/` 開頭 ⇒ 綁在這份 .gitignore 所在目錄;否則任何深度都算。 + anchored := strings.HasPrefix(line, "/") || strings.Contains(line, "/") + line = strings.TrimPrefix(line, "/") + if line == "" { + return p, false + } + + body := globToRegexp(line) + head := "^" + if !anchored { + head = "^(?:.*/)?" // 不含 `/` 的樣式在任何深度都算(git 語意) + } + re, err := regexp.Compile(head + body + "$") + if err != nil { + return p, false + } + // 「在它底下」的那一支——目錄被排除,底下整棵都跟著排除。 + reUnder, err := regexp.Compile(head + body + "/.*$") + if err != nil { + return p, false + } + p.re, p.reUnder = re, reUnder + return p, true +} + +// globToRegexp:`**` 跨目錄、`*` 不跨 `/`、`?` 單字元、`[...]` 原樣當字元類。 +func globToRegexp(glob string) string { + var b strings.Builder + for i := 0; i < len(glob); i++ { + c := glob[i] + switch c { + case '*': + if i+1 < len(glob) && glob[i+1] == '*' { + i++ + if i+1 < len(glob) && glob[i+1] == '/' { + i++ + b.WriteString("(?:.*/)?") // `**/` =任意層數(含零層) + } else { + b.WriteString(".*") + } + } else { + b.WriteString("[^/]*") + } + case '?': + b.WriteString("[^/]") + case '[': + end := strings.IndexByte(glob[i:], ']') + if end < 0 { + b.WriteString(regexp.QuoteMeta(string(c))) + continue + } + b.WriteString(glob[i : i+end+1]) + i += end + default: + b.WriteString(regexp.QuoteMeta(string(c))) + } + } + return b.String() +} diff --git a/ignorerules_test.go b/ignorerules_test.go new file mode 100644 index 0000000..1eba948 --- /dev/null +++ b/ignorerules_test.go @@ -0,0 +1,139 @@ +// ignorerules_test.go — `.gitignore` 是使用者已經寫好的宣告(arcrun-rag#104)。 +// +// 🔴 邊界(leo 2026-08-16 當場立的):**只用它的內容當線索,不用它的存在當門檻。** +// 沒有 `.gitignore` 的資料夾必須被正確處理;有 `.gitignore` 也不代表那是軟體專案。 +package collector + +import ( + "os" + "path/filepath" + "testing" +) + +func loadIgnoreFixture(t *testing.T, files map[string]string) *IgnoreRules { + t.Helper() + root := t.TempDir() + 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) + } + } + return LoadIgnoreRules(root) +} + +func TestIgnoreRules_基本語法(t *testing.T) { + // leo 真實的 `pms/.gitignore`,逐字。 + r := loadIgnoreFixture(t, map[string]string{ + ".gitignore": "node_modules/\ndist/\n.wrangler/\n*.log\n.dev.vars\n", + }) + + cases := []struct { + path string + isDir bool + want bool + why string + }{ + {"node_modules", true, true, "第一行就寫了"}, + {"workers/x/node_modules", true, true, "沒有 / 開頭=任何深度"}, + {"node_modules/undici/docs/api/Pool.md", false, true, "被排除的目錄底下整棵都算"}, + {"dist", true, true, "第二行"}, + {"dist", false, false, "`dist/` 只管目錄,同名的檔案不算"}, + {"build.log", false, true, "*.log"}, + {"docs/x.log", false, true, "*.log 任何深度"}, + {".dev.vars", false, true, "具名檔"}, + {"docs/PMS_USER_STORIES.md", false, false, "使用者自己的文件"}, + {"README.md", false, false, "使用者自己的文件"}, + } + for _, c := range cases { + if got := r.Ignores(c.path, c.isDir); got != c.want { + t.Errorf("Ignores(%q, dir=%v)=%v,want %v(%s)", c.path, c.isDir, got, c.want, c.why) + } + } +} + +func TestIgnoreRules_錨定與反向(t *testing.T) { + r := loadIgnoreFixture(t, map[string]string{ + ".gitignore": "/build\ntmp/**\n*.bak\n!keep.bak\ndocs/generated\n", + }) + cases := []struct { + path string + isDir bool + want bool + why string + }{ + {"build", true, true, "/build 綁在根"}, + {"src/build", true, false, "以 / 開頭=只有根那一個"}, + {"tmp/a/b", true, true, "tmp/** 任意深度"}, + {"x.bak", false, true, "*.bak"}, + {"keep.bak", false, false, "! 把它救回來"}, + {"docs/generated", true, true, "含 / ⇒ 錨定在根"}, + {"other/docs/generated", true, false, "錨定的不該在別處命中"}, + } + for _, c := range cases { + if got := r.Ignores(c.path, c.isDir); got != c.want { + t.Errorf("Ignores(%q, dir=%v)=%v,want %v(%s)", c.path, c.isDir, got, c.want, c.why) + } + } +} + +// 巢狀 `.gitignore`:深的那一份只管自己底下(monorepo 每個 package 各有一份)。 +func TestIgnoreRules_巢狀只管自己底下(t *testing.T) { + r := loadIgnoreFixture(t, map[string]string{ + ".gitignore": "*.log\n", + "workers/api/.gitignore": "cache/\n", + }) + if !r.Ignores("workers/api/cache", true) { + t.Error("子目錄自己的 .gitignore 沒生效") + } + if r.Ignores("cache", true) { + t.Error("子目錄的規則不該套用到根") + } + if !r.Ignores("workers/api/x.log", false) { + t.Error("根的規則應該往下套用") + } +} + +// 🔴 邊界一:沒有 `.gitignore` 的資料夾必須完全正常(不是門檻)。 +func TestIgnoreRules_沒有這個檔也要正常(t *testing.T) { + r := loadIgnoreFixture(t, map[string]string{"筆記.md": "# 我的"}) + if r.Present { + t.Error("沒有 .gitignore 卻回報有") + } + if r.Ignores("任何東西", true) || r.Ignores("筆記.md", false) { + t.Error("沒有規則時不該排除任何東西") + } + // nil 接收者也要安全——IngestPlan 零值就是 nil。 + var nilRules *IgnoreRules + if nilRules.Ignores("x", true) { + t.Error("nil IgnoreRules 不該排除任何東西") + } +} + +// 🔴 邊界二:有 `.gitignore` **不代表**這是軟體專案。 +// 一個筆記庫放了 `.gitignore`(很多人拿 git 做版本備份),它的 `build/` 仍是他的東西。 +func TestIgnoreRules_有這個檔不代表是軟體專案(t *testing.T) { + root := t.TempDir() + writeFixture(t, root, map[string]string{ + ".gitignore": ".DS_Store\n", // 只是不想收系統垃圾 + "build/樂高作品集.md": "# 我的模型", + "日記.md": "# 日記", + }) + payload, plan := scanWithPlan(t, root) + got := eventPaths(payload) + t.Logf("策略=%s|送出:%v", plan.Mode, got) + if len(got) != 2 { + t.Fatalf("有 .gitignore 就把 `build/` 當成產物殺掉了——那只是他不想收 .DS_Store。實得:%v", got) + } +} + +// 看不懂的樣式一律「不排除」——漏判只是多收一個資料夾,誤判是把使用者的東西弄不見。 +func TestIgnoreRules_看不懂的樣式不亂殺(t *testing.T) { + r := loadIgnoreFixture(t, map[string]string{ + ".gitignore": "# 註解\n\n\\escaped\n[unclosed\n", + }) + if r.Ignores("escaped", false) { + t.Error("跳脫語法未支援,就不該命中") + } +} diff --git a/ingestplan.go b/ingestplan.go index ebaf659..48999fd 100644 --- a/ingestplan.go +++ b/ingestplan.go @@ -62,6 +62,27 @@ type IngestPlan struct { // OtherWikiDirs=這個 repo 底下**其他**子專案自己的 wiki(相對路徑)。 // 刻意不收(見 wantsPath 的說明),但一定要列出來——不然使用者只會覺得東西不見了。 OtherWikiDirs []string `json:"other_wiki_dirs,omitempty"` + + // ── 以下不外露成 JSON:判準的材料,不是給使用者看的結論 ────────────────── + // ignore=使用者自己寫的 `.gitignore` 的**內容**(見 ignorerules.go)。 + // 🔴 只用內容,不把「有沒有這個檔」當門檻——leo 2026-08-16:他的 KB 筆記庫也有 git, + // 版控訊號分辨不出「筆記庫 vs 軟體專案」。沒有 `.gitignore` 的資料夾同樣要被正確處理。 + ignore *IgnoreRules +} + +// ExcludedDir=走訪時整棵跳過的一個目錄,連同「講給使用者聽的理由」。 +// +// 🔴 票上的紅線是「排除規則要看得見」,而原本的做法只數了**檔案層**被擋掉的數量 +// (ExcludedByPlan);整棵剪掉的子樹一個都沒數 ⇒ 拿 leo 真實的 `pms` 跑一輪, +// 2,127 個檔裡排除了絕大多數,畫面上的數字卻是 **0**。 +// 「安靜地少收」與「講了一個 0」對使用者是同一件事。 +// +// 為什麼記目錄而不是記檔案數:整棵剪掉的重點就是**不走進去**,要數就得走一趟, +// 那正是這一票要省掉的成本。而使用者真正要知道的本來就是 +// 「哪幾個資料夾沒收、為什麼」,不是「少收了幾千個檔」。 +type ExcludedDir struct { + Path string `json:"path"` + Reason string `json:"reason"` } // curatedWikiCandidates=「整理好的知識庫」慣例位置,依優先序。 @@ -72,26 +93,106 @@ 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, +// 🔴 2026-08-16 修正(本檔原本只有一張大表 noiseDirNames,任何模式一律照殺): +// 那張表把「沒有人會這樣命名」與「這是普通英文字」混在一起,於是同時犯了兩個方向的錯—— +// +// 實測:一個一般筆記庫(無 `.git`)放 8 個 .md,其中 7 個分別住在 +// `build/`、`專案/dist/`、`out/`、`target/`、`coverage/`、`bin/`、`fixtures/`, +// **只送出 1 個,而且 ExcludedByPlan 回報 0**——安靜地弄丟使用者七份筆記。 +// (`build` 可以是樂高作品集,`out` 可以是外出旅遊,`vendor` 可以是廠商。) +// +// ⇒ 判準要問的不是「這個名字像不像雜訊」,是「**這東西是誰放的**」。 +// ───────────────────────────────────────────────────────────────────────────── + +// toolOwnedDirNames=名字本身就不是人話的目錄——沒有人會把自己的筆記 +// 放進一個叫 `node_modules` 或 `__pycache__` 的資料夾。 +// **任何模式、任何脈絡下都跳過,不需要佐證。** +var toolOwnedDirNames = map[string]bool{ + // 依賴(別人的原始碼,不是使用者的) + "node_modules": 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, + // 建置產物/快取(從別的檔生出來的,收了就是同一份內容收兩次) + // #104 實據:`.next`/`.vercel`。這些以 `.` 開頭的其實已被 Scan 的隱藏目錄規則擋下, + // 列在這裡是為了讓「為什麼跳過」講得出理由,也讓 LoadIgnoreRules 少走幾趟。 ".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, + ".parcel-cache": true, ".pytest_cache": true, ".gradle": true, + // 範本/樣板:我們自己要鋪給別人的檔,收自己鋪的東西最荒謬 + // (#104 實據:8 份 `collector/templatefs/system-dev/wiki`) + "templatefs": true, "template-fs": true, + // 給程式吃的樣本,不是知識 + "__fixtures__": true, "__snapshots__": true, +} + +// ambiguousBuildDirNames=**旁邊擺著專案檔時**是建置產物/依賴,但在別的脈絡下 +// 完全可能是使用者真正的內容的目錄名。 +// +// 🔴 只有 looksGenerated 為真時才生效。 +// 一般筆記庫裡的 `build/`(樂高作品集)、`out/`(外出)、`vendor/`(廠商)一律照收。 +var ambiguousBuildDirNames = map[string]bool{ + "dist": true, "build": true, "out": true, "target": true, + "bin": true, "obj": true, "coverage": true, + "vendor": true, "skeleton": true, "testdata": true, "fixtures": true, +} + +// toolOwnedFileNames=機器產生的鎖定檔。與 toolOwnedDirNames 同一條理由 +// (名字本身就不是人話),只是它們是檔不是目錄。 +// +// 🔴 為什麼要另外列:`.yaml`/`.yml` 在 2026-08-15 被加進 allowedExt(InkStoneCo#44 ④, +// 因為 `.feature`/`.yaml` 常常真的是知識文件)。副作用是 **`pnpm-lock.yaml` 變成了 +// 「知識」**——實測 pms 那棵樹時它真的被送出去了。鎖定檔是解析器的輸出, +// 幾千行雜湊值,萃出來的卡只會跟使用者真正的筆記競爭排序(同授權條款那個病)。 +var toolOwnedFileNames = map[string]bool{ + "pnpm-lock.yaml": true, "package-lock.json": true, "yarn.lock": true, + "go.sum": true, "Cargo.lock": true, "composer.lock": true, + "Gemfile.lock": true, "poetry.lock": true, "pnpm-workspace.yaml": true, +} + +// templateOwnedDirNames=system-dev-template 鋪出來的產物區,任何深度都不當原稿 +// (daemon-beta task 2,2026-08-06,原始出處 commit b4fee43)。 +// +// 🔴 這一條以前是 direct.go 自己手捏的第二張表(`SkipDirNames{"system-dev": true}`), +// 而 #104 的排除清單在這裡——**兩張表分居兩處,於是 2026-08-16 讀源碼的人只看到其中一張, +// 把「清單根本沒接上」當成了真兇**(實際上兩張都有接上,見 direct.go 的 `Plan: plan`)。 +// 收成同一個地方,就不會再有第二張表可以漏看。 +// curated-wiki 模式要收的正是 `system-dev/wiki` ⇒ 那條路由 onPathTo 放行。 +var templateOwnedDirNames = map[string]bool{"system-dev": true} + +// projectManifestFiles=「有人在這一層跑建置工具」的佐證檔。 +// +// 🔴 為什麼**不是**看 `.git`(leo 2026-08-16 當場推翻): +// +// 「**你不需要判斷有沒有 git,我的 KB 筆記庫也有 git, +// 是否用 github/gitea 追蹤完全沒意義。**」 +// +// ⇒ 版控是「這個人有沒有在做版本備份」,與「這個資料夾是不是軟體專案」無關。 +// 同理 `.gitignore` 的**存在**也不是門檻(它的**內容**仍是有用的線索,見 ignorerules.go)。 +// ⇒ 判準只准建在「**這個目錄本身/旁邊是什麼**」上——這樣筆記庫與軟體專案一視同仁。 +var projectManifestFiles = []string{ + "package.json", "go.mod", "Cargo.toml", "pyproject.toml", "requirements.txt", + "pom.xml", "build.gradle", "build.gradle.kts", "Gemfile", "composer.json", + "CMakeLists.txt", "Makefile", "pnpm-workspace.yaml", "tsconfig.json", +} + +// looksGenerated 回答「這個叫 build/dist/out… 的目錄,真的是工具生出來的嗎」。 +// +// 判準是**目錄局部的**:它的**上一層**有沒有擺著專案檔(package.json、go.mod…)。 +// 建置產物一定跟產生它的專案檔同一層——`pms/workers/x/package.json` 旁邊的 +// `pms/workers/x/dist` 是產物;筆記庫 `專案/build`(樂高作品集)旁邊什麼都沒有。 +// +// 為什麼用「上一層」而不是「整棵樹有沒有專案檔」:monorepo 底下同時有程式與筆記, +// 用整棵樹當旗標會把筆記那一半也一起殺掉。局部判斷才不會誤傷。 +func looksGenerated(absDir string) bool { + parent := filepath.Dir(absDir) + for _, n := range projectManifestFiles { + if _, err := os.Stat(filepath.Join(parent, n)); err == nil { + return true + } + } + return false } // PlanIngest 決定某個監看根的收檔策略。**這支是 #104 的入口,Scan 在走訪前呼叫一次。** @@ -103,10 +204,14 @@ var noiseDirNames = map[string]bool{ // 誰的筆記本。判準只有一個地方(repoguard.go),兩張票共用,不會漂移。 func PlanIngest(absRoot string) IngestPlan { repoRoot := DetectRepoRoot(absRoot) + // 使用者自己寫的排除宣告——只讀它的**內容**當線索,不拿它的存在當門檻。 + ignore := LoadIgnoreRules(absRoot) + if repoRoot == "" { return IngestPlan{ Mode: IngestAll, - Reason: "這是一般資料夾,裡面的文件我全部都會讀。", + Reason: "這是一般資料夾,裡面的文件我全部都會讀(別人的套件與建置產物除外)。", + ignore: ignore, } } @@ -119,6 +224,7 @@ func PlanIngest(absRoot string) IngestPlan { Reason: "這是一個開發專案,而且你已經整理好一份知識庫(" + wiki + ")——" + "我直接讀那一份就好,不再把整個專案的原始碼與零散檔案重萃一次。", OtherWikiDirs: others, + ignore: ignore, } } @@ -133,6 +239,7 @@ func PlanIngest(absRoot string) IngestPlan { DocRelDirs: docs, Reason: reason, OtherWikiDirs: others, + ignore: ignore, } } @@ -208,7 +315,9 @@ func otherWikiDirs(absRoot string) []string { return nil } name := d.Name() - if strings.HasPrefix(name, ".") || noiseDirNames[name] { + // 與 SkipsDirWhy 的 ②③ 同一組判準(泛用名同樣要旁邊有專案檔才算)。 + if strings.HasPrefix(name, ".") || toolOwnedDirNames[name] || + (ambiguousBuildDirNames[name] && looksGenerated(p)) { return filepath.SkipDir } if IsLinkedWorktree(p) { @@ -244,30 +353,56 @@ func otherWikiDirs(absRoot string) []string { // 再加上模式限定的:curated-wiki 只走那一份 wiki 的路;docs-only 只走文件目錄的路。 // (隱藏目錄由 Scan 自己擋,那條規則比本檔更早存在,不搬過來。) func (p IngestPlan) SkipsDir(relSlash, absPath string) bool { + skip, _ := p.SkipsDirWhy(relSlash, absPath) + return skip +} + +// SkipsDirWhy 同 SkipsDir,但一併回「講給使用者聽的理由」。 +// +// 🔴 理由不是 debug 字串,是產品文案:使用者看到「兩千個檔只送了九個」的當下, +// 唯一能讓他不慌的東西就是這一句(票上的紅線,同 #121「不要讓用戶猜」)。 +func (p IngestPlan) SkipsDirWhy(relSlash, absPath string) (bool, string) { name := filepath.Base(relSlash) - if noiseDirNames[name] { - return true + + // ① 使用者自己宣告過的(最有力的理由——他親手寫的,不是我們猜的) + if p.ignore.Ignores(relSlash, true) { + return true, "你的 .gitignore 說不要收這裡" + } + // ② 名字本身就不是人話(不需要佐證) + if toolOwnedDirNames[name] { + return true, "這是工具產生的(別人的套件、快取或建置產物),不是你寫的東西" + } + // ③ 泛用名(build/dist/out…)——只有在它旁邊真的擺著專案檔時才算。 + // 判準是目錄局部的,與版控無關(leo 2026-08-16:他的筆記庫也有 git)。 + if ambiguousBuildDirNames[name] && looksGenerated(absPath) { + return true, "這是建置工具產生的目錄(旁邊就是產生它的專案檔)" + } + // ④ template 鋪出來的產物區(任何深度)。curated-wiki 要收的那條路例外。 + if templateOwnedDirNames[name] && !(p.Mode == IngestCuratedWiki && onPathTo(relSlash, p.WikiRelDir)) { + return true, "這是開發範本鋪出來的目錄,不是你的知識" } if IsLinkedWorktree(absPath) { - return true + return true, "這是同一個專案的第二份簽出(git worktree),內容與主資料夾重複" } // 巢狀 repo:監看根自己不算(relSlash == "." 走不到這裡,Scan 只對子目錄呼叫)。 if IsRepoRoot(absPath) { - return true + return true, "這是另一個獨立的專案,要收請把它自己加進看守清單" } switch p.Mode { case IngestCuratedWiki: // 只有「通往那份 wiki 的路」與「那份 wiki 底下」要走。 - return !onPathTo(relSlash, p.WikiRelDir) + if !onPathTo(relSlash, p.WikiRelDir) { + return true, "這次只讀你整理好的 " + p.WikiRelDir + } case IngestDocsOnly: for _, d := range p.DocRelDirs { if onPathTo(relSlash, d) { - return false + return false, "" } } - return true + return true, "這是一個開發專案,這次只讀文件區,不讀程式碼" } - return false + return false, "" } // KeepsFile 回答「這個檔要不要收」。relSlash 是相對監看根的路徑。 @@ -277,6 +412,14 @@ func (p IngestPlan) SkipsDir(relSlash, absPath string) bool { // 專案唯一的入門文件,把它們漏掉,一個只有 README 的 repo 會變成一個檔都不收)。 // all:全收,判準交回 Scan 原本的副檔名白名單。 func (p IngestPlan) KeepsFile(relSlash string) bool { + // 使用者自己宣告過的檔案(`*.log`、`.dev.vars`…)同樣當真——與目錄同一條理由。 + if p.ignore.Ignores(relSlash, false) { + return false + } + // 機器產生的鎖定檔不是知識(見 toolOwnedFileNames)。 + if toolOwnedFileNames[filepath.Base(relSlash)] { + return false + } switch p.Mode { case IngestCuratedWiki: return strings.HasPrefix(relSlash, p.WikiRelDir+"/") diff --git a/ingestplan_test.go b/ingestplan_test.go index 3613d10..0e2d52c 100644 --- a/ingestplan_test.go +++ b/ingestplan_test.go @@ -24,21 +24,41 @@ func eventPaths(p *TriggerPayload) []string { return out } -// scanWithPlan 照 daemon 的真實接法跑一輪掃描(PlanIngest + curated-wiki 時解除 -// system-dev 的 SkipDirNames,見 direct.go 那段註解)。 +// scanWithPlan 照 daemon 的真實接法跑一輪掃描。 +// +// 🔴 2026-08-16 簡化:這裡以前要自己複製 direct.go 那段「curated-wiki 時解除 +// system-dev 的 SkipDirNames」的邏輯——**測試複製了受測程式的一半,於是它驗的是 +// 我抄得對不對,不是產品對不對**。那段判準已收進 IngestPlan,兩邊都不必再抄。 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}) + payload, err := Scan(root, m, ScanOptions{}) // Plan 不填=Scan 自己算,同 daemon if err != nil { t.Fatalf("掃描失敗:%v", err) } - return payload, plan + return payload, payload.Plan +} + +// countDocFiles 數這棵樹裡有幾個「本來就會被收」的文件檔(不套任何策略)。 +// 拿它當對照組,比再跑一次 Scan 誠實——策略現在是走訪器自己裝的, +// 「不套策略的 Scan」已經不存在了(那正是本次修法要的性質)。 +func countDocFiles(t *testing.T, root string) int { + t.Helper() + n := 0 + _ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + name := info.Name() + if strings.HasPrefix(name, ".") { + return nil + } + if allowedExt[strings.ToLower(filepath.Ext(name))] { + n++ + } + return nil + }) + return n } // makeMonorepoFixture 造一個「leo 的 InkStoneCo」形狀的 repo。 @@ -115,19 +135,15 @@ func makeMonorepoFixture(t *testing.T) (root string, curatedCount int) { 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) - } + // 對照組:這棵樹裡本來就有幾個文件檔(=完全不排除時會送出去的量)。 + onDisk := countDocFiles(t, root) 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) + t.Logf("樹上共有 %d 個文件檔|實際送出 %d 個(逐檔擋掉 %d,整棵跳過 %d 個資料夾)", + onDisk, len(got), payload.ExcludedByPlan, payload.ExcludedDirCount) for _, p := range got { t.Logf(" → %s", p) } @@ -144,9 +160,9 @@ func TestPlanIngest_MonorepoSendsOnlyCuratedWiki(t *testing.T) { } } // 量級檢查:這一票的實據是「差 115 倍」,修好之後不該只差一點點。 - if len(baseline.Events) < len(got)*5 { - t.Fatalf("對照組只有 %d 個檔,fixture 沒造出「被淹沒」的形狀,這個測試證明不了什麼", - len(baseline.Events)) + if onDisk < len(got)*5 { + t.Fatalf("樹上只有 %d 個文件檔,fixture 沒造出「被淹沒」的形狀,這個測試證明不了什麼", + onDisk) } } @@ -271,3 +287,186 @@ func TestPlanIngest_EmptyWikiFallsBackToDocs(t *testing.T) { t.Fatalf("wiki 是空的,策略應退到 %s,卻是 %s", IngestDocsOnly, plan.Mode) } } + +// ═══════════════════════════════════════════════════════════════════════════ +// arcrun-rag#104 第二輪(2026-08-16 leo 實撞:掛上 pms,27 張卡裡 22 張是別人的) +// ═══════════════════════════════════════════════════════════════════════════ + +// makePMSFixture 造 leo 那棵真樹的形狀:一個含大量依賴目錄的專案。 +// 數字照票上的實測比例縮小(真樹 2,127 檔/1,647 在依賴底下/node_modules 裡 108 個 .md)。 +func makePMSFixture(t *testing.T) (root string, mine []string) { + t.Helper() + root = t.TempDir() + + files := map[string]string{} + + // ① leo 自己的東西——唯一該收的 + mine = []string{ + "README.md", + "docs/PMS_USER_STORIES.md", + "docs/kbdb-api-patterns.md", + "docs/u6u-implementation-notes.md", + } + for _, p := range mine { + files[p] = "# 我自己寫的:" + p + } + + // ② 專案本體(有 package.json ⇒ 這一層旁邊的 dist 才算產物) + files["package.json"] = `{"name":"pms"}` + files["pnpm-lock.yaml"] = "lockfileVersion: 1" + files[".gitignore"] = "node_modules/\ndist/\n.wrangler/\n*.log\n.dev.vars\n" + + // ③ 別人的套件:undici 的 API 文件與第三方授權條款 + // (票上實據:16 張 undici 卡+5 張授權條款,佔 27 張裡的 81%) + for _, n := range []string{ + "Pool", "ProxyAgent", "Dispatcher", "MockAgent", "MockPool", + "WebSocket", "RetryHandler", "DiagnosticsChannel", + } { + files["workers/pms-order-search/node_modules/undici/docs/api/"+n+".md"] = "# " + n + } + for _, n := range []string{ + "LICENSE-browserify-fs", "LICENSE-buffer-es6", "LICENSE-crypto-browserify", + "LICENSE-process-es6", "ThirdPartyNoticeText", + } { + files["workers/pms-order-search/node_modules/"+n+".md"] = "MIT License\n\nPermission is hereby granted…" + } + files["workers/pms-order-search/package.json"] = `{"name":"order-search"}` + files["workers/pms-order-search/dist/bundle.md"] = "# 建置產物" + + // ④ 使用者宣告不要的(.gitignore 第一線) + files["build.log"] = "noise" + files[".dev.vars"] = "secret" + + writeFixture(t, root, files) + sort.Strings(mine) + return root, mine +} + +// 🔴 正面驗收:**掛上一個真實專案資料夾,收進去的是他的東西,不是他安裝的別人的東西。** +func TestPlanIngest_真實專案只收使用者自己的東西(t *testing.T) { + root, mine := makePMSFixture(t) + onDisk := countDocFiles(t, root) + + payload, plan := scanWithPlan(t, root) + got := eventPaths(payload) + + t.Logf("── 修法前後對照(同一棵樹)──") + t.Logf("樹上的文件檔共 %d 個;其中別人的套件文件 13 個(undici API ×8+授權條款 ×5)", onDisk) + t.Logf("策略:%s — %s", plan.Mode, plan.Reason) + t.Logf("實際送出 %d 個:", len(got)) + for _, p := range got { + t.Logf(" ✓ %s", p) + } + t.Logf("整棵跳過 %d 個資料夾:", payload.ExcludedDirCount) + for _, d := range payload.ExcludedDirs { + t.Logf(" ✗ %s — %s", d.Path, d.Reason) + } + + for _, p := range got { + if strings.Contains(p, "node_modules/") { + t.Errorf("收進了別人的套件:%s", p) + } + if strings.Contains(strings.ToUpper(p), "LICENSE") { + t.Errorf("把授權條款收成知識:%s(那是法律文字,不是知識)", p) + } + } + if strings.Join(got, ",") != strings.Join(mine, ",") { + t.Fatalf("送出的不等於使用者自己的東西\n實得:%v\n應為:%v", got, mine) + } +} + +// 🔴 反面驗收(票上紅線「不要誤殺」):一個**真的**叫 build/dist/out 的資料夾, +// 但它確實是使用者的內容——不准安靜地弄不見。 +// +// 這一條是 2026-08-16 實測抓到的迴歸:原本的大表在一般資料夾裡也照殺, +// 8 個 .md 只送出 1 個,而且回報「擋掉 0 個」。 +func TestPlanIngest_筆記庫裡真的叫build的資料夾不准誤殺(t *testing.T) { + root := t.TempDir() + writeFixture(t, root, map[string]string{ + "日記.md": "# 日記", + "build/樂高作品集.md": "# 我在做的模型", + "專案/dist/交件清單.md": "# 交件", + "out/外出旅遊筆記.md": "# 旅遊", + "target/年度目標.md": "# 目標", + "coverage/保單整理.md": "# 保單", + "bin/雜項.md": "# 雜項", + "vendor/廠商聯絡簿.md": "# 廠商", + }) + + payload, plan := scanWithPlan(t, root) + got := eventPaths(payload) + t.Logf("策略:%s|送出 %d/8:%v", plan.Mode, len(got), got) + + if len(got) != 8 { + t.Fatalf("使用者的 8 份筆記只送了 %d 份——`build`/`out`/`vendor` 在筆記庫裡"+ + "是樂高作品集、外出旅遊、廠商聯絡簿,不是建置產物。實得:%v", len(got), got) + } + if payload.ExcludedDirCount != 0 { + t.Fatalf("一般資料夾不該有任何資料夾被剪掉,卻剪了:%v", payload.ExcludedDirs) + } +} + +// 同一個名字、不同脈絡:`dist` 旁邊擺著 package.json ⇒ 是產物,該跳過。 +// 判準是**目錄局部的**,與有沒有版控無關(leo 2026-08-16:他的 KB 筆記庫也有 git)。 +func TestPlanIngest_同一個名字看旁邊擺什麼決定(t *testing.T) { + root := t.TempDir() + writeFixture(t, root, map[string]string{ + "筆記/build/樂高作品集.md": "# 使用者的東西(旁邊沒有專案檔)", + "程式/package.json": `{"name":"x"}`, + "程式/build/bundle.md": "# 產物(旁邊就是 package.json)", + }) + + payload, _ := scanWithPlan(t, root) + got := eventPaths(payload) + t.Logf("送出:%v", got) + for _, d := range payload.ExcludedDirs { + t.Logf("跳過 %s — %s", d.Path, d.Reason) + } + + want := []string{"筆記/build/樂高作品集.md"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("實得 %v,應為 %v(同一個名字,要看旁邊擺什麼)", got, want) + } +} + +// 有 `.git` 但其實是筆記庫:**版控不得改變收檔行為**(leo 2026-08-16 推翻 .git 判準)。 +// +// ⚠️ 已知落差(不在本輪範圍,另報總管):`PlanIngest` 的**模式選擇**仍看 `.git` +// ——所以帶 `.git` 的筆記庫會被判成 docs-only。本測試只釘住「排除規則那一層 +// 不看版控」,模式選擇那一層要另外開票處理。 +func TestPlanIngest_排除判準不看有沒有版控(t *testing.T) { + base := map[string]string{ + "docs/說明.md": "# 文件", + "build/樂高作品集.md": "# 使用者的東西", + "node_modules/x/a.md": "# 別人的套件", + } + + withoutGit := t.TempDir() + writeFixture(t, withoutGit, base) + withGit := t.TempDir() + writeFixture(t, withGit, base) + mustMkdir(t, filepath.Join(withGit, ".git")) + + for _, tc := range []struct{ name, root string }{ + {"沒有版控", withoutGit}, {"有版控", withGit}, + } { + payload, plan := scanWithPlan(t, tc.root) + reasons := map[string]string{} + for _, d := range payload.ExcludedDirs { + reasons[d.Path] = d.Reason + } + t.Logf("%s:策略=%s|跳過=%v", tc.name, plan.Mode, reasons) + + if reasons["node_modules"] == "" { + t.Errorf("%s:node_modules 沒被排除——它是誰的套件跟有沒有版控無關", tc.name) + } + // 🔴 本測試釘的是**排除規則那一層**:`build` 旁邊沒有任何專案檔, + // 所以無論有沒有版控,都不准把它當成「建置工具產生的」。 + // (帶 `.git` 時 `build` 仍會因為**模式選擇**落在文件區之外而不收—— + // 那是另一層,見本函式上方的已知落差說明。) + if strings.Contains(reasons["build"], "建置") { + t.Errorf("%s:`build` 被判成建置產物(%q),但它旁邊沒有任何專案檔"+ + "——版控訊號不得改變這個判斷", tc.name, reasons["build"]) + } + } +} diff --git a/ingestplan_wiring_test.go b/ingestplan_wiring_test.go new file mode 100644 index 0000000..8360cef --- /dev/null +++ b/ingestplan_wiring_test.go @@ -0,0 +1,193 @@ +// ingestplan_wiring_test.go — arcrun-rag#104 第二輪。 +// +// 🔴 這一組測的是**接線**,不是規則本身(那在 ingestplan_test.go)。 +// +// 為什麼要獨立測(本票 2026-08-16 的真正教訓): +// 那天讀源碼的人看到 `direct.go` 裡一行 `skipDirNames := {"system-dev": true}`, +// 就宣告「排除清單完整正確,但跑的走訪器不讀它」——並把它寫成三次同款事故的第三次。 +// **實測之後那個診斷是錯的**:同一個呼叫裡下面幾行就寫著 `Plan: plan`,兩張表都接上了。 +// +// 但那個誤判本身是有原因的,而原因是真的缺陷: +// +// ① 同一件事有**兩張表分居兩處** ⇒ 讀源碼的人只看到一張 +// ② 排除規則生不生效,取決於**呼叫端記不記得傳** `Plan` +// ③ 真正沒接上的是**可見性**:`ExcludedByPlan`/`Plan` 只有 CLI 讀, +// daemon(使用者真正走的那條路)拿到就丟掉 +// +// ⇒ 本檔把這三件事各釘一根釘子。**如果哪天有人又走了一條沒接上排除規則的路, +// +// 這裡要紅。** +package collector + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +// ─────────────────────────────────────────────────────────────────────────── +// 釘子 ①:走訪器自己裝判準——呼叫端「忘了傳 Plan」這個失敗模式不存在 +// ─────────────────────────────────────────────────────────────────────────── + +// 這是本輪修法的核心性質:**裸呼叫 Scan(完全不給策略)也必須排除掉別人的套件。** +// 以前這會整包收進去,因為排除規則要呼叫端主動接上。 +func TestWiring_裸呼叫Scan也必須排除別人的套件(t *testing.T) { + root := t.TempDir() + writeFixture(t, root, map[string]string{ + "我的筆記.md": "# 我自己寫的", + // 票上的實據:undici 的 API 文件與別人的授權條款被做成了「知識卡」 + "node_modules/undici/docs/api/Pool.md": "# Pool", + "node_modules/undici/docs/api/Dispatcher.md": "# Dispatcher", + "node_modules/undici/README.md": "# undici", + "node_modules/crypto-browserify/LICENSE.md": "MIT License", + }) + + m := &Manifest{Entries: map[string]*ManifestEntry{}} + payload, err := Scan(root, m, ScanOptions{}) // ← 刻意什麼都不給 + if err != nil { + t.Fatal(err) + } + got := eventPaths(payload) + t.Logf("裸呼叫送出:%v", got) + + if payload.Plan.Mode == "" { + t.Fatal("Scan 沒有自己算出收檔策略——排除規則又變成「呼叫端記得傳才生效」了") + } + for _, p := range got { + if strings.Contains(p, "node_modules/") { + t.Fatalf("裸呼叫 Scan 收進了別人的套件:%s", p) + } + } + if len(got) != 1 || got[0] != "我的筆記.md" { + t.Fatalf("應該只送使用者自己的那一份,實得:%v", got) + } +} + +// 釘子 ①之二:**源碼層**——每一個 Scan 的呼叫端,要嘛不給 Plan(讓 Scan 自己算), +// 要嘛給一個真的算過的 Plan。禁止再出現「自己手捏一張目錄黑名單」的第二條路。 +// +// 這一條會在有人新增 `SkipDirNames: map[string]bool{...}` 當排除清單時變紅 +// ——那正是 2026-08-16 讓人誤判的那個形狀。 +func TestWiring_不准再有第二張排除清單(t *testing.T) { + fset := token.NewFileSet() + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + checked := 0 + for _, f := range files { + if strings.HasSuffix(f, "_test.go") { + continue + } + src, err := parser.ParseFile(fset, f, nil, parser.ParseComments) + if err != nil { + t.Fatalf("解析 %s 失敗:%v", f, err) + } + ast.Inspect(src, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + id, ok := call.Fun.(*ast.Ident) + if !ok || id.Name != "Scan" || len(call.Args) != 3 { + return true + } + checked++ + lit, ok := call.Args[2].(*ast.CompositeLit) + if !ok { + return true // 選項是變數,交給行為測試把關 + } + for _, el := range lit.Elts { + kv, ok := el.(*ast.KeyValueExpr) + if !ok { + continue + } + key, ok := kv.Key.(*ast.Ident) + if !ok || key.Name != "SkipDirNames" { + continue + } + // SkipDirNames 是呼叫端自訂的逃生門,不該被拿來當排除清單用。 + if _, isLit := kv.Value.(*ast.CompositeLit); isLit { + t.Errorf("%s:%d 又在 Scan 的呼叫端手捏排除清單(SkipDirNames)。"+ + "排除判準只准住在 ingestplan.go——兩張表分居兩處,正是 #104 誤判的成因。", + f, fset.Position(kv.Pos()).Line) + } + } + return true + }) + } + if checked == 0 { + t.Fatal("一個 Scan 呼叫端都沒掃到——這個測試沒有在守任何東西(是不是檔案改名了?)") + } + t.Logf("檢查了 %d 個 Scan 呼叫端", checked) +} + +// ─────────────────────────────────────────────────────────────────────────── +// 釘子 ②:排除掉的東西必須看得見——而且不能是一個 0 +// ─────────────────────────────────────────────────────────────────────────── + +// 🔴 2026-08-16 實測 leo 的 `pms`(2,127 檔、78% 在依賴目錄底下): +// 絕大多數被排除,而 `ExcludedByPlan` 回報 **0**——因為它只數「走進去才被逐檔擋下」的檔, +// 整棵剪掉的子樹一個都不算。講一個 0 跟安靜地少收,對使用者是同一件事。 +func TestWiring_整棵剪掉的資料夾要講得出是哪些為什麼(t *testing.T) { + root := t.TempDir() + writeFixture(t, root, map[string]string{ + "我的筆記.md": "# 我的", + "package.json": `{"name":"x"}`, + "node_modules/undici/README.md": "# undici", + "dist/bundle-notes.md": "# 建置產物", + "secret-stuff/內部.md": "# 不想收", + ".gitignore": "secret-stuff/\n", + }) + + payload, _ := scanWithPlan(t, root) + if payload.ExcludedDirCount == 0 { + t.Fatal("整棵剪掉了東西卻回報 0 個資料夾——使用者無從知道少收了什麼") + } + byPath := map[string]string{} + for _, d := range payload.ExcludedDirs { + byPath[d.Path] = d.Reason + t.Logf("跳過 %s — %s", d.Path, d.Reason) + } + for _, want := range []string{"node_modules", "dist", "secret-stuff"} { + if byPath[want] == "" { + t.Errorf("%s 被跳過了,卻沒有講出理由(實得清單:%v)", want, byPath) + } + } + // 理由必須是人話,不是路徑術語或規則代號。 + if r := byPath["secret-stuff"]; !strings.Contains(r, ".gitignore") { + t.Errorf("使用者自己宣告的排除,理由要講明是他的 .gitignore 說的,實得:%q", r) + } +} + +// 可見性接線:daemon(使用者真正走的那條路)必須把策略寫進 status.json。 +// +// 🔴 這一條就是 2026-08-16 抓到的第四例「東西做好了但不在執行路徑上」: +// #104 第一階段的收工留言宣稱策略「走進 status.json」,實際上整個 repo 裡 +// 只有 CLI 的 stderr 讀過它,daemon 拿到 payload 就把兩個欄位丟掉。 +func TestWiring_status要帶得出收檔策略(t *testing.T) { + st := SyncStatus{FolderPlans: map[string]FolderPlanStatus{ + "/tmp/x": {Mode: "docs-only", Reason: "只讀文件", ExcludedDirCount: 3}, + }} + path := filepath.Join(t.TempDir(), "status.json") + if err := SaveSyncStatus(path, st); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"folder_plans", "docs-only", "只讀文件", "excluded_dir_count"} { + if !strings.Contains(string(raw), want) { + t.Fatalf("status.json 裡沒有 %q——使用者的畫面就講不出「為什麼只收這些」\n%s", want, raw) + } + } + back, err := LoadSyncStatus(path) + if err != nil || back.FolderPlans["/tmp/x"].Mode != "docs-only" { + t.Fatalf("讀回來對不上:%+v(err=%v)", back.FolderPlans, err) + } +} diff --git a/main.go b/main.go index 12a7f89..dbad6cf 100644 --- a/main.go +++ b/main.go @@ -182,6 +182,18 @@ func run(args []string, mode runMode) int { if payload.ExcludedByPlan > 0 { fmt.Fprintf(os.Stderr, "依這個策略跳過了 %d 個檔案。\n", payload.ExcludedByPlan) } + // 🔴 上面那個數字只數得到「走進去了才被逐檔擋下」的檔——整棵剪掉的子樹一個都不算。 + // 實測 leo 的 `pms`(2,127 檔,78% 在依賴目錄底下):排除了絕大多數,這個數字卻是 0。 + // ⇒ 剪掉的目錄要逐筆講,不然「看得見」只是看得見一個 0。 + if payload.ExcludedDirCount > 0 { + fmt.Fprintf(os.Stderr, "整個跳過了 %d 個資料夾:\n", payload.ExcludedDirCount) + for _, d := range payload.ExcludedDirs { + fmt.Fprintf(os.Stderr, " · %s — %s\n", d.Path, d.Reason) + } + if payload.ExcludedDirCount > len(payload.ExcludedDirs) { + fmt.Fprintf(os.Stderr, " …等 %d 個。\n", payload.ExcludedDirCount) + } + } if len(plan.OtherWikiDirs) > 0 { fmt.Fprintf(os.Stderr, "這個資料夾底下還有 %d 個子專案有自己的知識庫,我沒有收(要收請個別加進看守清單):%v\n", diff --git a/scan.go b/scan.go index 86427dd..b218a01 100644 --- a/scan.go +++ b/scan.go @@ -133,8 +133,18 @@ type TriggerPayload struct { // 使用者接上一個一萬檔的 repo 只看到 32 個進度,會以為系統壞了。 Plan IngestPlan `json:"-"` ExcludedByPlan int `json:"-"` + // ExcludedDirs/ExcludedDirCount=整棵被剪掉的目錄與理由(2026-08-16 補)。 + // 🔴 ExcludedByPlan 只數得到「走進去了才被逐檔擋下」的檔;整棵剪掉的子樹 + // 一個都數不到 ⇒ 拿 leo 真實的 pms 跑一輪,2,127 個檔裡絕大多數被排除, + // 而畫面上的數字是 **0**。講一個 0 跟安靜地少收,對使用者是同一件事。 + ExcludedDirs []ExcludedDir `json:"-"` // 已排序,上限 MaxExcludedDirsListed + ExcludedDirCount int `json:"-"` // 總數(可能大於清單長度) } +// MaxExcludedDirsListed:最多逐筆列幾個被跳過的目錄。超過的只反映在 ExcludedDirCount +// (同 MaxSkippedListed 的道理:狀態檔不該被撐成一面看不完的清單牆)。 +const MaxExcludedDirsListed = 20 + // FormatDuplicate=同一份內容被偵測到有多種格式並存(同檔名主幹、不同副檔名)。 // // 🔴 為什麼要有這個(2026-08-07,leo 實據):封測者 Evan 給的資料集 @@ -232,8 +242,14 @@ 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:收檔策略(arcrun-rag#104)。 + // + // 🔴 2026-08-16 改成「不填就自己算」(Mode == "" ⇒ Scan 自己呼叫 PlanIngest)。 + // 原本的註解寫著「要拿到 #104 的效果就傳 PlanIngest(root)」——也就是**排除規則 + // 要不要生效,取決於呼叫端記不記得傳**。那正是這一票(以及同一天 #88、#46、 + // Arcrun#125)的共同形狀:**能力做好了,而它不在會被執行的那條路上**。 + // 讓走訪器自己裝上判準,「忘了接」這個失敗模式就不存在了。 + // 呼叫端仍可覆寫(測試要造特定情境時照傳即可)。 Plan IngestPlan } @@ -269,6 +285,11 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { if opts.MaxRemovedRatio <= 0 { opts.MaxRemovedRatio = DefaultMaxRemovedRatio } + // 🔴 呼叫端沒給策略就自己算——見 ScanOptions.Plan 的說明。 + // 這一行就是「排除規則不可能不在執行路徑上」的保證本身。 + if opts.Plan.Mode == "" { + opts.Plan = PlanIngest(root) + } orig := m.Entries manifestCountBefore := len(orig) @@ -280,6 +301,9 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { // arcrun-rag#104:被策略擋掉的檔案數。**一定要數出來**——票上的紅線是 // 「用戶要知道有 8,000 個檔沒被收,因為它們是程式碼」,不是安靜地少收。 excludedByPlan := 0 + // 整棵剪掉的目錄與理由。**剪掉的重點就是不走進去**,所以這裡記的是目錄不是檔案數 + // ——使用者要知道的本來就是「哪幾個資料夾沒收、為什麼」。見 ExcludedDir 的說明。 + var excludedDirs []ExcludedDir err := filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error { if werr != nil { return werr @@ -297,14 +321,18 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { return filepath.SkipDir // 隱藏目錄(.git、.obsidian…)整棵跳過 } if p != root && opts.SkipDirNames[name] { - return filepath.SkipDir // 名單目錄(system-dev…)整棵跳過 + return filepath.SkipDir // 名單目錄(呼叫端自訂)整棵跳過 } - // #104:依策略整棵跳過(依賴/建置產物/範本/worktree/巢狀 repo/ - // 以及非本次策略要收的區域)。整棵跳掉的檔不逐一計數—— - // 那個數字對使用者沒有意義,Plan.Reason 那句話才是他要的解釋。 + // #104:依策略整棵跳過(使用者的 .gitignore/依賴/建置產物/範本/ + // worktree/巢狀 repo/以及非本次策略要收的區域)。 + // 🔴 每一次剪枝都要留下「哪一個、為什麼」——票上的紅線是排除規則要看得見, + // 而 2026-08-16 實測發現原本整棵剪掉的部分完全沒有被記錄。 if p != root { - if rel, ok := relOf(); ok && opts.Plan.SkipsDir(rel, p) { - return filepath.SkipDir + if rel, ok := relOf(); ok { + if skip, why := opts.Plan.SkipsDirWhy(rel, p); skip { + excludedDirs = append(excludedDirs, ExcludedDir{Path: rel, Reason: why}) + return filepath.SkipDir + } } } return nil @@ -543,6 +571,12 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { events = []Event{} } sort.Slice(skipped, func(i, j int) bool { return skipped[i].Path < skipped[j].Path }) + // 排序=畫面每輪穩定;裁切前先記總數,不然「等 N 個」會少報。 + sort.Slice(excludedDirs, func(i, j int) bool { return excludedDirs[i].Path < excludedDirs[j].Path }) + excludedDirCount := len(excludedDirs) + if len(excludedDirs) > MaxExcludedDirsListed { + excludedDirs = excludedDirs[:MaxExcludedDirsListed] + } return &TriggerPayload{ SchemaVersion: 1, FolderID: m.FolderID, @@ -556,5 +590,7 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { DuplicateFormats: duplicateFormats, Plan: opts.Plan, ExcludedByPlan: excludedByPlan, + ExcludedDirs: excludedDirs, + ExcludedDirCount: excludedDirCount, }, nil } diff --git a/sync_status.go b/sync_status.go index 09f2463..332b018 100644 --- a/sync_status.go +++ b/sync_status.go @@ -100,6 +100,10 @@ type SyncStatus struct { // 沒人判斷得出那到底是什麼檔。 SkippedOtherNames []string `json:"skipped_other_names,omitempty"` + // FolderPlans=每個看守資料夾這一輪的收檔策略與少收了什麼(key=資料夾路徑)。 + // 與 SkippedDocs 同族:每輪照現況重算的快照,**不進 CarryForwardActivity**。 + FolderPlans map[string]FolderPlanStatus `json:"folder_plans,omitempty"` + // ── t210 統計層(2026-08-08,Evan 封測:「9000 個檔,雲端只有 101 張卡, // 畫面卻說 20 份沒送——這幾個數字到底是怎麼回事?」)────────────────────── // @@ -117,6 +121,29 @@ type SyncStatus struct { FailureBreakdown FailureBreakdown `json:"failure_breakdown"` } +// FolderPlanStatus=某個看守資料夾這一輪用了什麼收檔策略、據此少收了什麼 +// (arcrun-rag#104,2026-08-16 補接線)。 +// +// 🔴 為什麼補這個:#104 第一階段的收工留言宣稱「策略、理由、擋掉幾個檔… +// 都經 TriggerPayload.Plan 走進 status.json」,**但那從來沒有發生過**—— +// `ExcludedByPlan` 與 `Plan` 在整個 repo 裡只有 `main.go`(CLI,走 stderr)讀過, +// daemon(`direct.go`,也就是 App 真正在跑的那條路)拿到 payload 之後直接把這兩欄丟掉。 +// ⇒ 使用者接上一個兩千檔的專案、只看到九份進度,畫面**一個字都不會解釋**。 +// +// 這是與本票真兇同一天、同一個形狀的第四例:**東西做好了,但不在會被執行的那條路上。** +// 差別只在前三例是「沒接上」,這一例是「接了一半——CLI 有,使用者走的那條沒有」。 +type FolderPlanStatus struct { + Mode string `json:"mode"` // all/curated-wiki/docs-only + Reason string `json:"reason"` // 一句話講給使用者聽的「為什麼只收這些」 + // ExcludedFiles=走進去了但逐檔被策略擋下的數量。 + ExcludedFiles int `json:"excluded_files"` + // ExcludedDirs/ExcludedDirCount=整棵被剪掉的目錄與理由(清單有上限,總數看 Count)。 + ExcludedDirs []ExcludedDir `json:"excluded_dirs,omitempty"` + ExcludedDirCount int `json:"excluded_dir_count"` + // OtherWikiDirs=底下其他子專案自己的知識庫,刻意不收但一定要講。 + OtherWikiDirs []string `json:"other_wiki_dirs,omitempty"` +} + // MaxSkippedListed:status.json 裡最多逐檔列幾個。 // 超過的只反映在 SkippedDocCount,UI 說「…等 N 個」——避免整批舊 Office 檔 // 把狀態檔撐大,也避免畫面變成一面看不完的檔名牆。