diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e1d43e..9c9e0f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,14 @@ --- +## 0.18.41(2026-08-27) + +- 🔴 **不叫「docs」的文件夾,現在也認得出來**:以前只有名字剛好叫 `docs`/`doc`/`documentation` 的資料夾會被讀,一個放滿 PDF、Word、筆記的資料夾只要名字不一樣(例如一個舊專案的存檔夾),就整個被當成程式碼跳過,畫面上也不會說為什麼。現在只要一個資料夾裡完全沒有程式碼、有看得懂的文件,不管叫什麼名字都會被收進來——你不用先學會「哪個按鈕可以救回一個資料夾」,大部分情況它一開始就是對的。 + +## 0.18.40(2026-08-27) + +- 🔴 **卡住的檔案不再擋住後面所有人**:以前只要有幾個檔一直收不上去(格式讀不了、雲端當天額度滿了…),它們就會**每一輪都繼續佔著最前面的位子**,即使那一輪根本沒去試它們。結果是排在後面、本來好好的檔案永遠輪不到——你等再久、跑再多輪都一樣,數字只會往上長不會往下掉。現在退避中的檔案會讓開,後面的檔案遞補得上來,佇列真的會往前走。 + ## 0.18.39(2026-08-27) - 🔴 **「已送上去」從今以後是真的送上去了**:以前只要雲端把請求收下來,小幫手就算它成功——就算雲端其實根本沒把它寫進你的知識庫。畫面上是綠的、數字也在跑,而 AI 一句都查不到,而且那些檔案因為被蓋了「已送」的章,**永遠不會再試一次**。現在它會看雲端真正的回覆:沒寫進去就誠實標成失敗、不蓋章、下一輪重新送。(實測:一個開發資料夾 26 份標「已送達」,雲端實際只有 4 份。) diff --git a/cmd/arcrun-app/.version-source-files.json b/cmd/arcrun-app/.version-source-files.json index fa21b67..1faef37 100644 --- a/cmd/arcrun-app/.version-source-files.json +++ b/cmd/arcrun-app/.version-source-files.json @@ -1,10 +1,10 @@ { "_algo": 4, - "version": "0.18.39", - "fingerprint": "316426f50e886003", + "version": "0.18.41", + "fingerprint": "8033de0cdec3537b", "files": { ".gitignore": "4d56952b0fb13bf8f9b6c13a6d4c34a075bac3af447636a1df4335d7576e2f97", - "CHANGELOG.md": "f6f3858cd587278528f68ff7290722b6e67fcd763a5b159791169c49387d5502", + "CHANGELOG.md": "7f8a82a8a89483c09b76ed23773f8ef4dc56dae398e558130d40de4ddbdea107", "DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d", "README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f", "check-standalone.sh": "65fbce096326791c2f103a51e76d0ad79e9e510e2a7700b49ac2d2d81f73c853", @@ -122,11 +122,11 @@ "convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394", "convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18", "convert_wiring_test.go": "09e97bf32ace245b55acc7d65ee5c0bb7fd84f8fb1aab61f603f9e9d18b22f13", - "direct.go": "8f5adcbfe51e6b21b4cb585fd02c7a2f66ffa28770b61aa9722abd424c35c8a3", + "direct.go": "edea1388f4305d71b7738f131ed8513ac5de7b8d04db7d98f603d70ea80bf53c", "direct_extract_test.go": "317d0057d2d368fabc406351a7477f72e074d2b8c8459b1dc021febdf16902f4", "direct_multi_test.go": "a76593fef26231e18b7402f6f7376624d2471f8a719b3ce08fc4a0f5a3d3cf1e", - "direct_pacing.go": "2690b2fff858250028886bc3cd7793be54b4d4a03ddca41485476cb04b79f7c3", - "direct_pacing_test.go": "ba5c140082931a50c3728078502803c654c2fb9dacecda679f8f40e6ab61a0c5", + "direct_pacing.go": "b54838ce85f72ecd2d3388fa7cf89050c87fb10bd67dca07cd18d49568a8837c", + "direct_pacing_test.go": "f118ec03ab0bf51871037aa51eb442b98074e218430d244cb7d54b1d9791d738", "direct_quota_test.go": "0d4747db434fd10f367ff88c8253263e2f2ce3559cd767de2f0b2c77bdf7a080", "direct_retire_test.go": "fbff728362313c546c1fc01cc74094672e22a8171d4bc0329ce1b3d118c82b3c", "direct_sync_now_test.go": "dc7a6e3d5646a470e889d30f0224cdb86be832b8cf85ffef8629e42ec577622b", @@ -150,8 +150,8 @@ "grounding_test.go": "46f5fd98297caf45b5db494f69363f2544e1cfa5634ce24fe25ca6a87ae296d2", "ignorerules.go": "e6923d0fe35e377e75cfe2364a10624ca40aa6d28efe48ea725036769a48ea14", "ignorerules_test.go": "19675a03539e92e7fd58a46ce8105875cc5aaa42f53615348de775b6d90dbf16", - "ingestplan.go": "0a0f170bb17db210231039e9c67f7f796a2eacd4450c54f5325d3483439d4ecb", - "ingestplan_test.go": "1ad31f4fb69d917e3f9d46f5615f38074b7ed6ec8baa01d71bd1600d317b7878", + "ingestplan.go": "0c78c96c3218b8ca3f15bceb3e99c087f055bbd4d2e6024fee6268e3efc46e94", + "ingestplan_test.go": "baf57a5edc99482842c16d1ea28d1ad6afe7a236a56fc42dac8d007341b578be", "ingestplan_wiring_test.go": "4e5d25dace8a181418ed5857423ef1efeacdd98b7ff42b94a71c4e5fd7012e89", "inventory.go": "d0ba0be3e2af8df3cc6dffac5b75816b9c2a0a12f6d2eff1a786d8584ca77622", "inventory_test.go": "16e0f83a09d61dc4cded032fd493d30bfc747c38116b871fb02b2f0bf2b0216a", diff --git a/cmd/arcrun-app/.version-source.json b/cmd/arcrun-app/.version-source.json index 39715e0..f26f305 100644 --- a/cmd/arcrun-app/.version-source.json +++ b/cmd/arcrun-app/.version-source.json @@ -9,5 +9,7 @@ "0.18.36": "abb2f2cc2498f071", "0.18.37": "3bb5c94f68a26a06", "0.18.38": "7add5da65c671548", - "0.18.39": "316426f50e886003" + "0.18.39": "316426f50e886003", + "0.18.40": "a4485965c128ef9d", + "0.18.41": "8033de0cdec3537b" } diff --git a/ingestplan.go b/ingestplan.go index 2effcc0..abf3b2f 100644 --- a/ingestplan.go +++ b/ingestplan.go @@ -62,6 +62,9 @@ type IngestPlan struct { // WikiRelDir=現成 wiki 的相對路徑(僅 IngestCuratedWiki)。 WikiRelDir string `json:"wiki_rel_dir,omitempty"` // DocRelDirs=要收的文件目錄(僅 IngestDocsOnly;根層 .md 另由 keepsRootDoc 放行)。 + // 來源有兩種,合併後去重排序(見 mergeDocDirs):固定三名字(existingDocDirs) + // + Phase 0 內容判準自動找到的非標準命名文件目錄(scanAuxDirs 的 autoDocDirs, + // `arcrun-rag#136`)。 DocRelDirs []string `json:"doc_rel_dirs,omitempty"` // Reason=一句話講給使用者聽的「為什麼只收這些」。 Reason string `json:"reason"` @@ -237,7 +240,7 @@ func PlanIngest(absRoot string) IngestPlan { } evidence := shape.Evidence() - others := otherWikiDirs(absRoot) + aux := scanAuxDirs(absRoot) if wiki := findCuratedWiki(absRoot); wiki != "" { return IngestPlan{ Mode: IngestCuratedWiki, @@ -245,12 +248,12 @@ func PlanIngest(absRoot string) IngestPlan { WikiRelDir: wiki, Reason: "這是一個開發專案(" + evidence + "),而且你已經整理好一份知識庫(" + wiki + ")——" + "我直接讀那一份就好,不再把整個專案的原始碼與零散檔案重萃一次。", - OtherWikiDirs: others, + OtherWikiDirs: aux.otherWikiDirs, ignore: ignore, } } - docs := existingDocDirs(absRoot) + docs := mergeDocDirs(existingDocDirs(absRoot), aux.autoDocDirs) reason := "這是一個開發專案(" + evidence + "),我只讀文件、不讀程式碼。" if len(docs) > 0 { reason = "這是一個開發專案(" + evidence + "),我只讀文件(" + @@ -261,11 +264,33 @@ func PlanIngest(absRoot string) IngestPlan { Shape: shape, DocRelDirs: docs, Reason: reason, - OtherWikiDirs: others, + OtherWikiDirs: aux.otherWikiDirs, ignore: ignore, } } +// mergeDocDirs 合併「固定三名字」與「內容判準自動找到的」兩份文件目錄清單,去重排序。 +// 兩份清單本來就可能重疊(例如根層真的叫 `docs` 又剛好零程式碼)——去重才不會讓 +// Reason 文案讀起來像「docs、docs」。 +func mergeDocDirs(fixed, auto []string) []string { + seen := map[string]bool{} + var out []string + for _, d := range fixed { + if !seen[d] { + seen[d] = true + out = append(out, d) + } + } + for _, d := range auto { + if !seen[d] { + seen[d] = true + out = append(out, d) + } + } + sort.Strings(out) + return out +} + // findCuratedWiki 回傳第一個「存在且真的有內容」的現成 wiki 目錄(相對路徑),沒有回空字串。 // // 「有內容」=底下至少有一個 `.md`。空目錄不算——不然一個剛跑完 template 安裝、 @@ -315,24 +340,38 @@ func existingDocDirs(absRoot string) []string { return out } -// otherWikiDirs 找出監看根底下**其他**地方的 wiki(子專案自己的知識庫)。 +// auxDirScan=一次全樹走訪同時算出來的兩種結果(合併走訪,見 scanAuxDirs)。 +type auxDirScan struct { + otherWikiDirs []string // 子專案自己的 wiki(找到但刻意不收,只講出來) + autoDocDirs []string // Phase 0:內容判準找到的「非標準命名文件目錄」(要收) +} + +// scanAuxDirs 走訪一次監看根,同時算出「其他子專案的 wiki 在哪」與 +// 「Phase 0:非標準命名但整棵零程式碼的文件目錄」(`inkstone/arcrun-rag#136` +// leo 2026-08-24 第三次追加,優先度高於樹狀 UI)。 // -// 🔴 為什麼找出來卻不收:leo 的 `InkStoneCo` 底下有 `products/*`、`matrix/*` 這些 -// **各自獨立的 repo**,每一個都有自己的 `system-dev/wiki`。它們是**別的專案**的知識, -// 混進這個資料夾的知識庫裡,AR-Mira 搜一個主題就會回一堆分不清屬於誰的東西。 -// 要收哪一個,是使用者的決定——把那個子專案自己加進看守清單即可。 +// 🔴 兩者原本是兩支各自獨立的函式(otherWikiDirs/新的內容判準),這裡合併成一次 +// 走訪——理由是研究文件(`docs-only-skip-visibility-override-research.md` §2.5) +// 明講的經濟性:兩者都要「走一次監看根、套用同一組排除規則、在沒被排除的節點上 +// 做判斷」,差別只在判斷內容。合併之後每輪同步只走一次樹,不是兩次。 // -// 但**一定要講出來**:使用者接了一個 monorepo 卻只看到 32 張卡,不告訴他其餘的在哪, -// 他只會覺得東西不見了(票上的紅線:不要讓他猜)。 +// 🔴 合併還解決了一個正確性問題(不是效能問題):如果分開各自跑一次 WalkDir, +// 一個子專案自己的 `xxx/wiki/` 會被 otherWikiDirs 判定「找到但刻意不收」, +// 但新的內容判準若獨立走訪,會用「零程式碼+有文件」的邏輯把同一個 `wiki` 目錄 +// **又收了一次**,直接推翻 otherWikiDirs 那條「這是別人的知識,不混進來」的 +// 既有設計(該函式原本的說明就在解釋為什麼刻意不收)。同一次走訪、同一個節點只判 +// 一次,兩件事天生不會互相打架。 // -// 走訪時套用與 Scan 相同的跳過規則(隱藏目錄、noise、linked worktree、 -// 已知的 curated 位置自己),所以出貨用 worktree 與 templatefs 的那十幾份不會列進來。 -func otherWikiDirs(absRoot string) []string { - seen := map[string]bool{} +// 🔴 判準只在**通過既有排除規則、沒被任何一條攔下**的節點上跑(隱藏目錄、 +// `toolOwnedDirNames`、`ambiguousBuildDirNames`+`looksGenerated`、linked worktree) +// ——與 `SkipsDirWhy` 的優先序一致,所以 `node_modules` 底下的任何內容 +// **根本沒有機會被走到**,不會重新引入 `#104` 的套件洩漏洞。 +func scanAuxDirs(absRoot string) auxDirScan { + seenWiki := map[string]bool{} for _, rel := range curatedWikiCandidates { - seen[rel] = true + seenWiki[rel] = true } - var out []string + var result auxDirScan _ = filepath.WalkDir(absRoot, func(p string, d os.DirEntry, err error) error { if err != nil || !d.IsDir() || p == absRoot { return nil @@ -351,14 +390,71 @@ func otherWikiDirs(absRoot string) []string { return nil } relSlash := filepath.ToSlash(rel) - if name == "wiki" && !seen[relSlash] && dirHasMarkdown(p) { - out = append(out, relSlash) + + // ① 別人的 wiki——找到就講出來,但不收、不繼續往下走訪(其餘同舊行為)。 + if name == "wiki" && !seenWiki[relSlash] && dirHasMarkdown(p) { + result.otherWikiDirs = append(result.otherWikiDirs, relSlash) + return filepath.SkipDir + } + + // ② Phase 0:這個目錄「自己直接放的檔案」零程式碼、且至少一個文件類副檔名 + // ⇒ 即使名字不叫 docs,也當文件目錄收。 + // + // 🔴 只看「這個目錄自己直接放的檔案」,不遞迴檢查整棵子樹—— + // 研究文件原本建議整棵子樹零程式碼才算,但真實案例 + // (`pms_v1_legacy` 巢狀 `pms-backup/pms_db_backup.sql`)證明那樣會讓 + // `pms_v1_legacy` 自己直接放的 `PMS_USER_STORIES.md`/`PMS_GAP_ANALYSIS.md` + // 因為巢狀兩層深的一個 .sql 備份檔而整批繼續被跳過——治標的判準沒解決 + // 票上真正的案例。改成逐層各自判斷之後,`pms_v1_legacy` 用自己的直接內容 + // 合格,`pms-backup`(自己直接放著 .sql)不合格但不影響外層,而巢狀更深的 + // `pms-backup/.wiki`(自己直接內容零程式碼+有 .md)又重新合格—— + // 這與 Scan/畫面本來就「每個節點只算自己直接放的檔案」(`total_files` 等 + // 欄位的既有語意,見 `collector/foldertree.go`)一致,不是另立一套算法。 + // + // 合格就整棵收(`SkipsDirWhy`/`KeepsFile` 的 `onPathTo` 前綴比對本來就會 + // 涵蓋子孫),不必再往下走訪找子孫裡還有沒有另一個合格點。 + // 不合格則繼續遞迴——巢狀更深處仍可能有獨立合格的文件子目錄。 + if !seenWiki[relSlash] && dirQualifiesAsAutoDoc(p) { + result.autoDocDirs = append(result.autoDocDirs, relSlash) return filepath.SkipDir } return nil }) - sort.Strings(out) - return out + sort.Strings(result.otherWikiDirs) + sort.Strings(result.autoDocDirs) + return result +} + +// dirQualifiesAsAutoDoc 回答「這個目錄自己直接放的檔案,算不算文件目錄」—— +// Phase 0 的核心判準(`inkstone/arcrun-rag#136`):零程式碼副檔名+至少一個 +// 文件類副檔名。**只看直接放在這個目錄裡的檔案**,不遞迴看子目錄(見呼叫端說明)。 +// +// 🔴 「零程式碼」而非「程式碼佔比低於門檻」:leo 08-24 第三次留言明講不要用比例—— +// 一個真正的程式碼目錄(`scripts/` 這種文件寫得多的)不該因為比例低就被誤判成文件夾。 +// 副檔名表沿用既有的(`codeFileExts`/`allowedExt`/`docLikeExt`),不重新發明一套。 +func dirQualifiesAsAutoDoc(absDir string) bool { + entries, err := os.ReadDir(absDir) + if err != nil { + return false + } + hasDoc := false + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if strings.HasPrefix(name, ".") { + continue + } + ext := strings.ToLower(filepath.Ext(name)) + if codeFileExts[ext] { + return false // 一個程式碼副檔名就整個不合格——零門檻,見上方說明 + } + if allowedExt[ext] || docLikeExt[ext] { + hasDoc = true + } + } + return hasDoc } // ───────────────────────────────────────────────────────────────────────────── diff --git a/ingestplan_test.go b/ingestplan_test.go index a026351..7e4d560 100644 --- a/ingestplan_test.go +++ b/ingestplan_test.go @@ -229,6 +229,15 @@ func TestPlanIngest_ExclusionsAreVisible(t *testing.T) { } // 沒有現成 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") @@ -236,8 +245,8 @@ func TestPlanIngest_RepoWithoutWikiReadsDocsOnly(t *testing.T) { "README.md": "# 專案", "docs/請假規則.md": "# 特休 14 天", "docs/報銷政策.md": "# 每日 3000 元", - "src/說明.md": "# 散在程式碼旁邊", - "internal/notes.md": "# 也是程式碼旁邊", + "src/說明.md": "# 散在程式碼旁邊——但 src/ 自己直接放的檔案裡就有 .go,不合格", + "internal/notes.md": "# 自己單獨一個資料夾,直接內容零程式碼——Phase 0 判準下合格", } { files[rel] = body } @@ -250,9 +259,11 @@ func TestPlanIngest_RepoWithoutWikiReadsDocsOnly(t *testing.T) { if plan.Mode != IngestDocsOnly { t.Fatalf("策略=%s,want %s", plan.Mode, IngestDocsOnly) } - want := []string{"README.md", "docs/報銷政策.md", "docs/請假規則.md"} + want := []string{"README.md", "docs/報銷政策.md", "docs/請假規則.md", "internal/notes.md"} + sort.Strings(want) if strings.Join(got, ",") != strings.Join(want, ",") { - t.Fatalf("送了 %v,want %v(程式碼旁邊的 .md 不該收)", got, want) + t.Fatalf("送了 %v,want %v(`src/` 因為自己直接放著 .go 仍不收;`internal/` "+ + "自己直接內容零程式碼,Phase 0 判準下該收)", got, want) } } @@ -571,7 +582,11 @@ func TestPlanIngest_三種收法在新判準下都還正確(t *testing.T) { wantGot: []string{"wiki/INDEX.md", "wiki/status.md"}, }, { - // ③ 軟體專案、沒有現成 wiki ⇒ 只收文件區+根層說明檔,跳過原始碼 + // ③ 軟體專案、沒有現成 wiki ⇒ 只收文件區+根層說明檔+跳過原始碼, + // 但 Phase 0(`arcrun-rag#136`)之後也收「非標準命名、自己直接內容零程式碼」 + // 的資料夾——`雜/` 自己只放了一份 `隨手記.md`,沒有任何程式碼副檔名, + // 即使名字不叫 docs 也算文件目錄。這不是誤放的迴歸,見同檔 + // TestPlanIngest_RepoWithoutWikiReadsDocsOnly 上方的完整說明。 name: "沒wiki的專案只收文件跳過源碼", files: func() map[string]string { f := merge(codeProjectFiles("", ".go", "package main")) @@ -580,7 +595,7 @@ func TestPlanIngest_三種收法在新判準下都還正確(t *testing.T) { return f }(), wantMode: IngestDocsOnly, - wantGot: []string{"README.md", "docs/請假規則.md"}, + wantGot: []string{"README.md", "docs/請假規則.md", "雜/隨手記.md"}, }, } { t.Run(tc.name, func(t *testing.T) { @@ -671,6 +686,150 @@ func TestPlanIngest_判成專案時要講得出憑什麼(t *testing.T) { } } +// 🔴 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("策略=%s,want %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("送出 %v,want %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) + } + } +} + // 🔴 接線:**模式選擇的判準只有一個地方**。 // // 這一票的同款形狀出現過四次(能力做好了,卻不在會被執行的那條路上)。