Files
arcrun-collector/foldershape.go
T
Leo 577d883e94 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>
2026-08-16 23:26:52 +08:00

231 lines
9.3 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.
// foldershape.go — 「這個資料夾裡實際裝了什麼」:用內容回答,不看版控
// arcrun-rag#104 第二層,2026-08-16 leo 規格)。
//
// 🔴 leo 2026-08-16 原話(本檔的規格):
//
// 「**你不需要判斷有沒有 git,我的 KB 筆記庫也有 git,
// 是否用 github/gitea 追蹤完全沒意義。**」
//
// #104 第一層已經把「哪些目錄算雜訊」改成不看版控(見 ingestplan.go 的三種強度)。
// 但「**這個資料夾整體該用哪種收法**」(allcurated-wikidocs-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.jsongo.modCargo.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 ≥ 文件檔。
//
// 白話是「**程式碼至少要佔到文件的四分之一**,才算這個資料夾是拿來寫程式的」。
// KB15 × 4 = 60,遠小於 5,915 ⇒ 筆記庫。InkStoneCo5,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"`
// ManifestCountCodeFilesDocFiles=實際數到的量。
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
}