fix(collector): 排除判準不看版控、不誤殺筆記庫、剪掉什麼講得出來(arcrun-rag#104)

票上寫的真兇是錯的。`direct.go` 那一行 `skipDirNames{"system-dev"}` 不是唯一的
排除清單——同一個 Scan 呼叫下面幾行就是 `Plan: plan`,#104 的清單一直都接著。
拿 leo 真實的 `pms` 唯讀跑一輪現行 main:策略 docs-only、送 9 個檔、node_modules 零個。
他 08-16 看到 undici 文件,是因為手上的 daemon 是 v0.18.27,而修法 cc6e500 要到
v0.18.28(08-16 16:13,7f379d0)才被戳版號——那支 commit 自己就寫著
「changelog 停在 v0.18.27,而 collector/ 早已往前走 30 個檔(…cc6e500…)」。

但那個誤判之所以會發生,是因為底下有四個真的缺陷,這一版把它們一起修掉:

① 兩張表分居兩處 ⇒ 讀源碼的人只看得到一張。
   `system-dev` 的保護搬進 IngestPlan(templateOwnedDirNames),
   direct.go 不再手捏第二張清單。判準只剩一個地方。

② 排除規則生不生效,取決於呼叫端記不記得傳 Plan。
   改成 Scan 自己算(Mode == "" ⇒ PlanIngest)。「忘了接」這個失敗模式不存在了。

③ 一張大表把「沒有人會這樣命名」與「這是普通英文字」混在一起,於是**誤殺**。
   實測:一般筆記庫 8 份筆記只送出 1 份(build/樂高作品集、out/外出旅遊、
   vendor/廠商聯絡簿…全被當成建置產物),而且回報「擋掉 0 個」。
   拆成三種理由,強度不同、要求的佐證也不同:
     ① 使用者的 .gitignore 說的(新增 ignorerules.go,git 語法的安全子集)
     ② 名字本身就不是人話(node_modules、__pycache__…)——無條件
     ③ 泛用名(build/dist/out/vendor…)——**旁邊真的擺著專案檔才算**
   🔴 判準一律不看 `.git`(leo 2026-08-16:「你不需要判斷有沒有 git,
   我的 KB 筆記庫也有 git,是否用 github/gitea 追蹤完全沒意義」)。
   `.gitignore` 只讀內容當線索,不拿存在當門檻。
   順帶:鎖定檔(pnpm-lock.yaml…)不是知識——`.yaml` 進白名單後它變成了「知識」。

④ 「排除規則要看得見」只做了一半:整棵剪掉的子樹一個都沒數(pms 實測回報 0),
   而且 Plan/ExcludedByPlan 只有 CLI 讀,daemon(使用者真正走的那條路)拿到就丟。
   新增 ExcludedDirs(路徑+人話理由)+ SyncStatus.FolderPlans 寫進 status.json。

實測(唯讀跑 leo 的 `/Users/youlinhsieh/Documents/tech_projects/pms`):
  139 個文件檔 → 送出 7 個,全是他自己的 README/docs;
  6 個資料夾整棵跳過,每個都講得出理由;node_modules 與授權條款 0 個。

測試:collector 全綠(新增 12 案,含「裸呼叫 Scan 也必須排除別人的套件」、
「不准再有第二張排除清單」的源碼層守門、筆記庫不誤殺、同名看旁邊擺什麼決定);
arcrun-app 全綠。未出貨、未推 main。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 22:47:10 +08:00
parent 895d672177
commit 97309b5580
9 changed files with 1092 additions and 71 deletions
+44 -8
View File
@@ -133,8 +133,18 @@ type TriggerPayload struct {
// 使用者接上一個一萬檔的 repo 只看到 32 個進度,會以為系統壞了。
Plan IngestPlan `json:"-"`
ExcludedByPlan int `json:"-"`
// ExcludedDirsExcludedDirCount=整棵被剪掉的目錄與理由(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
}