Files
arcrun-collector/ingestplan_test.go
T

867 lines
38 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ingestplan_test.go — arcrun-rag#104 的驗收網:**接上一個開發用的 repo 時,
// 送進知識庫的應該是那幾十張整理好的卡,不是幾千個原始檔。**
//
// fixture 刻意照票上那份實據的形狀造:45 個名為 wiki 的目錄裡,8 份 `.claude/worktrees`、
// 5 份出貨用 worktree、8 份 `templatefs` 範本、還有 `.next``.vercel` 建置產物。
package collector
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
// eventPaths 取出本輪要送上去的檔案路徑(排序,好當實測輸出貼進 issue)。
func eventPaths(p *TriggerPayload) []string {
var out []string
for _, e := range p.Events {
out = append(out, e.Path)
}
sort.Strings(out)
return out
}
// scanWithPlan 照 daemon 的真實接法跑一輪掃描。
//
// 🔴 2026-08-16 簡化:這裡以前要自己複製 direct.go 那段「curated-wiki 時解除
// system-dev 的 SkipDirNames」的邏輯——**測試複製了受測程式的一半,於是它驗的是
// 我抄得對不對,不是產品對不對**。那段判準已收進 IngestPlan,兩邊都不必再抄。
func scanWithPlan(t *testing.T, root string) (*TriggerPayload, IngestPlan) {
t.Helper()
m := &Manifest{Entries: map[string]*ManifestEntry{}}
payload, err := Scan(root, m, ScanOptions{}) // Plan 不填=Scan 自己算,同 daemon
if err != nil {
t.Fatalf("掃描失敗:%v", err)
}
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
}
// codeProjectFiles 把「這裡真的是一套軟體專案」的內容鋪進 fixture:
// 一個專案檔 夠多的原始碼檔。
//
// 🔴 2026-08-16 第二層修法之後,**這件事必須用內容表達,不能再用 `mustMkdir(.git)` 表達**
// ——判準已經改成看資料夾裡實際裝了什麼(foldershape.go)。
// 以前的 fixture 造一個空的 `.git` 目錄就算「開發專案」,那正是 leo 推翻的那個訊號:
// 「我的 KB 筆記庫也有 git,是否用 github/gitea 追蹤完全沒意義。」
func codeProjectFiles(prefix, ext, body string) map[string]string {
out := map[string]string{prefix + "go.mod": "module example\n\ngo 1.22\n"}
for i := 0; i < softwareProjectMinCodeFiles+5; i++ {
out[fmt.Sprintf("%ssrc/mod%02d%s", prefix, i, ext)] = body
}
return out
}
// makeMonorepoFixture 造一個「leo 的 InkStoneCo」形狀的 repo。
// 回傳「真正整理好的那份 wiki」有幾個檔——那就是唯一該被送上去的量。
func makeMonorepoFixture(t *testing.T) (root string, curatedCount int) {
t.Helper()
root = t.TempDir()
files := map[string]string{}
// ① 真正整理好的知識庫(唯一該收的)
curated := []string{"status.md", "principles.md", "mistakes.md", "decisions-summary.md", "INDEX.md"}
for _, n := range curated {
files["system-dev/wiki/"+n] = "# 整理好的知識:" + n
}
files["system-dev/wiki/cards/autonomy/自動派工心法.md"] = "# 我自己寫的卡"
curatedCount = len(curated) + 1
// ② 程式碼與一般專案檔(leo:「只有文件要讀,程式碼不用讀」)
// ——這一段同時也是「這個資料夾是軟體專案」的**唯一**證據來源(見 codeProjectFiles)。
for i := 0; i < 40; i++ {
files[fmt.Sprintf("collector/file%02d.go", i)] = "package collector"
files[fmt.Sprintf("collector/note%02d.md", i)] = "# 散落在程式碼旁邊的說明"
}
files["go.mod"] = "module inkstone\n\ngo 1.22\n"
files["README.md"] = "# 專案說明"
files["CHANGELOG.md"] = "# 版本紀錄"
// ③ templatefs 範本:我們自己要鋪給別人的檔,不是誰的知識(實據:8 份)
for i := 0; i < 8; i++ {
files[fmt.Sprintf("collector/templatefs/system-dev/wiki/status%d.md", i)] = "# 範本"
}
// ④ 建置產物(實據:.next.vercel
files["landing/.next/server/pages/doc.md"] = "# 建置產物"
files["landing/.vercel/output/static/doc.md"] = "# 建置產物"
files["landing/node_modules/some-pkg/README.md"] = "# 別人的套件"
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)
}
}
// 監看根同時也在版控裡——**故意留著**:它不准影響任何判斷(見本檔最後那條迴歸測試)。
mustMkdir(t, filepath.Join(root, ".git"))
// ⑤ 出貨用 worktree`.git` 是**檔案**)——主 repo 的第二份簽出,內容重複(實據:5 份)
for i := 0; i < 5; i++ {
wt := filepath.Join(root, fmt.Sprintf("products/arcrun-rag-wt%dship", i))
mustMkdir(t, filepath.Join(wt, "system-dev", "wiki"))
if err := os.WriteFile(filepath.Join(wt, ".git"), []byte("gitdir: /elsewhere\n"), 0o644); err != nil {
t.Fatal(err)
}
for _, n := range curated {
if err := os.WriteFile(filepath.Join(wt, "system-dev", "wiki", n), []byte("# 同一份 wiki 的第 N 份副本"), 0o644); err != nil {
t.Fatal(err)
}
}
}
// ⑥ 巢狀的獨立子專案(自己有 .git 目錄)——那是別的專案的知識
sub := filepath.Join(root, "products", "arcrun-rag")
mustMkdir(t, filepath.Join(sub, ".git"))
mustMkdir(t, filepath.Join(sub, "system-dev", "wiki"))
if err := os.WriteFile(filepath.Join(sub, "system-dev", "wiki", "status.md"), []byte("# 子專案自己的知識"), 0o644); err != nil {
t.Fatal(err)
}
return root, curatedCount
}
// 🔴 #104 的驗收條件本身:接上一個開發 repo,送進去的是幾十張,不是幾千個。
func TestPlanIngest_MonorepoSendsOnlyCuratedWiki(t *testing.T) {
root, curatedCount := makeMonorepoFixture(t)
// 對照組:這棵樹裡本來就有幾個文件檔(=完全不排除時會送出去的量)。
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,整棵跳過 %d 個資料夾)",
onDisk, len(got), payload.ExcludedByPlan, payload.ExcludedDirCount)
for _, p := range got {
t.Logf(" → %s", p)
}
if plan.Mode != IngestCuratedWiki {
t.Fatalf("策略=%swant %s(這個 repo 有現成的 system-dev/wiki", plan.Mode, IngestCuratedWiki)
}
if len(got) != curatedCount {
t.Fatalf("送了 %d 個檔,want %d(只有整理好的那份 wiki):%v", len(got), curatedCount, got)
}
for _, p := range got {
if !strings.HasPrefix(p, "system-dev/wiki/") {
t.Fatalf("送了不該送的檔:%s", p)
}
}
// 量級檢查:這一票的實據是「差 115 倍」,修好之後不該只差一點點。
if onDisk < len(got)*5 {
t.Fatalf("樹上只有 %d 個文件檔,fixture 沒造出「被淹沒」的形狀,這個測試證明不了什麼",
onDisk)
}
}
// 同一份 wiki 被出貨用 worktreetemplatefs 收十幾次——那是「48 萬筆廢資料」的同款成因。
func TestPlanIngest_NoDuplicateWikiCopies(t *testing.T) {
root, _ := makeMonorepoFixture(t)
payload, _ := scanWithPlan(t, root)
seen := map[string]int{}
for _, p := range eventPaths(payload) {
seen[filepath.Base(p)]++
}
for base, n := range seen {
if n > 1 {
t.Fatalf("%s 被收了 %d 次——同一份內容重複放大,AR-Mira 會搜出一堆一模一樣的東西", base, n)
}
}
for _, p := range eventPaths(payload) {
for _, bad := range []string{"templatefs/", "-wt0ship/", ".next/", ".vercel/", "node_modules/"} {
if strings.Contains(p, bad) {
t.Fatalf("收到了不該收的 %s(命中 %q", p, bad)
}
}
}
}
// 排除規則要看得見:使用者要知道「有幾千個檔沒被收、為什麼」,以及「其他子專案的 wiki 在哪」。
func TestPlanIngest_ExclusionsAreVisible(t *testing.T) {
root, _ := makeMonorepoFixture(t)
payload, plan := scanWithPlan(t, root)
if plan.Reason == "" {
t.Fatal("沒有給使用者一句話解釋——他只會覺得系統壞了(票上的紅線)")
}
if !strings.Contains(plan.Reason, plan.WikiRelDir) {
t.Fatalf("理由沒說出是讀了哪一份 wiki%q", plan.Reason)
}
if payload.ExcludedByPlan <= 0 {
t.Fatal("擋掉的檔案數是 0——那個數字就是「有 N 個檔沒被收」要顯示的東西")
}
// 子專案的 wiki 刻意不收,但一定要列出來讓使用者知道去哪裡找。
if len(plan.OtherWikiDirs) == 0 {
t.Fatal("沒列出子專案自己的 wiki——使用者接了 monorepo 只看到幾十張,會以為東西不見了")
}
t.Logf("理由:%s", plan.Reason)
t.Logf("擋掉 %d 個檔|其他子專案的 wiki%v", payload.ExcludedByPlan, plan.OtherWikiDirs)
}
// 沒有現成 wiki 的 repo:退到「只讀文件、不讀程式碼」(leo 明講的第三步)。
//
// 🔴 2026-08-27 Phase 0`arcrun-rag#136`leo 08-24 第三次追加)之後行為分兩種:
// - `src/說明.md``src/` 這個目錄自己直接放的檔案裡就有 `.go`(程式碼)——
// 零程式碼這一條不成立,繼續當程式碼目錄整棵跳過,不收。
// - `internal/notes.md``internal/` 這個目錄自己直接放的檔案裡**只有這份 .md**、
// 沒有任何程式碼副檔名——即使名字不叫 docs,Phase 0 判準也會把它當文件目錄收。
// 這不是誤放的迴歸,是這次要的效果:leo 08-24「如果要加上很多(手動救回),
// 那就更覺得很煩,就會棄用」——非標準命名但整棵零程式碼的資料夾預設就該收,
// 不必等使用者自己救。
func TestPlanIngest_RepoWithoutWikiReadsDocsOnly(t *testing.T) {
root := t.TempDir()
files := codeProjectFiles("", ".go", "package main")
for rel, body := range map[string]string{
"README.md": "# 專案",
"docs/請假規則.md": "# 特休 14 天",
"docs/報銷政策.md": "# 每日 3000 元",
"src/說明.md": "# 散在程式碼旁邊——但 src/ 自己直接放的檔案裡就有 .go,不合格",
"internal/notes.md": "# 自己單獨一個資料夾,直接內容零程式碼——Phase 0 判準下合格",
} {
files[rel] = body
}
writeFixture(t, root, files)
payload, plan := scanWithPlan(t, root)
got := eventPaths(payload)
t.Logf("策略:%s%s)|送:%v", plan.Mode, plan.Reason, got)
if plan.Mode != IngestDocsOnly {
t.Fatalf("策略=%swant %s", plan.Mode, IngestDocsOnly)
}
want := []string{"README.md", "docs/報銷政策.md", "docs/請假規則.md", "internal/notes.md"}
sort.Strings(want)
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("送了 %vwant %v`src/` 因為自己直接放著 .go 仍不收;`internal/` "+
"自己直接內容零程式碼,Phase 0 判準下該收)", got, want)
}
}
// 一般資料夾/筆記庫:行為與修這一票之前**完全一致**,全部照收。
// 這條是防止「修好 repo 卻把一般使用者的資料夾弄壞」——他們才是產品的主要客群。
func TestPlanIngest_PlainFolderUnchanged(t *testing.T) {
root := t.TempDir()
writeFixture(t, root, map[string]string{
"會議記錄.md": "# 會議",
"專案/請假規則.md": "# 特休",
"專案/深/一點/報銷.md": "# 報銷",
"src/main.go": "package main", // 非文件,本來就被副檔名白名單擋
"隨手/notes/2026.md": "# 隨手記",
})
plan := PlanIngest(root)
if plan.Mode != IngestAll {
t.Fatalf("一般資料夾的策略=%swant %s——不准因為修 repo 而改變一般使用者的行為",
plan.Mode, IngestAll)
}
payload, _ := scanWithPlan(t, root)
got := eventPaths(payload)
want := []string{"會議記錄.md", "專案/深/一點/報銷.md", "專案/請假規則.md", "隨手/notes/2026.md"}
sort.Strings(want)
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("一般資料夾送了 %vwant %v", got, want)
}
if payload.ExcludedByPlan != 0 {
t.Fatalf("一般資料夾不該有任何檔被策略擋掉,卻擋了 %d 個", payload.ExcludedByPlan)
}
}
// 空的 wiki 目錄不算「已經整理好」——不然剛裝完 template 的 repo 會一個檔都不收,
// 那比收太多更糟(使用者會以為系統壞了)。
func TestPlanIngest_EmptyWikiFallsBackToDocs(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "system-dev", "wiki"))
files := codeProjectFiles("", ".go", "package main")
files["docs/說明.md"] = "# 說明"
writeFixture(t, root, files)
plan := PlanIngest(root)
if plan.Mode != IngestDocsOnly {
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"
// 真的有一整套程式碼——**這才是「這是軟體專案」的證據**,不是 `.git`
// (真樹 2,127 檔裡絕大多數是 `.ts``.js`)。
for i := 0; i < softwareProjectMinCodeFiles+5; i++ {
files[fmt.Sprintf("workers/pms-order-search/src/handler%02d.ts", i)] = "export const x = 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)
}
}
// 🔴 本票最重要的一條:**版控訊號不得改變任何收檔行為**(leo 2026-08-16 推翻 .git 判準)。
//
// 「**你不需要判斷有沒有 git,我的 KB 筆記庫也有 git,
// 是否用 github/gitea 追蹤完全沒意義。**」
//
// 這條測試原本只釘住**排除規則**那一層,函式上方還記著一條已知落差:
// 「`PlanIngest` 的模式選擇仍看 `.git`,要另外開票」。
// 2026-08-16 第二層修法把那個落差補掉了 ⇒ **本測試同時擴充到模式選擇**,
// 而不是另開一條平行的(兩條分開釘,下一個人只會改到其中一條)。
//
// 判準也從「幾個個別行為一樣」升成「**收到的檔案集合一模一樣**」——
// 那才是使用者手上真正會少掉東西的那一維。
func TestPlanIngest_排除判準與模式選擇都不看有沒有版控(t *testing.T) {
// 🔴 地雷本體:一個**有 wiki 目錄、也有大量其他內容**的筆記庫。
// 這正是 `~/Documents/KB` 的形狀(實測:5,915 份文件,而它真的有 `system-dev/wiki`)。
// 舊判準下:沒有 `.git` ⇒ all(全收);跑一次 `git init` ⇒ curated-wiki ⇒ 只剩 wiki 那幾張。
// **一個純粹的版控動作,會讓使用者手上的知識少掉九成,而畫面上不會有任何提示。**
base := map[string]string{
"docs/說明.md": "# 文件",
"build/樂高作品集.md": "# 使用者的東西",
"node_modules/x/a.md": "# 別人的套件",
"wiki/status.md": "# 我自己開的 wiki 資料夾",
"wiki/INDEX.md": "# 索引",
"system-dev/wiki/status.md": "# 我在筆記庫裡也順手裝過 template(KB 真的是這樣)",
}
// 「大量其他內容」——真正會被吃掉的那一批。
for i := 0; i < 30; i++ {
base[fmt.Sprintf("journals/2026_08_%02d.md", i)] = "# 日記"
}
withoutGit := t.TempDir()
writeFixture(t, withoutGit, base)
withGit := t.TempDir()
writeFixture(t, withGit, base)
mustMkdir(t, filepath.Join(withGit, ".git"))
var firstSet string
for _, tc := range []struct{ name, root string }{
{"沒有版控", withoutGit}, {"有版控(跑過 git init", withGit},
} {
payload, plan := scanWithPlan(t, tc.root)
reasons := map[string]string{}
for _, d := range payload.ExcludedDirs {
reasons[d.Path] = d.Reason
}
got := eventPaths(payload)
t.Logf("%s:策略=%s|送出 %d 個檔|跳過=%v", tc.name, plan.Mode, len(got), reasons)
// ① 排除規則那一層(原本就釘住的)
if reasons["node_modules"] == "" {
t.Errorf("%snode_modules 沒被排除——它是誰的套件跟有沒有版控無關", tc.name)
}
if strings.Contains(reasons["build"], "建置") {
t.Errorf("%s`build` 被判成建置產物(%q),但它旁邊沒有任何專案檔"+
"——版控訊號不得改變這個判斷", tc.name, reasons["build"])
}
// ② 模式選擇那一層(本輪補上的)
if plan.Mode != IngestAll {
t.Errorf("%s:策略=%s,want %s——這是筆記庫(沒有專案檔、沒有成堆原始碼),"+
"有沒有跑過 git init 都不該改變它", tc.name, plan.Mode, IngestAll)
}
// ③ 🔴 真正的判準:**收到的檔案集合必須一模一樣**
set := strings.Join(got, "\n")
if firstSet == "" {
firstSet = set
// 3430 篇日記+docs/說明+build/樂高+wiki 的 2 張。
// `system-dev/` 是 template 鋪出來的產物區,任何模式都不收——那條規則
// 比本票更早存在,也正是舊判準的殺傷力所在:一旦翻成 curated-wiki
// **唯一被收的就只剩那個平常根本不收的目錄**。)
if len(got) != 34 {
t.Fatalf("%s:只送出 %d 個檔(want 34):%v", tc.name, len(got), got)
}
} else if set != firstSet {
t.Fatalf("🔴 跑一次 `git init` 就改變了收到的東西——這正是本票要拆掉的引信。\n"+
"沒有版控時:\n%s\n有版控時:\n%s", firstSet, set)
}
}
}
// 三種收法換了判準之後仍然各自正確——同一組內容,只差「裡面有沒有一整套程式碼」。
//
// 🔴 這條是防止「修好筆記庫卻把專案那兩種弄壞」:判準只有一個,三種答案必須都還在。
func TestPlanIngest_三種收法在新判準下都還正確(t *testing.T) {
// 共用的內容:一份整理好的 wiki、一個文件區、一些散落在旁邊的 .md。
shared := map[string]string{
"wiki/status.md": "# 整理好的知識",
"wiki/INDEX.md": "# 索引",
"docs/請假規則.md": "# 特休 14 天",
"README.md": "# 說明",
"雜/隨手記.md": "# 散落在旁邊",
}
merge := func(extra map[string]string) map[string]string {
out := map[string]string{}
for k, v := range shared {
out[k] = v
}
for k, v := range extra {
out[k] = v
}
return out
}
for _, tc := range []struct {
name string
files map[string]string
wantMode IngestMode
wantGot []string
}{
{
// ① 筆記庫:沒有專案檔、沒有成堆原始碼 ⇒ 整份讀進去
name: "筆記庫整份收",
files: merge(nil),
wantMode: IngestAll,
wantGot: []string{"README.md", "docs/請假規則.md",
"wiki/INDEX.md", "wiki/status.md", "雜/隨手記.md"},
},
{
// ② 軟體專案+已經整理好 wiki ⇒ 只收那份 wiki
name: "整理好的專案只收那份wiki",
files: merge(codeProjectFiles("", ".go", "package main")),
wantMode: IngestCuratedWiki,
wantGot: []string{"wiki/INDEX.md", "wiki/status.md"},
},
{
// ③ 軟體專案、沒有現成 wiki ⇒ 只收文件區+根層說明檔+跳過原始碼,
// 但 Phase 0`arcrun-rag#136`)之後也收「非標準命名、自己直接內容零程式碼」
// 的資料夾——`雜/` 自己只放了一份 `隨手記.md`,沒有任何程式碼副檔名,
// 即使名字不叫 docs 也算文件目錄。這不是誤放的迴歸,見同檔
// TestPlanIngest_RepoWithoutWikiReadsDocsOnly 上方的完整說明。
name: "沒wiki的專案只收文件跳過源碼",
files: func() map[string]string {
f := merge(codeProjectFiles("", ".go", "package main"))
delete(f, "wiki/status.md")
delete(f, "wiki/INDEX.md")
return f
}(),
wantMode: IngestDocsOnly,
wantGot: []string{"README.md", "docs/請假規則.md", "雜/隨手記.md"},
},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
writeFixture(t, root, tc.files)
payload, plan := scanWithPlan(t, root)
got := eventPaths(payload)
t.Logf("策略=%s%s", plan.Mode, plan.Reason)
t.Logf("送出 %d 個:%v", len(got), got)
if plan.Mode != tc.wantMode {
t.Fatalf("策略=%swant %s", plan.Mode, tc.wantMode)
}
sort.Strings(tc.wantGot)
if strings.Join(got, ",") != strings.Join(tc.wantGot, ",") {
t.Fatalf("送出 %vwant %v", got, tc.wantGot)
}
})
}
}
// 🔴 不誤殺(票上的紅線):筆記庫裡名字像建置產物的資料夾,換了判準之後照樣要收。
//
// 上一輪已經有 TestPlanIngest_筆記庫裡真的叫build的資料夾不准誤殺 守著排除那一層;
// 這一條守的是**模式選擇**那一層——判準若寫成「看到 build/dist/out 就算專案」,
// 或看到零星幾個腳本就算專案,那些筆記一樣會整批消失,而且是換一個入口消失。
func TestPlanIngest_筆記庫有像產物的資料夾與零星腳本也不准翻成專案(t *testing.T) {
root := t.TempDir()
files := map[string]string{
"日記.md": "# 日記",
"build/樂高作品集.md": "# 我在做的模型",
"dist/出貨清單.md": "# 交件",
"out/外出旅遊筆記.md": "# 旅遊",
"vendor/廠商聯絡簿.md": "# 廠商",
"target/年度目標.md": "# 目標",
"coverage/保單整理.md": "# 保單",
}
// 筆記庫裡存幾個順手抄下來的腳本片段——**不足以讓整個資料夾變成軟體專案**。
for i := 0; i < softwareProjectMinCodeFiles-5; i++ {
files[fmt.Sprintf("片段/snippet%02d.py", i)] = "print('hi')"
}
writeFixture(t, root, files)
payload, plan := scanWithPlan(t, root)
got := eventPaths(payload)
t.Logf("策略=%s%s", plan.Mode, plan.Reason)
t.Logf("送出 %d7%v", len(got), got)
if plan.Mode != IngestAll {
t.Fatalf("策略=%swant %s——這是筆記庫,`build` 是樂高作品集、`out` 是外出旅遊,"+
"幾個腳本片段不能讓整個資料夾翻成軟體專案", plan.Mode, IngestAll)
}
if len(got) != 7 {
t.Fatalf("使用者的 7 份筆記只送了 %d 份:%v", len(got), got)
}
if payload.ExcludedDirCount != 0 {
t.Fatalf("筆記庫不該有任何資料夾被剪掉,卻剪了:%v", payload.ExcludedDirs)
}
}
// 判準的證據要看得見(票上的紅線:使用者要看得出「我對你的資料夾做了什麼」)。
//
// 「我判斷這是軟體專案」不附證據=一個黑盒;附上「看到 go.mod,還有 25 個原始碼檔」,
// 他自己就看得出對不對。而且**這句話裡不准再出現版控字眼**——
// 那正是 leo 說「完全沒意義」的那個訊號。
func TestPlanIngest_判成專案時要講得出憑什麼(t *testing.T) {
root := t.TempDir()
files := codeProjectFiles("", ".go", "package main")
files["docs/說明.md"] = "# 文件"
writeFixture(t, root, files)
plan := PlanIngest(root)
t.Logf("策略=%s", plan.Mode)
t.Logf("理由:%s", plan.Reason)
t.Logf("實測形狀:專案檔 %d 個(%v)/原始碼 %d 個/文件 %d 個",
plan.Shape.ManifestCount, plan.Shape.ManifestRels, plan.Shape.CodeFiles, plan.Shape.DocFiles)
if !strings.Contains(plan.Reason, "go.mod") {
t.Errorf("理由沒講出憑什麼判成專案:%q", plan.Reason)
}
if plan.Shape.CodeFiles < softwareProjectMinCodeFiles {
t.Errorf("形狀沒數到原始碼:%+v", plan.Shape)
}
for _, bad := range []string{"版控", "git", "Git", "GitHub", "gitea"} {
if strings.Contains(plan.Reason, bad) {
t.Errorf("理由裡出現了版控字眼 %q——判準已經不看版控了:%q", bad, plan.Reason)
}
}
}
// 🔴 Phase 0`arcrun-rag#136`leo 2026-08-24 第三次追加)——正面驗收:
// 票上真正的案例(`pms_v1_legacy/pms-backup`):非標準命名、整棵沒有一套程式碼、
// 但巢狀更深處混了一份資料庫備份檔——這正是研究文件原本「整棵子樹零程式碼」的
// 設計會漏掉的那個真實形狀(見 `dirQualifiesAsAutoDoc` 的說明)。
//
// fixture 照 leo 08-24 給的 Finder 截圖形狀造:
//
// pms_v1_legacy/
// PMS_USER_STORIES.md ← 自己直接放的文件(零程式碼);一旦這裡合格,
// 整個 pms_v1_legacy 都被收(onPathTo 前綴涵蓋子孫)
// pms-backup/
// PMS_ASSESSMENT.md ← 自己直接放的文件
// pms_db_backup.sql ← 巢狀更深處的「程式碼副檔名」(codeFileExts 收 .sql
//
// 🔴 `pms-backup` 底下混了 .sql 這件事,**不影響** `pms_v1_legacy` 本身的合格判斷
// (逐層判準只看每一層自己的直接內容),所以 `pms-backup/PMS_ASSESSMENT.md`
// 也跟著被收——它是「已合格的 pms_v1_legacy」底下的子孫,不必再單獨判一次
// (與 `docs/` 目錄底下不管有沒有子資料夾都整棵收,是同一條既有語意)。
func TestPlanIngest_Phase0非標準命名文件目錄零程式碼即收(t *testing.T) {
root := t.TempDir()
files := codeProjectFiles("", ".go", "package main")
files["README.md"] = "# 專案"
files["pms_v1_legacy/PMS_USER_STORIES.md"] = "# 使用者故事"
files["pms_v1_legacy/pms-backup/PMS_ASSESSMENT.md"] = "# 評估報告"
files["pms_v1_legacy/pms-backup/pms_db_backup.sql"] = "-- 假資料,測試只在意副檔名不在意內容\n"
writeFixture(t, root, files)
payload, plan := scanWithPlan(t, root)
got := eventPaths(payload)
t.Logf("策略=%s%s", plan.Mode, plan.Reason)
t.Logf("送出:%v", got)
for _, d := range payload.ExcludedDirs {
t.Logf("跳過 %s — %s", d.Path, d.Reason)
}
if plan.Mode != IngestDocsOnly {
t.Fatalf("策略=%swant %s", plan.Mode, IngestDocsOnly)
}
want := []string{
"README.md",
"pms_v1_legacy/PMS_USER_STORIES.md", // 自己直接放的檔案零程式碼,合格
"pms_v1_legacy/pms-backup/PMS_ASSESSMENT.md", // 已合格祖先底下的子孫,一併收
}
sort.Strings(want)
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("送出 %vwant %v", got, want)
}
}
// 🔴 Phase 0——正面驗收(獨立合格):`pms-backup` 若是「自己單獨掛上去的看守根」
// (不在任何已合格祖先底下),逐層判準要能單獨判它自己——它自己直接混了 .sql,
// 不合格,但巢狀更深處若有一個自己零程式碼的子目錄,仍要被獨立找到。
// 用 `archive/`(非標準命名、非隱藏)取代真實案例裡的 `.wiki`——後者是
// daemon 自己產生的隱藏快取目錄,本來就會被 Scan 的隱藏目錄規則整個擋下,
// 不是這裡要驗的「內容判準」這件事。
func TestPlanIngest_Phase0巢狀更深處的獨立合格目錄也找得到(t *testing.T) {
root := t.TempDir()
files := codeProjectFiles("", ".go", "package main")
files["pms_v1_legacy/pms-backup/PMS_ASSESSMENT.md"] = "# 評估報告"
files["pms_v1_legacy/pms-backup/pms_db_backup.sql"] = "-- 假資料,測試只在意副檔名不在意內容\n"
files["pms_v1_legacy/pms-backup/archive/OLD_NOTES.md"] = "# 更早的筆記"
writeFixture(t, root, files)
payload, _ := scanWithPlan(t, root)
got := eventPaths(payload)
t.Logf("送出:%v", got)
want := "pms_v1_legacy/pms-backup/archive/OLD_NOTES.md"
found := false
for _, p := range got {
if p == want {
found = true
}
if strings.HasPrefix(p, "pms_v1_legacy/pms-backup/") && p != want {
t.Fatalf("`pms-backup` 自己混了 .sql 不該被收,卻收了 %s", p)
}
}
if !found {
t.Fatalf("巢狀更深處的獨立合格目錄沒被找到:%v", got)
}
}
// 🔴 Phase 0——反面驗收①:`pms-backup` 自己直接放著 `.sql`(程式碼副檔名),
// 所以它自己不合格,同層的 `PMS_ASSESSMENT.md` 目前**仍然不會被收**——
// 這是逐層判準(而非整棵子樹判準)刻意接受的邊界:一個目錄自己混了程式碼副檔名,
// 就當它自己是「開發用的」,不因為隔壁躺著一份文件就整個放行。使用者若真的要救
// 這一份,仍然有第 6 節的樹狀 UI/手動覆寫(尚未實作,見票上 Phase 2)這條路。
// 本測試把這個邊界寫清楚,不讓它在下一輪被誤改成「連 pms-backup 自己也收」。
func TestPlanIngest_Phase0巢狀混雜程式碼的目錄自己仍不收(t *testing.T) {
root := t.TempDir()
files := codeProjectFiles("", ".go", "package main")
files["pms_v1_legacy/pms-backup/PMS_ASSESSMENT.md"] = "# 評估報告"
files["pms_v1_legacy/pms-backup/pms_db_backup.sql"] = "-- 假資料,測試只在意副檔名不在意內容\n"
writeFixture(t, root, files)
payload, _ := scanWithPlan(t, root)
got := eventPaths(payload)
for _, p := range got {
if strings.Contains(p, "pms-backup/") {
t.Fatalf("`pms-backup` 自己直接放著 .sql(程式碼副檔名),不該被 Phase 0 判準收進去:%v", got)
}
}
}
// 🔴 Phase 0——反面驗收②:不准重新引入 `#104` 的套件洩漏洞。
// `node_modules` 底下即使巢狀著一個「整棵零程式碼」的文件目錄,也不能被收——
// `toolOwnedDirNames` 的優先序排在 Phase 0 判準之前,整棵 `SkipDir`
// 新判準根本沒有機會看到 `node_modules` 底下的任何內容。
func TestPlanIngest_Phase0不重新引入套件洩漏洞(t *testing.T) {
root := t.TempDir()
files := codeProjectFiles("", ".go", "package main")
files["node_modules/some-pkg/docs-backup/README.md"] = "# 別人的套件文件"
files["node_modules/some-pkg/docs-backup/GUIDE.md"] = "# 別人的套件文件"
writeFixture(t, root, files)
payload, _ := scanWithPlan(t, root)
got := eventPaths(payload)
for _, p := range got {
if strings.Contains(p, "node_modules/") {
t.Fatalf("Phase 0 判準洩漏了 node_modules 底下的內容(重新打開 #104 那個洞):%v", got)
}
}
}
// 🔴 Phase 0——反面驗收③:真正的程式碼目錄不受影響,仍走原本的 docs-only 通用跳過。
// `workers/pms-auth` 自己直接放著 `.go`(與 `package.json`),零程式碼那一條不成立,
// 整棵照舊被跳過——不因為新判準而多送出任何一份程式碼旁邊的檔案。
func TestPlanIngest_Phase0真正的程式碼目錄不受影響(t *testing.T) {
root := t.TempDir()
files := codeProjectFiles("", ".go", "package main")
files["workers/pms-auth/package.json"] = `{"name":"pms-auth"}`
files["workers/pms-auth/main.go"] = "package main"
files["workers/pms-auth/README.md"] = "# 這個服務怎麼跑"
writeFixture(t, root, files)
payload, plan := scanWithPlan(t, root)
got := eventPaths(payload)
t.Logf("策略=%s|送出:%v", plan.Mode, got)
for _, p := range got {
if strings.Contains(p, "workers/pms-auth/") {
t.Fatalf("`workers/pms-auth` 自己直接放著 .go,不該被 Phase 0 判準收進去:%v", got)
}
}
}
// 🔴 接線:**模式選擇的判準只有一個地方**。
//
// 這一票的同款形狀出現過四次(能力做好了,卻不在會被執行的那條路上)。
// 上一輪用 `go/ast` 釘住「不准再有第二張排除清單」;這一條釘住同一件事的另一半:
// 除了 `PlanIngest` 自己,**沒有別的地方可以用版控訊號決定收檔策略**。
func TestWiring_模式選擇不准再引用版控訊號(t *testing.T) {
src, err := os.ReadFile("ingestplan.go")
if err != nil {
t.Fatal(err)
}
// 註解裡當然會提到(那是在解釋為什麼不用),只看程式碼行。
for i, line := range strings.Split(string(src), "\n") {
code := line
if idx := strings.Index(code, "//"); idx >= 0 {
code = code[:idx]
}
for _, fn := range []string{"DetectRepoRoot(", "UnderVersionControl("} {
if strings.Contains(code, fn) {
t.Errorf("ingestplan.go:%d 又用版控訊號決定收檔策略了:%s\n"+
"leo 2026-08-16:「你不需要判斷有沒有 git,我的 KB 筆記庫也有 git」\n"+
"判準要建在資料夾裡實際裝了什麼(foldershape.go", i+1, strings.TrimSpace(line))
}
}
}
// 反面:#105 那個**正當**的用途不准被一起拆掉(tidy.go:版控中的檔案不自動改名搬移)。
tidy, err := os.ReadFile("tidy.go")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(tidy), "DetectRepoRoot(") {
t.Error("tidy.go 不再檢查版控——#105「不要改動使用者版控中的檔案」那一條是對的," +
"它問的真的是版控,不准跟著這一票一起拿掉")
}
}