Files
arcrun-collector/ignorerules.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

263 lines
8.6 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.go — 把使用者自己寫的 `.gitignore` 當成「他已經宣告過的排除清單」
// arcrun-rag#1042026-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/*/`);再深的漏掉,代價只是那一層少一種判準,
// 上面還有 toolOwnedambiguous 兩道接著擋。
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()
}