Files
arcrun-collector/ignorerules_test.go
T
Leo 97309b5580 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>
2026-08-16 22:48:22 +08:00

140 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)=%vwant %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)=%vwant %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("跳脫語法未支援,就不該命中")
}
}