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:
@@ -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{}
|
||||
|
||||
+262
@@ -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()
|
||||
}
|
||||
@@ -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("跳脫語法未支援,就不該命中")
|
||||
}
|
||||
}
|
||||
+170
-27
@@ -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+"/")
|
||||
|
||||
+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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 檔
|
||||
// 把狀態檔撐大,也避免畫面變成一面看不完的檔名牆。
|
||||
|
||||
Reference in New Issue
Block a user