97309b5580
票上寫的真兇是錯的。`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>
194 lines
8.2 KiB
Go
194 lines
8.2 KiB
Go
// 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)
|
||
}
|
||
}
|