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
+25 -15
View File
@@ -632,6 +632,10 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
skippedOther := 0 skippedOther := 0
var skippedOtherNames []string var skippedOtherNames []string
// arcrun-rag#104:每個看守資料夾這一輪的收檔策略與「少收了什麼」。
// key=資料夾路徑(同一個根被多帳號看守時後寫覆蓋——策略只看資料夾,與帳號無關)。
folderPlans := map[string]FolderPlanStatus{}
// t210:跨帳號、跨資料夾累加的總量進度(見 rootProgress 註解)。 // t210:跨帳號、跨資料夾累加的總量進度(見 rootProgress 註解)。
var totalProgress SyncProgress var totalProgress SyncProgress
var stuckReasons []string var stuckReasons []string
@@ -732,6 +736,16 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
skippedOther += p.SkippedOther skippedOther += p.SkippedOther
// 同理,每一根的檔名都要收(上限在寫進 status 時才裁)。 // 同理,每一根的檔名都要收(上限在寫進 status 時才裁)。
skippedOtherNames = append(skippedOtherNames, p.SkippedOtherNames...) 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] skippedOtherNames = skippedOtherNames[:maxOtherNames]
} }
st.SkippedOtherNames = skippedOtherNames st.SkippedOtherNames = skippedOtherNames
// #104:收檔策略與被排除的東西 —— 使用者要知道「有幾千個檔沒被收、為什麼」。
if len(folderPlans) > 0 {
st.FolderPlans = folderPlans
}
st.SkippedDocCount = len(skippedSeen) st.SkippedDocCount = len(skippedSeen)
for _, sf := range skippedSeen { for _, sf := range skippedSeen {
st.SkippedDocs = append(st.SkippedDocs, sf) st.SkippedDocs = append(st.SkippedDocs, sf)
@@ -1220,19 +1238,13 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
// wiki(沒有 wiki 才退到文件區),是一般資料夾/筆記庫才全收。見 ingestplan.go。 // wiki(沒有 wiki 才退到文件區),是一般資料夾/筆記庫才全收。見 ingestplan.go。
plan := PlanIngest(absRoot) plan := PlanIngest(absRoot)
// 🔴 #104 的一個必然後果:curated-wiki 模式要收的正是 `system-dev/wiki/` // 🔴 2026-08-16:這裡以前還手捏了**第二張**排除表
//daemon-beta task 2 為了「template 代裝的產物區不要被當原稿」把整個 //`skipDirNames := {"system-dev": true}`daemon-beta task 2 template 產物區保護),
// `system-dev` 列進 SkipDirNames——兩者直接對撞,不處理的話這個模式會一個檔都收不到。 // 而 #104 的排除清單住在 ingestplan.go。**兩張表分居兩處** ⇒ 2026-08-16 讀源碼的人
// // 只看到這一張,就把「排除清單根本沒接上」寫成了真兇——實際上兩張都接上了
// 解法不是拿掉那條保護,是**看它保護的是誰**:task 2 擋的是「**我們自己**代裝進 //(下面那行 `Plan: plan` 就是)。誤判本身正是「同一件事有兩個地方管」的代價。
// 使用者資料夾的 template 產物」;curated-wiki 模式的前提則是「**使用者自己** // ⇒ 那條保護已搬進 IngestPlantemplateOwnedDirNames),連同「curated-wiki 模式
// 在他的 repo 裡整理好的知識庫」——同一個路徑,兩種身分,由 PlanIngest 分辨 // 要收的正是 system-dev/wiki」這個例外一起 ⇒ **判準只剩一個地方,沒有第二張表可漏看。**
// (他的 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{}
}
payload, err := Scan(absRoot, m, ScanOptions{ payload, err := Scan(absRoot, m, ScanOptions{
MaxRemovedRatio: cfg.MaxRemoved, MaxRemovedRatio: cfg.MaxRemoved,
SkipPaths: map[string]bool{ SkipPaths: map[string]bool{
@@ -1240,8 +1252,6 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
// template 代裝的根層 CLAUDE.md 是 CC 設定檔,永遠不是用戶知識(task 2) // template 代裝的根層 CLAUDE.md 是 CC 設定檔,永遠不是用戶知識(task 2)
filepath.Join(absRoot, "CLAUDE.md"): true, filepath.Join(absRoot, "CLAUDE.md"): true,
}, },
// template 代裝後 system-dev/wiki 產物區)不得被當原稿掃進 ingest(task 2
SkipDirNames: skipDirNames,
Plan: plan, Plan: plan,
}) })
if err != nil { if err != nil {
+262
View File
@@ -0,0 +1,262 @@
// 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()
}
+139
View File
@@ -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)=%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("跳脫語法未支援,就不該命中")
}
}
+170 -27
View File
@@ -62,6 +62,27 @@ type IngestPlan struct {
// OtherWikiDirs=這個 repo 底下**其他**子專案自己的 wiki(相對路徑)。 // OtherWikiDirs=這個 repo 底下**其他**子專案自己的 wiki(相對路徑)。
// 刻意不收(見 wantsPath 的說明),但一定要列出來——不然使用者只會覺得東西不見了。 // 刻意不收(見 wantsPath 的說明),但一定要列出來——不然使用者只會覺得東西不見了。
OtherWikiDirs []string `json:"other_wiki_dirs,omitempty"` 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=「整理好的知識庫」慣例位置,依優先序。 // curatedWikiCandidates=「整理好的知識庫」慣例位置,依優先序。
@@ -72,26 +93,106 @@ var curatedWikiCandidates = []string{"system-dev/wiki", "docs/wiki", "wiki"}
// docDirCandidates=沒有現成 wiki 時,「文件住在哪」的慣例位置。 // docDirCandidates=沒有現成 wiki 時,「文件住在哪」的慣例位置。
var docDirCandidates = []string{"docs", "doc", "documentation"} var docDirCandidates = []string{"docs", "doc", "documentation"}
// noiseDirNames=任何模式下都整棵跳過的目錄名。 // ─────────────────────────────────────────────────────────────────────────────
// 排除一個目錄的三種理由。**順序就是強度**,理由不同、要求的佐證也不同。
// //
// 分三類,全部都是「這個 repo 的零件,不是誰的知識」 // 🔴 2026-08-16 修正(本檔原本只有一張大表 noiseDirNames,任何模式一律照殺)
// - 依賴:別人的原始碼,不是使用者的 // 那張表把「沒有人會這樣命名」與「這是普通英文字」混在一起,於是同時犯了兩個方向的錯——
// - 建置產物:從別的檔生出來的,收了就是同一份內容收兩次(#104 實據:`.next``.vercel` //
// - 範本/樣板:`templatefs` 是我們自己要鋪給別人的檔,收自己鋪的東西最荒謬 // 實測:一個一般筆記庫(無 `.git`)放 8 個 .md,其中 7 個分別住在
// #104 實據:8 份 `collector/templatefs/system-dev/wiki` // `build/`、`專案/dist/`、`out/`、`target/`、`coverage/`、`bin/`、`fixtures/`
var noiseDirNames = map[string]bool{ // **只送出 1 個,而且 ExcludedByPlan 回報 0**——安靜地弄丟使用者七份筆記。
// 依賴 // `build` 可以是樂高作品集,`out` 可以是外出旅遊,`vendor` 可以是廠商。)
"node_modules": true, "vendor": true, "bower_components": true, //
"site-packages": true, "venv": true, "virtualenv": true, "__pycache__": true, // ⇒ 判準要問的不是「這個名字像不像雜訊」,是「**這東西是誰放的**」。
// ─────────────────────────────────────────────────────────────────────────────
// 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, "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, ".next": true, ".nuxt": true, ".vercel": true, ".output": true, ".turbo": true,
".parcel-cache": true, "coverage": true, ".pytest_cache": true, ".gradle": true, ".parcel-cache": true, ".pytest_cache": true, ".gradle": true,
// 範本/樣板我們自己鋪給別人的檔 // 範本/樣板我們自己鋪給別人的檔,收自己鋪的東西最荒謬
"templatefs": true, "template-fs": true, "skeleton": true, // #104 實據:8 份 `collector/templatefs/system-dev/wiki`
// 測試素材(不是知識,是給程式吃的樣本) "templatefs": true, "template-fs": true,
"testdata": true, "fixtures": true, "__fixtures__": true, "__snapshots__": 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 被加進 allowedExtInkStoneCo#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,
}
// templateOwnedDirNamessystem-dev-template 鋪出來的產物區,任何深度都不當原稿
// daemon-beta task 22026-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 回答「這個叫 builddist/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 在走訪前呼叫一次。** // PlanIngest 決定某個監看根的收檔策略。**這支是 #104 的入口,Scan 在走訪前呼叫一次。**
@@ -103,10 +204,14 @@ var noiseDirNames = map[string]bool{
// 誰的筆記本。判準只有一個地方(repoguard.go),兩張票共用,不會漂移。 // 誰的筆記本。判準只有一個地方(repoguard.go),兩張票共用,不會漂移。
func PlanIngest(absRoot string) IngestPlan { func PlanIngest(absRoot string) IngestPlan {
repoRoot := DetectRepoRoot(absRoot) repoRoot := DetectRepoRoot(absRoot)
// 使用者自己寫的排除宣告——只讀它的**內容**當線索,不拿它的存在當門檻。
ignore := LoadIgnoreRules(absRoot)
if repoRoot == "" { if repoRoot == "" {
return IngestPlan{ return IngestPlan{
Mode: IngestAll, Mode: IngestAll,
Reason: "這是一般資料夾,裡面的文件我全部都會讀。", Reason: "這是一般資料夾,裡面的文件我全部都會讀(別人的套件與建置產物除外)。",
ignore: ignore,
} }
} }
@@ -119,6 +224,7 @@ func PlanIngest(absRoot string) IngestPlan {
Reason: "這是一個開發專案,而且你已經整理好一份知識庫(" + wiki + ")——" + Reason: "這是一個開發專案,而且你已經整理好一份知識庫(" + wiki + ")——" +
"我直接讀那一份就好,不再把整個專案的原始碼與零散檔案重萃一次。", "我直接讀那一份就好,不再把整個專案的原始碼與零散檔案重萃一次。",
OtherWikiDirs: others, OtherWikiDirs: others,
ignore: ignore,
} }
} }
@@ -133,6 +239,7 @@ func PlanIngest(absRoot string) IngestPlan {
DocRelDirs: docs, DocRelDirs: docs,
Reason: reason, Reason: reason,
OtherWikiDirs: others, OtherWikiDirs: others,
ignore: ignore,
} }
} }
@@ -208,7 +315,9 @@ func otherWikiDirs(absRoot string) []string {
return nil return nil
} }
name := d.Name() name := d.Name()
if strings.HasPrefix(name, ".") || noiseDirNames[name] { // 與 SkipsDirWhy 的 ②③ 同一組判準(泛用名同樣要旁邊有專案檔才算)。
if strings.HasPrefix(name, ".") || toolOwnedDirNames[name] ||
(ambiguousBuildDirNames[name] && looksGenerated(p)) {
return filepath.SkipDir return filepath.SkipDir
} }
if IsLinkedWorktree(p) { if IsLinkedWorktree(p) {
@@ -244,30 +353,56 @@ func otherWikiDirs(absRoot string) []string {
// 再加上模式限定的:curated-wiki 只走那一份 wiki 的路;docs-only 只走文件目錄的路。 // 再加上模式限定的:curated-wiki 只走那一份 wiki 的路;docs-only 只走文件目錄的路。
// (隱藏目錄由 Scan 自己擋,那條規則比本檔更早存在,不搬過來。) // (隱藏目錄由 Scan 自己擋,那條規則比本檔更早存在,不搬過來。)
func (p IngestPlan) SkipsDir(relSlash, absPath string) bool { 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) 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) { if IsLinkedWorktree(absPath) {
return true return true, "這是同一個專案的第二份簽出(git worktree),內容與主資料夾重複"
} }
// 巢狀 repo:監看根自己不算(relSlash == "." 走不到這裡,Scan 只對子目錄呼叫)。 // 巢狀 repo:監看根自己不算(relSlash == "." 走不到這裡,Scan 只對子目錄呼叫)。
if IsRepoRoot(absPath) { if IsRepoRoot(absPath) {
return true return true, "這是另一個獨立的專案,要收請把它自己加進看守清單"
} }
switch p.Mode { switch p.Mode {
case IngestCuratedWiki: case IngestCuratedWiki:
// 只有「通往那份 wiki 的路」與「那份 wiki 底下」要走。 // 只有「通往那份 wiki 的路」與「那份 wiki 底下」要走。
return !onPathTo(relSlash, p.WikiRelDir) if !onPathTo(relSlash, p.WikiRelDir) {
return true, "這次只讀你整理好的 " + p.WikiRelDir
}
case IngestDocsOnly: case IngestDocsOnly:
for _, d := range p.DocRelDirs { for _, d := range p.DocRelDirs {
if onPathTo(relSlash, d) { if onPathTo(relSlash, d) {
return false return false, ""
} }
} }
return true return true, "這是一個開發專案,這次只讀文件區,不讀程式碼"
} }
return false return false, ""
} }
// KeepsFile 回答「這個檔要不要收」。relSlash 是相對監看根的路徑。 // KeepsFile 回答「這個檔要不要收」。relSlash 是相對監看根的路徑。
@@ -277,6 +412,14 @@ func (p IngestPlan) SkipsDir(relSlash, absPath string) bool {
// 專案唯一的入門文件,把它們漏掉,一個只有 README 的 repo 會變成一個檔都不收)。 // 專案唯一的入門文件,把它們漏掉,一個只有 README 的 repo 會變成一個檔都不收)。
// all:全收,判準交回 Scan 原本的副檔名白名單。 // all:全收,判準交回 Scan 原本的副檔名白名單。
func (p IngestPlan) KeepsFile(relSlash string) bool { 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 { switch p.Mode {
case IngestCuratedWiki: case IngestCuratedWiki:
return strings.HasPrefix(relSlash, p.WikiRelDir+"/") return strings.HasPrefix(relSlash, p.WikiRelDir+"/")
+219 -20
View File
@@ -24,21 +24,41 @@ func eventPaths(p *TriggerPayload) []string {
return out return out
} }
// scanWithPlan 照 daemon 的真實接法跑一輪掃描PlanIngest curated-wiki 時解除 // scanWithPlan 照 daemon 的真實接法跑一輪掃描
// system-dev 的 SkipDirNames,見 direct.go 那段註解)。 //
// 🔴 2026-08-16 簡化:這裡以前要自己複製 direct.go 那段「curated-wiki 時解除
// system-dev 的 SkipDirNames」的邏輯——**測試複製了受測程式的一半,於是它驗的是
// 我抄得對不對,不是產品對不對**。那段判準已收進 IngestPlan,兩邊都不必再抄。
func scanWithPlan(t *testing.T, root string) (*TriggerPayload, IngestPlan) { func scanWithPlan(t *testing.T, root string) (*TriggerPayload, IngestPlan) {
t.Helper() 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{}} 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 { if err != nil {
t.Fatalf("掃描失敗:%v", err) 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。 // makeMonorepoFixture 造一個「leo 的 InkStoneCo」形狀的 repo。
@@ -115,19 +135,15 @@ func makeMonorepoFixture(t *testing.T) (root string, curatedCount int) {
func TestPlanIngest_MonorepoSendsOnlyCuratedWiki(t *testing.T) { func TestPlanIngest_MonorepoSendsOnlyCuratedWiki(t *testing.T) {
root, curatedCount := makeMonorepoFixture(t) root, curatedCount := makeMonorepoFixture(t)
// 對照組:沒有策略時(也就是修好之前的行為)會送多少 // 對照組:這棵樹裡本來就有幾個文件檔(=完全不排除時會送出去的量)
baseM := &Manifest{Entries: map[string]*ManifestEntry{}} onDisk := countDocFiles(t, root)
baseline, err := Scan(root, baseM, ScanOptions{SkipDirNames: map[string]bool{"system-dev": true}})
if err != nil {
t.Fatal(err)
}
payload, plan := scanWithPlan(t, root) payload, plan := scanWithPlan(t, root)
got := eventPaths(payload) got := eventPaths(payload)
t.Logf("策略:%s%s", plan.Mode, plan.Reason) t.Logf("策略:%s%s", plan.Mode, plan.Reason)
t.Logf("修好之前會送:%d 個檔|現在送:%d 個檔(策略擋掉 %d 個", t.Logf("樹上共有 %d 個文件檔|實際送出 %d 個(逐檔擋掉 %d,整棵跳過 %d 個資料夾",
len(baseline.Events), len(got), payload.ExcludedByPlan) onDisk, len(got), payload.ExcludedByPlan, payload.ExcludedDirCount)
for _, p := range got { for _, p := range got {
t.Logf(" → %s", p) t.Logf(" → %s", p)
} }
@@ -144,9 +160,9 @@ func TestPlanIngest_MonorepoSendsOnlyCuratedWiki(t *testing.T) {
} }
} }
// 量級檢查:這一票的實據是「差 115 倍」,修好之後不該只差一點點。 // 量級檢查:這一票的實據是「差 115 倍」,修好之後不該只差一點點。
if len(baseline.Events) < len(got)*5 { if onDisk < len(got)*5 {
t.Fatalf("對照組只有 %d 個檔,fixture 沒造出「被淹沒」的形狀,這個測試證明不了什麼", t.Fatalf("樹上只有 %d 個文件檔,fixture 沒造出「被淹沒」的形狀,這個測試證明不了什麼",
len(baseline.Events)) onDisk)
} }
} }
@@ -271,3 +287,186 @@ func TestPlanIngest_EmptyWikiFallsBackToDocs(t *testing.T) {
t.Fatalf("wiki 是空的,策略應退到 %s,卻是 %s", IngestDocsOnly, plan.Mode) t.Fatalf("wiki 是空的,策略應退到 %s,卻是 %s", IngestDocsOnly, plan.Mode)
} }
} }
// ═══════════════════════════════════════════════════════════════════════════
// arcrun-rag#104 第二輪(2026-08-16 leo 實撞:掛上 pms27 張卡裡 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/distout 的資料夾,
// 但它確實是使用者的內容——不准安靜地弄不見。
//
// 這一條是 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|送出 %d8%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("%snode_modules 沒被排除——它是誰的套件跟有沒有版控無關", tc.name)
}
// 🔴 本測試釘的是**排除規則那一層**:`build` 旁邊沒有任何專案檔,
// 所以無論有沒有版控,都不准把它當成「建置工具產生的」。
// (帶 `.git` 時 `build` 仍會因為**模式選擇**落在文件區之外而不收——
// 那是另一層,見本函式上方的已知落差說明。)
if strings.Contains(reasons["build"], "建置") {
t.Errorf("%s`build` 被判成建置產物(%q),但它旁邊沒有任何專案檔"+
"——版控訊號不得改變這個判斷", tc.name, reasons["build"])
}
}
}
+193
View File
@@ -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("讀回來對不上:%+verr=%v", back.FolderPlans, err)
}
}
+12
View File
@@ -182,6 +182,18 @@ func run(args []string, mode runMode) int {
if payload.ExcludedByPlan > 0 { if payload.ExcludedByPlan > 0 {
fmt.Fprintf(os.Stderr, "依這個策略跳過了 %d 個檔案。\n", payload.ExcludedByPlan) 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 { if len(plan.OtherWikiDirs) > 0 {
fmt.Fprintf(os.Stderr, fmt.Fprintf(os.Stderr,
"這個資料夾底下還有 %d 個子專案有自己的知識庫,我沒有收(要收請個別加進看守清單):%v\n", "這個資料夾底下還有 %d 個子專案有自己的知識庫,我沒有收(要收請個別加進看守清單):%v\n",
+43 -7
View File
@@ -133,8 +133,18 @@ type TriggerPayload struct {
// 使用者接上一個一萬檔的 repo 只看到 32 個進度,會以為系統壞了。 // 使用者接上一個一萬檔的 repo 只看到 32 個進度,會以為系統壞了。
Plan IngestPlan `json:"-"` Plan IngestPlan `json:"-"`
ExcludedByPlan int `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=同一份內容被偵測到有多種格式並存(同檔名主幹、不同副檔名)。 // FormatDuplicate=同一份內容被偵測到有多種格式並存(同檔名主幹、不同副檔名)。
// //
// 🔴 為什麼要有這個(2026-08-07,leo 實據):封測者 Evan 給的資料集 // 🔴 為什麼要有這個(2026-08-07,leo 實據):封測者 Evan 給的資料集
@@ -232,8 +242,14 @@ type ScanOptions struct {
// SkipDirNames:目錄名黑名單(任一層命中整棵跳過)。daemon-beta task 2 // SkipDirNames:目錄名黑名單(任一層命中整棵跳過)。daemon-beta task 2
// template 代裝後 `system-dev/`wiki 產物區)不得被當成原稿掃進 ingest。 // template 代裝後 `system-dev/`wiki 產物區)不得被當成原稿掃進 ingest。
SkipDirNames map[string]bool SkipDirNames map[string]bool
// Plan:收檔策略(arcrun-rag#104)。零值=IngestAll,行為與加這個欄位之前**完全一致** // Plan:收檔策略(arcrun-rag#104)。
// ——既有呼叫端與測試不必全部改。要拿到 #104 的效果就傳 PlanIngest(root)。 //
// 🔴 2026-08-16 改成「不填就自己算」(Mode == "" ⇒ Scan 自己呼叫 PlanIngest)。
// 原本的註解寫著「要拿到 #104 的效果就傳 PlanIngest(root)」——也就是**排除規則
// 要不要生效,取決於呼叫端記不記得傳**。那正是這一票(以及同一天 #88、#46、
// Arcrun#125)的共同形狀:**能力做好了,而它不在會被執行的那條路上**。
// 讓走訪器自己裝上判準,「忘了接」這個失敗模式就不存在了。
// 呼叫端仍可覆寫(測試要造特定情境時照傳即可)。
Plan IngestPlan Plan IngestPlan
} }
@@ -269,6 +285,11 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
if opts.MaxRemovedRatio <= 0 { if opts.MaxRemovedRatio <= 0 {
opts.MaxRemovedRatio = DefaultMaxRemovedRatio opts.MaxRemovedRatio = DefaultMaxRemovedRatio
} }
// 🔴 呼叫端沒給策略就自己算——見 ScanOptions.Plan 的說明。
// 這一行就是「排除規則不可能不在執行路徑上」的保證本身。
if opts.Plan.Mode == "" {
opts.Plan = PlanIngest(root)
}
orig := m.Entries orig := m.Entries
manifestCountBefore := len(orig) manifestCountBefore := len(orig)
@@ -280,6 +301,9 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
// arcrun-rag#104:被策略擋掉的檔案數。**一定要數出來**——票上的紅線是 // arcrun-rag#104:被策略擋掉的檔案數。**一定要數出來**——票上的紅線是
// 「用戶要知道有 8,000 個檔沒被收,因為它們是程式碼」,不是安靜地少收。 // 「用戶要知道有 8,000 個檔沒被收,因為它們是程式碼」,不是安靜地少收。
excludedByPlan := 0 excludedByPlan := 0
// 整棵剪掉的目錄與理由。**剪掉的重點就是不走進去**,所以這裡記的是目錄不是檔案數
// ——使用者要知道的本來就是「哪幾個資料夾沒收、為什麼」。見 ExcludedDir 的說明。
var excludedDirs []ExcludedDir
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error { err := filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error {
if werr != nil { if werr != nil {
return werr return werr
@@ -297,16 +321,20 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
return filepath.SkipDir // 隱藏目錄(.git、.obsidian…)整棵跳過 return filepath.SkipDir // 隱藏目錄(.git、.obsidian…)整棵跳過
} }
if p != root && opts.SkipDirNames[name] { if p != root && opts.SkipDirNames[name] {
return filepath.SkipDir // 名單目錄(system-dev…)整棵跳過 return filepath.SkipDir // 名單目錄(呼叫端自訂)整棵跳過
} }
// #104:依策略整棵跳過(依賴/建置產物/範本worktree/巢狀 repo // #104:依策略整棵跳過(使用者的 .gitignore依賴/建置產物/範本/
// 以及非本次策略要收的區域)。整棵跳掉的檔不逐一計數—— // worktree/巢狀 repo以及非本次策略要收的區域)。
// 那個數字對使用者沒有意義,Plan.Reason 那句話才是他要的解釋。 // 🔴 每一次剪枝都要留下「哪一個、為什麼」——票上的紅線是排除規則要看得見,
// 而 2026-08-16 實測發現原本整棵剪掉的部分完全沒有被記錄。
if p != root { if p != root {
if rel, ok := relOf(); ok && opts.Plan.SkipsDir(rel, p) { 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 filepath.SkipDir
} }
} }
}
return nil return nil
} }
if strings.HasPrefix(name, ".") { if strings.HasPrefix(name, ".") {
@@ -543,6 +571,12 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
events = []Event{} events = []Event{}
} }
sort.Slice(skipped, func(i, j int) bool { return skipped[i].Path < skipped[j].Path }) 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{ return &TriggerPayload{
SchemaVersion: 1, SchemaVersion: 1,
FolderID: m.FolderID, FolderID: m.FolderID,
@@ -556,5 +590,7 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
DuplicateFormats: duplicateFormats, DuplicateFormats: duplicateFormats,
Plan: opts.Plan, Plan: opts.Plan,
ExcludedByPlan: excludedByPlan, ExcludedByPlan: excludedByPlan,
ExcludedDirs: excludedDirs,
ExcludedDirCount: excludedDirCount,
}, nil }, nil
} }
+27
View File
@@ -100,6 +100,10 @@ type SyncStatus struct {
// 沒人判斷得出那到底是什麼檔。 // 沒人判斷得出那到底是什麼檔。
SkippedOtherNames []string `json:"skipped_other_names,omitempty"` SkippedOtherNames []string `json:"skipped_other_names,omitempty"`
// FolderPlans=每個看守資料夾這一輪的收檔策略與少收了什麼(key=資料夾路徑)。
// 與 SkippedDocs 同族:每輪照現況重算的快照,**不進 CarryForwardActivity**。
FolderPlans map[string]FolderPlanStatus `json:"folder_plans,omitempty"`
// ── t210 統計層(2026-08-08Evan 封測:「9000 個檔,雲端只有 101 張卡, // ── t210 統計層(2026-08-08Evan 封測:「9000 個檔,雲端只有 101 張卡,
// 畫面卻說 20 份沒送——這幾個數字到底是怎麼回事?」)────────────────────── // 畫面卻說 20 份沒送——這幾個數字到底是怎麼回事?」)──────────────────────
// //
@@ -117,6 +121,29 @@ type SyncStatus struct {
FailureBreakdown FailureBreakdown `json:"failure_breakdown"` FailureBreakdown FailureBreakdown `json:"failure_breakdown"`
} }
// FolderPlanStatus=某個看守資料夾這一輪用了什麼收檔策略、據此少收了什麼
// arcrun-rag#1042026-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"` // allcurated-wikidocs-only
Reason string `json:"reason"` // 一句話講給使用者聽的「為什麼只收這些」
// ExcludedFiles=走進去了但逐檔被策略擋下的數量。
ExcludedFiles int `json:"excluded_files"`
// ExcludedDirsExcludedDirCount=整棵被剪掉的目錄與理由(清單有上限,總數看 Count)。
ExcludedDirs []ExcludedDir `json:"excluded_dirs,omitempty"`
ExcludedDirCount int `json:"excluded_dir_count"`
// OtherWikiDirs=底下其他子專案自己的知識庫,刻意不收但一定要講。
OtherWikiDirs []string `json:"other_wiki_dirs,omitempty"`
}
// MaxSkippedListedstatus.json 裡最多逐檔列幾個。 // MaxSkippedListedstatus.json 裡最多逐檔列幾個。
// 超過的只反映在 SkippedDocCount,UI 說「…等 N 個」——避免整批舊 Office 檔 // 超過的只反映在 SkippedDocCount,UI 說「…等 N 個」——避免整批舊 Office 檔
// 把狀態檔撐大,也避免畫面變成一面看不完的檔名牆。 // 把狀態檔撐大,也避免畫面變成一面看不完的檔名牆。