fix(collector): 收檔策略改看資料夾裝了什麼,不看有沒有版控(arcrun-rag#104 第二層)

leo 2026-08-16:「你不需要判斷有沒有 git,我的 KB 筆記庫也有 git,
是否用 github/gitea 追蹤完全沒意義。」

第一層(80aed82)已把「哪些目錄算雜訊」改成不看版控;本輪處理
「這個資料夾整體該用哪種收法」(all/curated-wiki/docs-only)那一層。

定時炸彈(實測,非推測):`~/Documents/KB` 是 leo 的真知識庫,今天沒有
`.git` ⇒ all ⇒ 全收;而它**真的有 system-dev/wiki**(他在筆記庫裡也裝過
template)⇒ 只要有人跑一次 `git init`,就翻成 curated-wiki,靜默塌成十幾張。

新判準(collector/foldershape.go):三條同時成立才算軟體專案——
① 找得到專案檔 ② 原始碼 ≥ 20 ③ 原始碼 × 4 ≥ 文件數。三條都不看版控。
第③條擋的是「筆記庫裡放了一整份下載回來的範例專案」。

實測分得很開:KB=0/15/5915(筆記庫)|InkStoneCo=213/3355/6115(專案)
|pms=21/37/22(專案)。InkStoneCo 與 pms 的模式都不變。

判準換成內容,證據也換成內容:`IngestPlan.RepoRoot`(.git 在哪)改成
`IngestPlan.Shape`(實測形狀),Reason 句子改成「我看到 go.mod 等專案檔,
還有 3,355 個原始碼檔」——使用者看得出憑什麼那樣判。

- 既有那條迴歸測試就地擴充到涵蓋模式選擇(不另開平行的一條),
  判準升成「同一棵樹有無版控,收到的檔案集合必須一模一樣」
- 加上三種收法各自仍正確、筆記庫有像產物的資料夾與零星腳本不准翻成專案、
  理由裡不准再出現版控字眼、以及「ingestplan.go 不准再引用 DetectRepoRoot」
  的接線測試(反面同時釘住 #105 在 tidy.go 的正當用途不准被順手拆掉)
- collector/probe_real_manual_test.go:唯讀探針,留著給下一個人重量存量影響

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 23:26:52 +08:00
parent d1a929e764
commit 577d883e94
4 changed files with 685 additions and 45 deletions
+147
View File
@@ -0,0 +1,147 @@
package collector
// probe_real_manual_test.go — 手動探針(**唯讀**):拿真實資料夾對照
// 「舊判準(看 `.git`)」與「新判準(看內容)」,arcrun-rag#104 第二層。
//
// 🔴 為什麼留在 repo 裡而不是用完就丟:這一票的驗收條件是「**leo 真實的資料夾
// 換判準之後會收到什麼**」——那不是 fixture 答得出來的問題。下一個人要重新確認
// 存量影響(誰會多收、誰會少收),跑這兩支就有答案,不必再從頭想一次怎麼量。
// 預設 skip,不進 CI`Scan` 全程不寫檔(scan.go 裡沒有任何 os.Write/Remove/Rename)。
//
// PROBE_REAL=1 PROBE_SCAN=1 PROBE_ROOTS="/路徑A,/路徑B" go test -run TestProbeRealFolders -v ./
// PROBE_REAL=1 PROBE_MINE="$HOME/Documents/KB" go test -run TestProbeMine -v ./
import (
"os"
"path/filepath"
"strings"
"testing"
)
// oldMode 重現 2026-08-16 之前的模式選擇:用 DetectRepoRoot(找 .git)判是不是專案。
func oldMode(root string) IngestMode {
if DetectRepoRoot(root) == "" {
return IngestAll
}
if findCuratedWiki(root) != "" {
return IngestCuratedWiki
}
return IngestDocsOnly
}
// mirrorTree 把 src 的**結構**(每個目錄、每個檔名)複製到 dst,內容一律換成一個字。
// 用途:拿真實資料夾的形狀當地雷測試的素材,而**完全不碰真實資料夾**。
func mirrorTree(t *testing.T, src, dst string) int {
t.Helper()
n := 0
err := filepath.WalkDir(src, func(p string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
rel, rerr := filepath.Rel(src, p)
if rerr != nil {
return nil
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
if !d.Type().IsRegular() {
return nil
}
n++
return os.WriteFile(target, []byte("x\n"), 0o644)
})
if err != nil {
t.Fatal(err)
}
return n
}
// 🔴 地雷測試(真實形狀版):拿 `~/Documents/KB` 的**結構**造兩份拋棄式副本,
// 一份有 `.git`、一份沒有,比對兩邊收到的檔案集合。**全程不碰 KB 本身。**
//
// PROBE_REAL=1 PROBE_MINE="$HOME/Documents/KB" go test -run TestProbeMine -v ./ -timeout 30m
func TestProbeMine(t *testing.T) {
if os.Getenv("PROBE_REAL") != "1" || os.Getenv("PROBE_MINE") == "" {
t.Skip("手動探針")
}
src := os.Getenv("PROBE_MINE")
base := t.TempDir()
noGit := filepath.Join(base, "nogit")
withGit := filepath.Join(base, "withgit")
n := mirrorTree(t, src, noGit)
mirrorTree(t, src, withGit)
if err := os.MkdirAll(filepath.Join(withGit, ".git"), 0o755); err != nil {
t.Fatal(err)
}
t.Logf("拿 %s 的結構造了兩份拋棄式副本(各 %d 個檔),一份跑過 git init", src, n)
var first []string
for _, tc := range []struct{ name, root string }{
{"沒有版控(今天的 KB", noGit}, {"有版控(有人跑了一次 git init", withGit},
} {
plan := PlanIngest(tc.root)
m := &Manifest{Entries: map[string]*ManifestEntry{}}
payload, err := Scan(tc.root, m, ScanOptions{})
if err != nil {
t.Fatal(err)
}
got := eventPaths(payload)
t.Logf("%s:舊模式=%s|新模式=%s|送出 %d 個檔",
tc.name, oldMode(tc.root), plan.Mode, len(got))
if first == nil {
first = got
continue
}
if strings.Join(got, "\n") != strings.Join(first, "\n") {
t.Fatalf("🔴 跑一次 git init 就改變了收到的東西:%d → %d 個檔", len(first), len(got))
}
t.Logf("✅ 兩邊收到的檔案集合完全相同(%d 個)", len(got))
}
}
func TestProbeRealFolders(t *testing.T) {
if os.Getenv("PROBE_REAL") != "1" {
t.Skip("手動探針")
}
for _, root := range strings.Split(os.Getenv("PROBE_ROOTS"), ",") {
root = strings.TrimSpace(root)
if root == "" {
continue
}
shape := InspectFolder(root)
plan := PlanIngest(root)
t.Logf("═══ %s ═══", root)
t.Logf(" 版控訊號(舊判準用的):DetectRepoRoot=%q", DetectRepoRoot(root))
t.Logf(" 實測形狀:專案檔 %d 個 %v/原始碼 %d/文件 %dtruncated=%v",
shape.ManifestCount, shape.ManifestRels, shape.CodeFiles, shape.DocFiles, shape.Truncated)
t.Logf(" 舊模式=%s → 新模式=%s", oldMode(root), plan.Mode)
t.Logf(" 理由:%s", plan.Reason)
if os.Getenv("PROBE_SCAN") == "1" {
m := &Manifest{Entries: map[string]*ManifestEntry{}}
payload, err := Scan(root, m, ScanOptions{})
if err != nil {
t.Logf(" 掃描失敗:%v", err)
continue
}
t.Logf(" ⇒ 這一輪會送出 %d 個檔(逐檔擋掉 %d,整棵跳過 %d 個資料夾)",
len(payload.Events), payload.ExcludedByPlan, payload.ExcludedDirCount)
for i, e := range payload.Events {
if i >= 12 {
t.Logf(" …等 %d 個", len(payload.Events))
break
}
t.Logf(" ✓ %s", e.Path)
}
for i, d := range payload.ExcludedDirs {
if i >= 8 {
break
}
t.Logf(" ✗ %s — %s", d.Path, d.Reason)
}
}
}
}