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:
+230
@@ -0,0 +1,230 @@
|
||||
// foldershape.go — 「這個資料夾裡實際裝了什麼」:用內容回答,不看版控
|
||||
// (arcrun-rag#104 第二層,2026-08-16 leo 規格)。
|
||||
//
|
||||
// 🔴 leo 2026-08-16 原話(本檔的規格):
|
||||
//
|
||||
// 「**你不需要判斷有沒有 git,我的 KB 筆記庫也有 git,
|
||||
// 是否用 github/gitea 追蹤完全沒意義。**」
|
||||
//
|
||||
// #104 第一層已經把「哪些目錄算雜訊」改成不看版控(見 ingestplan.go 的三種強度)。
|
||||
// 但「**這個資料夾整體該用哪種收法**」(all/curated-wiki/docs-only)那一層,
|
||||
// 當時仍然由 `DetectRepoRoot`(找 `.git`)決定——本檔就是要把它換掉。
|
||||
//
|
||||
// 🔴 為什麼非換不可(實測,非推測):`~/Documents/KB` 是 leo 的真知識庫。
|
||||
//
|
||||
// 今天:沒有版控 ⇒ all ⇒ 5,915 份文件全收(正確)
|
||||
// 只要有人在那跑一次 `git init`:
|
||||
// 有版控 ⇒ curated-wiki ⇒ 只剩 `system-dev/wiki` 的 14 張
|
||||
//
|
||||
// 而 KB 真的有 `system-dev/wiki`(leo 在那裡也裝過 template)——
|
||||
// **炸彈的引信早就接好了,只差一個 `git init`。**
|
||||
// 而在自己的筆記資料夾開版控是完全正常的行為,畫面上不會有任何提示。
|
||||
//
|
||||
// ⇒ 判準改成問:**這個資料夾裡有沒有一整套軟體專案?**
|
||||
// 那正是「該不該跳過原始碼」這個問題本身,而版控只是「這個人有沒有在做版本備份」。
|
||||
//
|
||||
// ── 判準(三個條件同時成立才算軟體專案)──────────────────────────────────────
|
||||
//
|
||||
// ① 找得到專案檔(package.json/go.mod/Cargo.toml…) ── 有人在這裡跑建置工具
|
||||
// ② 原始碼檔 ≥ softwareProjectMinCodeFiles ── 不是零星幾個範例
|
||||
// ③ 原始碼檔 × 4 ≥ 文件檔 ── 程式碼不是文件旁邊的零頭
|
||||
//
|
||||
// 實測三個真實資料夾(2026-08-16,唯讀 `find` 計數):
|
||||
//
|
||||
// 專案檔 原始碼 文件 判定
|
||||
// ~/Documents/KB 0 15 5,915 ✗ 筆記庫(①②③ 三條全不過)
|
||||
// InkStoneCo 20+ 5,901 8,036 ✓ 軟體專案
|
||||
// tech_projects/pms 有 大量 41 ✓ 軟體專案
|
||||
//
|
||||
// 三條都不看 `.git`,所以 `git init` 跑幾次都不會改變答案。
|
||||
//
|
||||
// 🔴 為什麼要三條而不是一條:**漏判的代價是多收一些檔,誤判的代價是使用者的知識
|
||||
// 靜默消失** ——代價不對稱,就往安全那邊倒(同 ingestplan.go 排除判準的那條)。
|
||||
//
|
||||
// · 只看專案檔:筆記庫裡放一個 `Makefile` 就整個塌掉
|
||||
// · 只看原始碼數:筆記庫裡存了幾十個程式碼片段就整個塌掉
|
||||
// · 加上第③條:筆記庫裡放一整份下載回來的範例專案(有專案檔、有上百個原始碼檔),
|
||||
// 只要它相對於那幾千份筆記仍是零頭,整個資料夾就照樣全收
|
||||
package collector
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// softwareProjectMinCodeFiles=要幾個原始碼檔才算「一整套」。
|
||||
//
|
||||
// 20 這個數字的來源不是拍腦袋,是「**低於它就沒有東西需要防**」:
|
||||
// 一個只有三五個原始碼檔的資料夾,散落的 `.md` 本來就寥寥可數,
|
||||
// 全收也不會淹掉使用者的知識庫——而這一票要防的正是「幾千個檔淹掉幾十張卡」。
|
||||
const softwareProjectMinCodeFiles = 20
|
||||
|
||||
// codeToDocRatioDenominator=第③條的分母:原始碼檔 × 4 ≥ 文件檔。
|
||||
//
|
||||
// 白話是「**程式碼至少要佔到文件的四分之一**,才算這個資料夾是拿來寫程式的」。
|
||||
// KB:15 × 4 = 60,遠小於 5,915 ⇒ 筆記庫。InkStoneCo:5,901 × 4 遠大於 8,036 ⇒ 專案。
|
||||
const codeToDocRatioDenominator = 4
|
||||
|
||||
// shapeProbeMaxEntries=探測時最多看幾個檔案系統項目。
|
||||
//
|
||||
// 為什麼要有上限:這支在每一輪同步、每一個看守根各跑一次。真的碰到一個病態大的
|
||||
// 資料夾時,寧可拿「已經看過的六萬個項目」下判斷,也不要把同步卡在這裡。
|
||||
// 看滿六萬個項目還沒湊齊三個條件,本來就不是軟體專案。
|
||||
const shapeProbeMaxEntries = 60000
|
||||
|
||||
// codeFileExts=算得上「原始碼」的副檔名。
|
||||
//
|
||||
// 🔴 刻意**不收** `.html`/`.css`/`.json`/`.xml`:筆記軟體匯出的網頁、
|
||||
// 存下來的資料、設定檔都長這樣,把它們算成程式碼會讓筆記庫誤判成專案。
|
||||
// 這裡只留「不寫程式的人不會有」的那些。
|
||||
var codeFileExts = map[string]bool{
|
||||
".go": true, ".rs": true, ".java": true, ".kt": true, ".kts": true,
|
||||
".swift": true, ".m": true, ".mm": true, ".c": true, ".h": true,
|
||||
".cc": true, ".cpp": true, ".hpp": true, ".cs": true,
|
||||
".ts": true, ".tsx": true, ".js": true, ".jsx": true, ".mjs": true, ".cjs": true,
|
||||
".vue": true, ".svelte": true,
|
||||
".py": true, ".rb": true, ".php": true, ".pl": true, ".lua": true,
|
||||
".ex": true, ".exs": true, ".erl": true, ".hs": true, ".clj": true,
|
||||
".scala": true, ".dart": true, ".groovy": true,
|
||||
".sh": true, ".bash": true, ".zsh": true, ".ps1": true, ".bat": true,
|
||||
".sql": true, ".proto": true, ".tf": true, ".wat": true,
|
||||
}
|
||||
|
||||
// FolderShape=探測結果。欄位不只是判準的材料,也是**講給使用者聽的證據**
|
||||
// (票上的紅線:排除要看得見,不能只丟一個結論給他)。
|
||||
type FolderShape struct {
|
||||
// ManifestRels=找到的專案檔(相對路徑),最多留 maxShapeEvidence 個當證據。
|
||||
ManifestRels []string `json:"manifest_files,omitempty"`
|
||||
// ManifestCount/CodeFiles/DocFiles=實際數到的量。
|
||||
ManifestCount int `json:"manifest_count"`
|
||||
CodeFiles int `json:"code_files"`
|
||||
DocFiles int `json:"doc_files"`
|
||||
// Truncated=有沒有撞到 shapeProbeMaxEntries 上限。
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// maxShapeEvidence=證據最多列幾個專案檔(給使用者看的,不是清單)。
|
||||
const maxShapeEvidence = 3
|
||||
|
||||
// IsSoftwareProject 回答「這個資料夾裡有沒有一整套軟體專案」。
|
||||
//
|
||||
// 🔴 三條同時成立才算,理由見檔頭。任一條不過就當成一般資料夾/筆記庫(全收)。
|
||||
func (s FolderShape) IsSoftwareProject() bool {
|
||||
if s.ManifestCount == 0 || s.CodeFiles < softwareProjectMinCodeFiles {
|
||||
return false
|
||||
}
|
||||
return s.CodeFiles*codeToDocRatioDenominator >= s.DocFiles
|
||||
}
|
||||
|
||||
// Evidence 把判定的依據講成一句人話,塞進 IngestPlan.Reason 給使用者看。
|
||||
//
|
||||
// 「我判斷這是軟體專案」不附證據,等於要使用者相信一個黑盒;附上「看到 go.mod、
|
||||
// package.json,還有 5,901 個原始碼檔」,他自己就看得出對不對。
|
||||
func (s FolderShape) Evidence() string {
|
||||
if !s.IsSoftwareProject() {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("我看到 ")
|
||||
if len(s.ManifestRels) > 0 {
|
||||
b.WriteString(strings.Join(s.ManifestRels, "、"))
|
||||
if s.ManifestCount > len(s.ManifestRels) {
|
||||
b.WriteString(" 等專案檔")
|
||||
} else {
|
||||
b.WriteString(" 這類專案檔")
|
||||
}
|
||||
b.WriteString(",還有 ")
|
||||
}
|
||||
b.WriteString(plainCount(s.CodeFiles))
|
||||
b.WriteString(" 個原始碼檔")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// plainCount 把數字寫成人看的樣子(超過上限就講「多」,不假裝精準)。
|
||||
func plainCount(n int) string {
|
||||
if n >= shapeProbeMaxEntries {
|
||||
return "非常多"
|
||||
}
|
||||
return itoaWithComma(n)
|
||||
}
|
||||
|
||||
func itoaWithComma(n int) string {
|
||||
s := ""
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
for i := 0; n > 0; i++ {
|
||||
if i > 0 && i%3 == 0 {
|
||||
s = "," + s
|
||||
}
|
||||
s = string(rune('0'+n%10)) + s
|
||||
n /= 10
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// InspectFolder 走一趟資料夾,數出「裡面實際裝了什麼」。
|
||||
//
|
||||
// 🔴 走訪時**不看任何版控訊號**——不看 `.git`、不看 `.gitignore` 的存在、
|
||||
// 也不因為某個子目錄是巢狀 repo 或 linked worktree 就跳過它。
|
||||
// 那些訊號正是這一票要拔掉的東西;而且要判斷「裡面裝了什麼」,本來就該把裡面看完。
|
||||
//
|
||||
// 只跳過三種目錄,理由與 ingestplan.go 的排除判準同源(都不是版控訊號):
|
||||
// - 隱藏目錄(`.` 開頭):Scan 本來就不走
|
||||
// - toolOwnedDirNames:`node_modules` 底下是別人的原始碼,數它等於數別人的專案
|
||||
// - ambiguousBuildDirNames + looksGenerated:建置產物是同一份程式碼的第二份拷貝
|
||||
func InspectFolder(absRoot string) FolderShape {
|
||||
var s FolderShape
|
||||
manifestNames := map[string]bool{}
|
||||
for _, n := range projectManifestFiles {
|
||||
manifestNames[n] = true
|
||||
}
|
||||
seen := 0
|
||||
|
||||
_ = filepath.WalkDir(absRoot, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if seen >= shapeProbeMaxEntries {
|
||||
s.Truncated = true
|
||||
return filepath.SkipAll
|
||||
}
|
||||
seen++
|
||||
|
||||
if d.IsDir() {
|
||||
if p == absRoot {
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
if strings.HasPrefix(name, ".") || toolOwnedDirNames[name] ||
|
||||
(ambiguousBuildDirNames[name] && looksGenerated(p)) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
name := d.Name()
|
||||
if strings.HasPrefix(name, ".") {
|
||||
return nil
|
||||
}
|
||||
if manifestNames[name] {
|
||||
s.ManifestCount++
|
||||
if len(s.ManifestRels) < maxShapeEvidence {
|
||||
if rel, rerr := filepath.Rel(absRoot, p); rerr == nil {
|
||||
s.ManifestRels = append(s.ManifestRels, filepath.ToSlash(rel))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
switch {
|
||||
case codeFileExts[ext]:
|
||||
s.CodeFiles++
|
||||
case allowedExt[ext]:
|
||||
s.DocFiles++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return s
|
||||
}
|
||||
+46
-18
@@ -17,8 +17,11 @@
|
||||
// ⇒ 本檔回答一個問題:**這個監看根該收哪些檔**。三種答案:
|
||||
//
|
||||
// IngestAll 一般資料夾/筆記庫——收全部(行為與先前完全一致,零改變)
|
||||
// IngestCuratedWiki 版控中的專案,而且**已經有整理好的 wiki** ⇒ 只收那一份
|
||||
// IngestDocsOnly 版控中的專案,但沒有現成 wiki ⇒ 只收文件區,程式碼一律不讀
|
||||
// IngestCuratedWiki 軟體專案,而且**已經有整理好的 wiki** ⇒ 只收那一份
|
||||
// IngestDocsOnly 軟體專案,但沒有現成 wiki ⇒ 只收文件區,程式碼一律不讀
|
||||
//
|
||||
// 🔴 「是不是軟體專案」**看資料夾裡實際裝了什麼**,不看有沒有 `.git`
|
||||
// (2026-08-16 第二層修正,判準在 foldershape.go;為什麼見 PlanIngest 的說明)。
|
||||
//
|
||||
// 🔴 「不要用副檔名白名單當唯一判準」(票上的紅線):本檔的主判準是**路徑身分**
|
||||
// (這個目錄在這個 repo 裡扮演什麼角色),副檔名只是最後一道。`.md` 在 repo 裡
|
||||
@@ -51,8 +54,11 @@ const (
|
||||
// 的當下,唯一能讓他不慌的東西就是這句話。用他的話寫,不要寫路徑術語。
|
||||
type IngestPlan struct {
|
||||
Mode IngestMode `json:"mode"`
|
||||
// RepoRoot=版控工作目錄的根(Mode != IngestAll 時非空)。
|
||||
RepoRoot string `json:"repo_root,omitempty"`
|
||||
// Shape=「這個資料夾裡實際裝了什麼」的實測結果,也就是模式是怎麼決定的
|
||||
// (foldershape.go)。**取代了原本的 RepoRoot(`.git` 的位置)**——
|
||||
// leo 2026-08-16 推翻版控判準,判準改成內容,那麼「為什麼這樣判」的證據
|
||||
// 也該是內容(幾個專案檔、幾個原始碼檔),不是一個路徑。
|
||||
Shape FolderShape `json:"shape"`
|
||||
// WikiRelDir=現成 wiki 的相對路徑(僅 IngestCuratedWiki)。
|
||||
WikiRelDir string `json:"wiki_rel_dir,omitempty"`
|
||||
// DocRelDirs=要收的文件目錄(僅 IngestDocsOnly;根層 .md 另由 keepsRootDoc 放行)。
|
||||
@@ -199,29 +205,45 @@ func looksGenerated(absDir string) bool {
|
||||
//
|
||||
// 判斷順序刻意照 leo 的原話:先問「是不是專案」,再問「有沒有現成 wiki」,最後才退到文件區。
|
||||
//
|
||||
// 為什麼「是不是專案」用版控(`.git`)判:那是唯一不必猜的訊號,而且與 #105 同一個判準
|
||||
// ——一個資料夾在版控裡,就代表裡面有人在追每個檔案的歷史,那幾乎必然是原始碼專案而不是
|
||||
// 誰的筆記本。判準只有一個地方(repoguard.go),兩張票共用,不會漂移。
|
||||
// 🔴 「是不是專案」怎麼判:**看這個資料夾裡實際裝了什麼**(foldershape.go),不看版控。
|
||||
// leo 2026-08-16 當場推翻了原本的 `.git` 判準:
|
||||
//
|
||||
// 「**你不需要判斷有沒有 git,我的 KB 筆記庫也有 git,
|
||||
// 是否用 github/gitea 追蹤完全沒意義。**」
|
||||
//
|
||||
// 舊判準是一顆定時炸彈:`~/Documents/KB`(leo 的真知識庫)今天沒有 `.git` ⇒ all
|
||||
// ⇒ 5,915 份文件全收;而它**真的有 `system-dev/wiki`**(他在那裡也裝過 template)
|
||||
// ⇒ 只要有人在那跑一次 `git init`,就翻成 curated-wiki、靜默塌成 14 張,
|
||||
// 而畫面上不會有任何提示。**引信早就接好了,只差一個很自然的動作。**
|
||||
// 換成內容判準之後,`git init` 跑幾次都不會改變答案
|
||||
// (TestPlanIngest_同一棵樹有沒有版控收到的必須一模一樣 釘死這件事)。
|
||||
//
|
||||
// ⚠️ **只有「用哪種收法」這個判斷改掉了。** `repoguard.go` 的另一個用途
|
||||
// (#105「版控中的資料夾一個檔都不自動改名搬移」,見 tidy.go)**沒有動、也不該動**
|
||||
// ——那一個問的真的是版控,而且問對了。
|
||||
func PlanIngest(absRoot string) IngestPlan {
|
||||
repoRoot := DetectRepoRoot(absRoot)
|
||||
shape := InspectFolder(absRoot)
|
||||
// 使用者自己寫的排除宣告——只讀它的**內容**當線索,不拿它的存在當門檻。
|
||||
ignore := LoadIgnoreRules(absRoot)
|
||||
|
||||
if repoRoot == "" {
|
||||
if !shape.IsSoftwareProject() {
|
||||
return IngestPlan{
|
||||
Mode: IngestAll,
|
||||
Reason: "這是一般資料夾,裡面的文件我全部都會讀(別人的套件與建置產物除外)。",
|
||||
Mode: IngestAll,
|
||||
Shape: shape,
|
||||
Reason: "這是一般資料夾或筆記庫(裡面沒有成套的程式碼)," +
|
||||
"裡面的文件我全部都會讀(別人的套件與建置產物除外)。",
|
||||
ignore: ignore,
|
||||
}
|
||||
}
|
||||
|
||||
evidence := shape.Evidence()
|
||||
others := otherWikiDirs(absRoot)
|
||||
if wiki := findCuratedWiki(absRoot); wiki != "" {
|
||||
return IngestPlan{
|
||||
Mode: IngestCuratedWiki,
|
||||
RepoRoot: repoRoot,
|
||||
Shape: shape,
|
||||
WikiRelDir: wiki,
|
||||
Reason: "這是一個開發專案,而且你已經整理好一份知識庫(" + wiki + ")——" +
|
||||
Reason: "這是一個開發專案(" + evidence + "),而且你已經整理好一份知識庫(" + wiki + ")——" +
|
||||
"我直接讀那一份就好,不再把整個專案的原始碼與零散檔案重萃一次。",
|
||||
OtherWikiDirs: others,
|
||||
ignore: ignore,
|
||||
@@ -229,13 +251,14 @@ func PlanIngest(absRoot string) IngestPlan {
|
||||
}
|
||||
|
||||
docs := existingDocDirs(absRoot)
|
||||
reason := "這是一個開發專案,我只讀文件、不讀程式碼。"
|
||||
reason := "這是一個開發專案(" + evidence + "),我只讀文件、不讀程式碼。"
|
||||
if len(docs) > 0 {
|
||||
reason = "這是一個開發專案,我只讀文件(" + strings.Join(docs, "、") + ")與根目錄的說明檔,不讀程式碼。"
|
||||
reason = "這是一個開發專案(" + evidence + "),我只讀文件(" +
|
||||
strings.Join(docs, "、") + ")與根目錄的說明檔,不讀程式碼。"
|
||||
}
|
||||
return IngestPlan{
|
||||
Mode: IngestDocsOnly,
|
||||
RepoRoot: repoRoot,
|
||||
Shape: shape,
|
||||
DocRelDirs: docs,
|
||||
Reason: reason,
|
||||
OtherWikiDirs: others,
|
||||
@@ -445,8 +468,13 @@ func (p IngestPlan) KeepsFile(relSlash string) bool {
|
||||
// 是他親手整理的知識庫,而且是唯一該收的東西。
|
||||
//
|
||||
// 兩條規則對撞,用**身分**化解而不是拿掉任何一條:
|
||||
// - 我們代裝的資料夾沒有 `.git` ⇒ PlanIngest 回 IngestAll ⇒ 這裡回 false ⇒ 舊規則照舊
|
||||
// - 他自己的 repo 有 `.git` 且有現成 wiki ⇒ curated-wiki ⇒ 這裡回 true ⇒ 收那份 wiki
|
||||
// - 我們代裝進使用者筆記資料夾的那份,那個資料夾裡沒有成套的程式碼
|
||||
// ⇒ PlanIngest 回 IngestAll ⇒ 這裡回 false ⇒ 舊規則照舊
|
||||
// - 他自己的軟體專案(有專案檔、有成堆原始碼)且有現成 wiki
|
||||
// ⇒ curated-wiki ⇒ 這裡回 true ⇒ 收那份 wiki
|
||||
//
|
||||
// 🔴 2026-08-16 更新:以前這兩行寫的是「沒有 `.git`/有 `.git`」,而那正是
|
||||
// 本票第二層要拔掉的判準——leo 的 KB 筆記庫也有 git。現在兩邊都改看內容。
|
||||
func (p IngestPlan) OverridesTemplateOwned(relSlash string) bool {
|
||||
if p.Mode != IngestCuratedWiki {
|
||||
return false
|
||||
|
||||
+262
-27
@@ -61,6 +61,21 @@ func countDocFiles(t *testing.T, root string) int {
|
||||
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) {
|
||||
@@ -78,10 +93,12 @@ func makeMonorepoFixture(t *testing.T) (root string, curatedCount int) {
|
||||
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"] = "# 版本紀錄"
|
||||
|
||||
@@ -103,7 +120,7 @@ func makeMonorepoFixture(t *testing.T) (root string, curatedCount int) {
|
||||
}
|
||||
}
|
||||
|
||||
// 監看根是版控中的專案
|
||||
// 監看根同時也在版控裡——**故意留著**:它不准影響任何判斷(見本檔最後那條迴歸測試)。
|
||||
mustMkdir(t, filepath.Join(root, ".git"))
|
||||
|
||||
// ⑤ 出貨用 worktree(`.git` 是**檔案**)——主 repo 的第二份簽出,內容重複(實據:5 份)
|
||||
@@ -214,22 +231,17 @@ func TestPlanIngest_ExclusionsAreVisible(t *testing.T) {
|
||||
// 沒有現成 wiki 的 repo:退到「只讀文件、不讀程式碼」(leo 明講的第三步)。
|
||||
func TestPlanIngest_RepoWithoutWikiReadsDocsOnly(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
files := map[string]string{
|
||||
files := codeProjectFiles("", ".go", "package main")
|
||||
for rel, body := range map[string]string{
|
||||
"README.md": "# 專案",
|
||||
"docs/請假規則.md": "# 特休 14 天",
|
||||
"docs/報銷政策.md": "# 每日 3000 元",
|
||||
"src/main.go": "package main",
|
||||
"src/說明.md": "# 散在程式碼旁邊",
|
||||
"internal/notes.md": "# 也是程式碼旁邊",
|
||||
} {
|
||||
files[rel] = body
|
||||
}
|
||||
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"))
|
||||
writeFixture(t, root, files)
|
||||
|
||||
payload, plan := scanWithPlan(t, root)
|
||||
got := eventPaths(payload)
|
||||
@@ -279,8 +291,9 @@ func TestPlanIngest_PlainFolderUnchanged(t *testing.T) {
|
||||
func TestPlanIngest_EmptyWikiFallsBackToDocs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustMkdir(t, filepath.Join(root, "system-dev", "wiki"))
|
||||
mustMkdir(t, filepath.Join(root, ".git"))
|
||||
writeFixture(t, root, map[string]string{"docs/說明.md": "# 說明"})
|
||||
files := codeProjectFiles("", ".go", "package main")
|
||||
files["docs/說明.md"] = "# 說明"
|
||||
writeFixture(t, root, files)
|
||||
|
||||
plan := PlanIngest(root)
|
||||
if plan.Mode != IngestDocsOnly {
|
||||
@@ -314,6 +327,11 @@ func makePMSFixture(t *testing.T) (root string, mine []string) {
|
||||
// ② 專案本體(有 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 文件與第三方授權條款
|
||||
@@ -429,16 +447,34 @@ func TestPlanIngest_同一個名字看旁邊擺什麼決定(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 有 `.git` 但其實是筆記庫:**版控不得改變收檔行為**(leo 2026-08-16 推翻 .git 判準)。
|
||||
// 🔴 本票最重要的一條:**版控訊號不得改變任何收檔行為**(leo 2026-08-16 推翻 .git 判準)。
|
||||
//
|
||||
// ⚠️ 已知落差(不在本輪範圍,另報總管):`PlanIngest` 的**模式選擇**仍看 `.git`
|
||||
// ——所以帶 `.git` 的筆記庫會被判成 docs-only。本測試只釘住「排除規則那一層
|
||||
// 不看版控」,模式選擇那一層要另外開票處理。
|
||||
func TestPlanIngest_排除判準不看有沒有版控(t *testing.T) {
|
||||
// 「**你不需要判斷有沒有 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": "# 別人的套件",
|
||||
"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()
|
||||
@@ -447,26 +483,225 @@ func TestPlanIngest_排除判準不看有沒有版控(t *testing.T) {
|
||||
writeFixture(t, withGit, base)
|
||||
mustMkdir(t, filepath.Join(withGit, ".git"))
|
||||
|
||||
var firstSet string
|
||||
for _, tc := range []struct{ name, root string }{
|
||||
{"沒有版控", withoutGit}, {"有版控", withGit},
|
||||
{"沒有版控", withoutGit}, {"有版控(跑過 git init)", 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)
|
||||
got := eventPaths(payload)
|
||||
t.Logf("%s:策略=%s|送出 %d 個檔|跳過=%v", tc.name, plan.Mode, len(got), 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"])
|
||||
}
|
||||
// ② 模式選擇那一層(本輪補上的)
|
||||
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
|
||||
// 34=30 篇日記+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 ⇒ 只收文件區+根層說明檔,跳過原始碼
|
||||
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"},
|
||||
},
|
||||
} {
|
||||
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("策略=%s,want %s", plan.Mode, tc.wantMode)
|
||||
}
|
||||
sort.Strings(tc.wantGot)
|
||||
if strings.Join(got, ",") != strings.Join(tc.wantGot, ",") {
|
||||
t.Fatalf("送出 %v,want %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("送出 %d/7:%v", len(got), got)
|
||||
|
||||
if plan.Mode != IngestAll {
|
||||
t.Fatalf("策略=%s,want %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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 接線:**模式選擇的判準只有一個地方**。
|
||||
//
|
||||
// 這一票的同款形狀出現過四次(能力做好了,卻不在會被執行的那條路上)。
|
||||
// 上一輪用 `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「不要改動使用者版控中的檔案」那一條是對的," +
|
||||
"它問的真的是版控,不准跟著這一票一起拿掉")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/文件 %d(truncated=%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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user