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:
+219
-20
@@ -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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user