diff --git a/CHANGELOG.md b/CHANGELOG.md index 44fb37a..47d36b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,17 @@ --- +## 下一版(未發佈) + +- 🔴 **小幫手不再把自己整理出來的筆記當成新資料再整理一次**:以前它整理完一份文件、把筆記寫進資料夾之後,下一輪掃描又會把那些筆記當成「新來的檔案」再整理一遍,整理出來的東西又變成下一輪的材料——一輪接一輪停不下來。你會看到資料夾裡冒出越來越多名字奇怪的檔(像是同一個詞被括號包起來重複一次),每一輪都白白用掉一次當天的整理額度,知識庫裡也被塞進一堆「筆記的筆記」。現在它認得出哪些檔是自己寫的,一律跳過。 +- **已經被繁殖出來的那些檔要自己刪**:這次的修正只保證不再繼續長,**不會回頭清掉先前多出來的檔**——那些檔跟你自己的東西混在同一個資料夾裡,我們寧可不替你決定刪哪一個。名字裡有重複括號的(例如「換柱(換柱)」)就是這次多出來的,可以直接刪掉。 +- 🔴 **裝過開發範本的資料夾,現在分得出「空白樣板」與「你真的寫過的東西」**:以前只要路徑在 `system-dev/` 底下就整批不收,於是同一個資料夾裡**你自己的筆記與現成的知識卡也一起被丟掉**(實測:一個桌面資料夾底下有 9 張整理好的卡,一張都沒上去)。現在改成逐個檔比對——**內容和安裝時鋪下去的原版一模一樣**(你還沒動過)才不收;你寫過一個字、或那個檔根本不是範本鋪的,就是你的知識,照收。 +- **這個分辨不看資料夾叫什麼名字**:換個名字裝、裝在別的位置,答案都一樣;反過來,一個剛好也叫 `system-dev` 但裡面是你自己東西的資料夾,不會再被誤殺。 +- 🔴 **資料夾樹現在真的是「所有的資料夾」**:以前有幾種資料夾我們決定不收,而「不收」的做法是**連走都不走進去**——結果是那個資料夾底下有哪幾層、哪些子資料夾,你在小幫手與知識庫網頁上**一個都看不到**,樹到那一節就斷了。(實測:桌面上一個資料夾實際有 12 層資料夾,畫面上只有 3 個。)現在每一層都會列出來,跟你在檔案總管裡看到的一樣。 +- **不收的那一層,照樣告訴你「底下有幾個檔、我收了幾個、為什麼沒收」**:以前這種資料夾要嘛不存在,要嘛顯示一個我們自己編的 `0 / 0`。現在數字是真的(例如「0 / 9」),旁邊寫著原因(例如「這是開發範本鋪下來的檔,不是你寫的東西」)——你先看得到那裡有東西,才輪得到決定要不要收它。 +- **收檔範圍一個字都沒有放寬**:這一版只改「看得到什麼」,沒有改「收什麼」。原本不收的檔案現在仍然不收。 +- 🔴 **把整理好的卡片放在資料夾裡,不會再被小幫手自己攪亂**:如果你把整理過的知識卡放在 `system-dev/wiki/cards/` 這種位置,小幫手替它們整理出來的新卡片會**被舊版的「卡片歸位」動作硬拖出來、改成別的檔名**——接著它連自己一秒前寫的卡都找不到,那批文件於是一份都上不去;被拖出來的卡又被當成你的新文件再整理一次,檔案愈長愈多(`某某(某某)`、`某某(某某)(某某)`…)。(實測:一個資料夾裡因此多出 70 個沒人要的檔案,9 份現成的卡一份也沒進到雲端。)現在那批卡片放在哪裡就留在哪裡。 + ## 0.18.42(2026-08-27) - 🔴 **問 AI「這份資料的原文在哪」,現在答得出你走得到的位置**:以前它會回一個像 `../某某檔.pdf` 的東西——那是程式自己擺放檔案的相對位置,你拿著它打不開任何檔案;原稿如果放在子資料夾裡,連是哪一層都看不出來。現在每張卡的「出處」寫的是**哪一台電腦 › 哪一個知識庫 › 庫內哪一個路徑**(例如 `youlinhsieh@Leo-MBA › youlinhsieh-test1 › 會議紀錄/2026/某某.md`),照著三格走就找得到那個檔。 diff --git a/cardloop_test.go b/cardloop_test.go new file mode 100644 index 0000000..00d32fd --- /dev/null +++ b/cardloop_test.go @@ -0,0 +1,118 @@ +// cardloop_test.go — 「卡片自我繁殖」迴歸網(inkstone/Arcrun#134 → comment 5025)。 +// +// 🔴 2026-08-28 在 leo 的 `~/Desktop/youlinhsieh-test1` 實測到的現象: +// 一份 20 KB 的劇本原稿,`.wiki/` 只落了 4 張卡;而 `system-dev/wiki/cards/` 底下 +// 卻在 20 分鐘內從 9 個檔長到 55 個,出現 `arcrun-換柱(arcrun-換柱).md` +// 這種第三代檔名。那不是萃取品質問題,是**產物被自己的整理程序拖回原稿區**: +// +// ① 萃取寫 `/.wiki/換柱.md`(隱藏目錄,Scan 整棵跳過 ⇒ 不會被當原稿) +// ② MigrateCardNames 每輪自動跑,遞迴掃 `system-dev/wiki/cards/`—— +// **它沒有跳過隱藏目錄**,於是把 `cards/.wiki/換柱.md` +// 「歸位」成 `cards/arcrun-換柱.md` +// ③ 那個位置 Scan 看得見 ⇒ 下一輪當成新原稿再萃一次 ⇒ 回到 ① +// +// 每一圈都燒一次 Workers AI 額度、往雲端推一批「卡片的卡片」,而且不會停。 +// +// 四支測試各釘住迴圈的一個環節(tidy 自動路/tidy 手動路/收檔判準/claude 路), +// 缺一個就會復發。 +package collector + +import ( + "os" + "path/filepath" + "testing" +) + +// TestMigrateCardNamesLeavesWikiDirAlone:`.wiki/` 是卡片的家,不是「位置不對的舊卡」。 +// MigrateCardNames 一個檔都不准動它——動了就等於把產物送回原稿區(迴圈的第②環)。 +func TestMigrateCardNamesLeavesWikiDirAlone(t *testing.T) { + root := t.TempDir() + + // 卡片產物區底下的 `.wiki/`:萃取正常的落點。 + wikiDir := filepath.Join(root, filepath.FromSlash(cardsRelDir), wikiRelDir) + if err := os.MkdirAll(wikiDir, 0o755); err != nil { + t.Fatal(err) + } + card := filepath.Join(wikiDir, "換柱.md") + if err := os.WriteFile(card, []byte("# 換柱\n"), 0o644); err != nil { + t.Fatal(err) + } + + mig := MigrateCardNames(root) + + if _, err := os.Stat(card); err != nil { + t.Fatalf("`.wiki/` 裡的卡被搬走了(MigrateCardNames 回報 Moved=%d)——"+ + "它下一輪就會被當成新原稿再萃一次,迴圈就是這樣長出來的", mig.Moved) + } + dragged := filepath.Join(root, filepath.FromSlash(cardsRelDir), MarkName("換柱.md")) + if _, err := os.Stat(dragged); err == nil { + t.Fatalf("卡片被拖回原稿區:%s", dragged) + } + if mig.Moved != 0 { + t.Fatalf("Moved = %d,期望 0(`.wiki/` 不該有任何東西被搬)", mig.Moved) + } +} + +// TestTidyLeavesWikiDirAlone:手動 `collector tidy --apply` 走的是另一條路,同樣不准碰。 +func TestTidyLeavesWikiDirAlone(t *testing.T) { + root := t.TempDir() + wikiDir := filepath.Join(root, filepath.FromSlash(cardsRelDir), wikiRelDir) + if err := os.MkdirAll(wikiDir, 0o755); err != nil { + t.Fatal(err) + } + card := filepath.Join(wikiDir, "官架子.md") + if err := os.WriteFile(card, []byte("# 官架子\n"), 0o644); err != nil { + t.Fatal(err) + } + + rep, err := Tidy(root, true) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(card); err != nil { + t.Fatalf("tidy --apply 搬走了 `.wiki/` 裡的卡;報告列了 %d 項", len(rep.Items)) + } + for _, it := range rep.Items { + if it.Kind == TidyKindCard && filepath.Base(filepath.Dir(it.Rel)) == wikiRelDir { + t.Fatalf("tidy 把 `.wiki/` 裡的卡列成待整理項:%s → %s", it.Rel, it.To) + } + } +} + +// TestMachineOwnedCardsAreNotIngestedAsSource:curated-wiki 模式收的是「他自己寫的 wiki」, +// 但 `system-dev/wiki/cards/arcrun-*.md` 是 **daemon 上一版親手產的卡** +// (檔名帶 MachineMark 就是它的身分證)。把自己的產物當原稿再萃一次 +// =迴圈的第③環,也是這次「卡片的卡片」的第一代來源。 +func TestMachineOwnedCardsAreNotIngestedAsSource(t *testing.T) { + plan := IngestPlan{Mode: IngestCuratedWiki, WikiRelDir: "system-dev/wiki"} + + userCard := "system-dev/wiki/cards/我自己寫的.md" + if !plan.KeepsFile(userCard) { + t.Fatalf("使用者自己寫的卡被排除了:%s(curated-wiki 要收的就是這個)", userCard) + } + + machineCard := "system-dev/wiki/cards/" + MarkName("換柱.md") + if plan.KeepsFile(machineCard) { + t.Fatalf("daemon 自己產的卡被當成原稿收進去:%s——"+ + "它會被再萃一次,產出「卡片的卡片」", machineCard) + } +} + +// TestSnapshotCardsIgnoresWikiDir:claude 路的同一個洞。 +// snapshotCards → diffCards → enforceCardMarks 會把「這一輪新出現的卡」歸位到 +// cardRelFor(搬出 `.wiki/`、加前綴)。`.wiki/` 不進快照,就沒有東西可以被搬。 +func TestSnapshotCardsIgnoresWikiDir(t *testing.T) { + root := t.TempDir() + wikiDir := filepath.Join(root, filepath.FromSlash(cardsRelDir), wikiRelDir) + if err := os.MkdirAll(wikiDir, 0o755); err != nil { + t.Fatal(err) + } + before := snapshotCards(root) + if err := os.WriteFile(filepath.Join(wikiDir, "Pixel手機.md"), []byte("# Pixel手機\n"), 0o644); err != nil { + t.Fatal(err) + } + changed := diffCards(before, snapshotCards(root)) + if len(changed) != 0 { + t.Fatalf("`.wiki/` 裡的新卡進了快照差集:%v——enforceCardMarks 會把它搬出隱藏目錄", changed) + } +} diff --git a/cardloop_vs_existing_wiki_test.go b/cardloop_vs_existing_wiki_test.go new file mode 100644 index 0000000..b0a1b11 --- /dev/null +++ b/cardloop_vs_existing_wiki_test.go @@ -0,0 +1,68 @@ +// cardloop_vs_existing_wiki_test.go — `#134`(別把自己的卡當原稿)與 `#180`(現成的卡要收) +// 在同一個路徑上要求相反答案,這一支把**分界線本身**釘住。 +// +// 🔴 這不是普通的迴歸網,是一條**有爭議的分界**:兩張票的驗收條件互斥—— +// +// #134 cardloop_test.go `system-dev/wiki/cards/arcrun-換柱.md`        **不准收** +// #180 template_not_knowledge_test.go `system-dev/wiki/cards/arcrun-火星座標_短片劇本_v1.md` **必須收** +// +// 同一個目錄、同一個前綴 ⇒ **靠名字分不開**。現行分界是「**模式**」: +// curated-wiki(接一個開發 repo,`cardsRelDir` 與他的 wiki 結構性重疊)擋; +// 其餘(一般資料夾/筆記庫)收。理由與出處寫在 `ingestplan.go` 的 KeepsFile。 +// +// ⚠️ 這條分界是收工方的假設,**leo 還沒裁**。要改分界,連這支一起改, +// 並在 `inkstone/Arcrun#180` 留言說明改成什麼、為什麼——不要只改 code。 +package collector + +import "testing" + +func TestKeepsFile_機器產的卡在哪一種模式下才擋(t *testing.T) { + const machineCard = "system-dev/wiki/cards/" + MachineMark + "火星座標_短片劇本_v1.md" + const userCard = "system-dev/wiki/cards/我自己寫的.md" + const liveProduct = "system-dev/wiki/cards/.wiki/換柱.md" + + // ① 接開發 repo(curated-wiki):舊版 daemon 寫在他 wiki 裡的卡不當原稿(#134) + repo := IngestPlan{Mode: IngestCuratedWiki, WikiRelDir: "system-dev/wiki"} + if repo.KeepsFile(machineCard) { + t.Errorf("curated-wiki 仍該擋 %s(#134 第③環)", machineCard) + } + if !repo.KeepsFile(userCard) { + t.Errorf("使用者自己寫的卡被擋了:%s", userCard) + } + + // ② 一般資料夾/筆記庫:leo 的 youlinhsieh-test1 就是這一種,那 9 張卡要收(#180) + for _, m := range []IngestMode{IngestAll, ""} { + plain := IngestPlan{Mode: m} + if !plain.KeepsFile(machineCard) { + t.Errorf("mode=%q 沒收 %s——#180 驗收第 3 條(那 9 張卡的內容要進得了雲端)就不成立", + string(m), machineCard) + } + } + + // ②之二 `arcrun-` 是人也會用的命名習慣:**卡片產物區以外的檔一律照收**。 + // 實測 leo 的 youlinhsieh-test1 根層就有三個他自己寫的(其中一個檔名裡寫著「請勿刪」), + // 只看前綴的話它們會被排進「下架」——不是不收而已,是把雲端已經有的撤掉。 + // (curated-wiki 只讀那份 wiki,所以根層的檔本來就不收——那是模式的範圍, + // 不是前綴造成的;這裡分別用兩種模式各檢一格,才不會把兩件事混在一起。) + plainAll := IngestPlan{Mode: IngestAll} + for _, rel := range []string{ + MachineMark + "複驗用-請勿刪-20260827c.md", + MachineMark + "1457驗收-20260827.md", + } { + if !plainAll.KeepsFile(rel) { + t.Errorf("mode=all 把使用者自己命名的根層檔擋掉了:%s", rel) + } + } + if !repo.KeepsFile("system-dev/wiki/" + MachineMark + "我放在wiki根的筆記.md") { + t.Error("curated-wiki 把卡片產物區**以外**、帶前綴的檔擋掉了") + } + + // ③ 不管哪一種模式,**這一版正在維護的產物**(住在 `.wiki/`)永遠不是原稿。 + // 這一條才是關掉「卡片的卡片」迴圈的那道閘,前綴不是。 + for _, m := range []IngestMode{IngestCuratedWiki, IngestDocsOnly, IngestAll, ""} { + p := IngestPlan{Mode: m, WikiRelDir: "system-dev/wiki"} + if p.KeepsFile(liveProduct) { + t.Errorf("mode=%q 把 `.wiki/` 裡的產物當原稿收了:%s", string(m), liveProduct) + } + } +} diff --git a/cmd/arcrun-app/frontend/src/main.js b/cmd/arcrun-app/frontend/src/main.js index e82536b..8a172d1 100644 --- a/cmd/arcrun-app/frontend/src/main.js +++ b/cmd/arcrun-app/frontend/src/main.js @@ -331,7 +331,12 @@ function treeBoxId(path) { // 子樹合計:小幫手送的每個數字都只算「這一層直接放的檔」(存兩套遲早對不起來), // 所以要顯示「這個資料夾底下總共」就得在這裡疊一次。 -// 🔴 skipped 的節點整棵不計:沒走進去就是不知道裡面有幾個檔,加 0 會讓分母說謊。 +// 🔴 **沒走進去**的節點整棵不計:不知道裡面有幾個檔,加 0 會讓分母說謊。 +// 分辨方法(inkstone/Arcrun#180 之後):`skipped` 已經改成「這一層的檔不收」, +// 而**走進去過的節點 total_files 是真的**——所以只有 `total_files === 0` 的 +// skipped 節點才是「真的沒走進去」,那種才不計。 +// 走進去過的(例如 `system-dev/wiki` 6 個範本檔全排除)要照算, +// 不然 leo 要的「知道資料夾下有幾個檔案被萃」在合計上又變回 0。 // (與 portal 的 rollupTree 同一套算法——兩邊看到的數字必須是同一個。) function rollupTree(nodes) { const byPath = {}, kids = {}; @@ -339,7 +344,7 @@ function rollupTree(nodes) { const sums = {}; function walk(n) { const acc = { total: 0, synced: 0, pending: 0, unsupported: 0, excluded: 0 }; - if (!n.skipped) { + if (!n.skipped || n.total_files > 0) { acc.total = n.total_files; acc.synced = n.synced_files; acc.pending = n.pending_files; acc.unsupported = n.unsupported_files; acc.excluded = n.excluded_files; } @@ -416,11 +421,17 @@ function renderFolderTree(path) { + `${kids.length ? '' : ''}` + `${kids.length && open ? '📂' : '📁'}` + `${esc(n.name || '(未命名)')}`; - if (n.skipped) { + if (n.skipped && n.total_files === 0) { // 🔴 整棵沒走進去 ⇒ 不准顯示 0/0(那會是我們自己編的數字),改講理由。 const why = n.skip_reason || '(小幫手沒說明理由)'; row += `整個資料夾未收` + `${esc(why)}`; + } else if (n.skipped) { + // #180:走進去了、數字是真的,只是這一層一個檔都沒收 + // ⇒ **數字照顯示**(他要知道底下有幾個),理由也照講。 + const why = n.skip_reason || '(小幫手沒說明理由)'; + row += `${s.synced} / ${s.total}` + + `${esc(why)}`; } else { const full = s.total > 0 && s.synced === s.total; row += `${s.synced} / ${s.total}`; diff --git a/extract.go b/extract.go index 1afa711..cd93dfd 100644 --- a/extract.go +++ b/extract.go @@ -123,7 +123,22 @@ func snapshotCards(absRoot string) map[string]fileState { for _, relDir := range []string{cardsRelDir, vaultCardsRelDir} { base := filepath.Join(absRoot, filepath.FromSlash(relDir)) _ = filepath.Walk(base, func(p string, info os.FileInfo, err error) error { - if err != nil || info.IsDir() || filepath.Ext(p) != ".md" { + if err != nil { + return nil + } + // 🔴 inkstone/Arcrun#134(2026-08-28):`.wiki/` 整棵跳過。 + // 那是規範定的卡片正式落點,不是「位置不對的卡」——而 enforceCardMarks + // 會把這裡掃到的**新**檔搬去 cardRelFor(搬出隱藏目錄、加 arcrun- 前綴), + // 也就是把產物送回 Scan 看得見的地方,下一輪被當新原稿再萃一次。 + // 829da02 的 `before[rel]` 只擋得住「本來就在的檔」,擋不住這一輪剛寫的卡。 + // 與 tidy.go collectCardItems 同一個洞,兩支都補才堵得住。 + if info.IsDir() { + if p != base && strings.HasPrefix(info.Name(), ".") { + return filepath.SkipDir + } + return nil + } + if filepath.Ext(p) != ".md" { return nil } rel, rerr := filepath.Rel(absRoot, p) diff --git a/extract_workersai.go b/extract_workersai.go index a161fed..0eeb676 100644 --- a/extract_workersai.go +++ b/extract_workersai.go @@ -63,7 +63,8 @@ var workersAIHTTP = &http.Client{Timeout: 90 * time.Second} // {"error":{"message":"This model's maximum context length is 131000 tokens… // // ⇒ 兩個錯:①明知一定會失敗還是送出去(每次燒一份額度、拖住整個佇列) -// ②失敗的理由沒有翻成人話(#104 的紅線:不要讓他猜)。 +// +// ②失敗的理由沒有翻成人話(#104 的紅線:不要讓他猜)。 // // 300,000 這個數字怎麼來的:實測那份 401,526 位元組的檔,上游回報 // 「prompt contains at least 122,xxx tokens」,而可用輸入是 @@ -201,6 +202,17 @@ func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string, origin Sou // vault 落隱藏目錄,且**檔名一律帶 arcrun- 前綴**(第二輪:光換目錄擋不住撞名, // 因為 Logseq 的頁名是 basename)。落地前先查目標存不存在、不無條件覆蓋(safeWriteCard)。 cardRel := cardRelFor(absRoot, pageName) + // 🔴 inkstone/Arcrun#180(2026-08-28):**不准把萃取結果寫回原稿自己身上。** + // 舊制的落點是 `system-dev/wiki/cards/arcrun-<頁名>.md`,而 `MarkName` 是冪等的 + // ⇒ 原稿本來就住在那裡、名字本來就帶前綴時(leo 的 9 張現成卡正是這一種), + // 算出來的目的地**就是原稿自己**。真寫下去=原稿被自己的摘要蓋掉(safeWriteCard + // 會留一份 `.bak-*`,但下一輪內容又變了 ⇒ 每輪重萃、每輪燒一次額度)。 + // 這條路只在**舊雲端**(回 `card` 而不是 `output`)才走得到,所以平常撞不到; + // #180 讓 `system-dev/` 收得到之後就撞得到了。誠實報錯,不要靜默略過。 + if cardRel == filepath.ToSlash(relPath) { + return nil, fmt.Errorf("這份檔案本身就在卡片產物區、而且已經帶著我們的標記(%s)"+ + "——整理它會蓋掉它自己。請把它移到別的資料夾,或更新你的知識庫到新版", relPath) + } dest := filepath.Join(absRoot, filepath.FromSlash(cardRel)) if err := safeWriteCard(absRoot, dest, []byte(card)); err != nil { return nil, err diff --git a/foldertree.go b/foldertree.go index db65e25..545ebc5 100644 --- a/foldertree.go +++ b/foldertree.go @@ -58,7 +58,22 @@ const ( // // 🔴 **超過不准安靜地截掉**——`FolderTree.Truncated` 會被送上去、由畫面講出來 //(同 #104「排除規則要看得見」那條紅線:安靜地少講與講一個 0,對使用者是同一件事)。 - MaxFolderTreeNodes = 300 + // + // 🔴 2026-08-28(inkstone/Arcrun#180)從 300 提到 2000。leo 的規格是 + // 「**雲端看到所有的 folder**」,而剪枝拿掉之後節點會變多——300 會讓「看得到全部」 + // 在大資料夾上靜默失效(`Truncated` 雖然誠實,但那是在說「我做不到你要的事」)。 + // + // **實測(本次改動後,同一台機器)**: + // + // youlinhsieh-test1 12 個節點(地端 `find -type d` 非隱藏 12) + // youlinhsieh-test2 13(地端 13) + // pms 53(地端 360——差額是 node_modules/dist/.gitignore 宣告的, + // 那幾種仍然剪枝,但節點與理由都在) + // InkStoneCo 62(地端 14,923,同上) + // + // ⇒ 真實資料夾離 300 都還很遠;2000 是留給「一個沒有 .gitignore 的大筆記庫」的餘裕。 + // 一棵樹在收端是一把 KV、整棵覆寫,2000 個節點約 400 KB,離 KV 的 25 MB 很遠。 + MaxFolderTreeNodes = 2000 // folderTreeRetryDelay:整棵樹送失敗後,同一份內容多久才准再試。 // 理由同 inventory.go:積壓時每輪都有事件,沒有退避就是每 5 秒撞一次(t195 教訓)。 @@ -106,8 +121,18 @@ type FolderNode struct { // ExcludedFiles=收檔策略決定不收的(leo 講的「程式碼」多半落在這裡,見 ingestplan.go)。 ExcludedFiles int `json:"excluded_files"` - // Skipped=這整棵子樹被剪掉了(沒有走進去),所以上面的數字**全部是 0 而且不是事實**。 - // 🔴 畫面看到 Skipped 必須改講 SkipReason,不准顯示 `0/0`——那會是我們自己編的數字。 + // Skipped=**這一層的檔案這次一個都不收**。SkipReason 說得出為什麼。 + // + // 🔴 2026-08-28(inkstone/Arcrun#180)語意收窄了一格,因為 leo 推翻了原本的做法: + // 以前 Skipped 同時代表「整棵沒走進去」⇒ 數字全是 0 而且不是事實 ⇒ **底下一個 + // 子節點都不會生**。leo 的原話:「**所有的 system-dev 都可以展開,因為就算沒有 + // 可萃的它也有下層**」「我要的就是雲端看到**所有的 folder** 像 tree 一樣呈現」。 + // ⇒ 現在絕大多數 Skipped 節點是**走進去了、數字是真的、子節點也都在**, + // 只是這一層的檔不收(範本檔、非本次收檔範圍…)。 + // + // 仍然有數字是 0 而且不是事實的那一種——真的沒走進去的(`node_modules` 這類, + // 見 IngestPlan.SkipsDirWhy 剩下的五條)。**分辨方法:那種節點沒有子節點。** + // 畫面看到 Skipped 一律改講 SkipReason,不要只顯示分子分母。 Skipped bool `json:"skipped,omitempty"` // SkipReason=一句話講給使用者聽的「為什麼整個沒收」(原文來自 IngestPlan.SkipsDirWhy)。 SkipReason string `json:"skip_reason,omitempty"` @@ -162,6 +187,14 @@ func BuildFolderTree(absRoot, library string, dirs map[string]*dirStat, entries n.TotalFiles = st.total n.UnsupportedFiles = st.unsupported n.ExcludedFiles = st.excluded + // #180:這一層走進去了、數字是真的,但**一個檔都沒收** ⇒ 照樣要講得出為什麼。 + // 以前這種節點根本不存在(整棵被剪掉),現在存在了,就不能只給一個沒有解釋的 + // `0 / 6`——那對使用者跟「安靜地少收」是同一件事。 + // 🔴 子節點與真實數字都留著(leo 2026-08-28:「跳過的節點仍要展得開」)。 + if st.total > 0 && st.excluded == st.total && st.excludeWhy != "" { + n.Skipped = true + n.SkipReason = st.excludeWhy + } } // 分子:manifest 現況。已送達且送上去之後沒再改過=已同步,其餘=還在路上。 diff --git a/foldertree_live_test.go b/foldertree_live_test.go new file mode 100644 index 0000000..b1caa1d --- /dev/null +++ b/foldertree_live_test.go @@ -0,0 +1,116 @@ +package collector + +// foldertree_live_test.go — 對**真實的監看資料夾**跑一次樹,用 leo 給的機械判準對帳 +//(`inkstone/Arcrun#180`,leo 2026-08-28: +// 「就在桌面,你為什麼沒看?最簡單的方式就像 terminal tree 一樣把被同步資料夾下的 +// 所有 folder 都遍歷後印出來」)。 +// +// 🔴 為什麼 fixture 不夠(同 sourcerepair_live_test.go 的理由): +// `foldertree_traversal_test.go` 用的是我們自己擺出來的形狀——它證明得了邏輯, +// 證明不了「**leo 桌面上那個資料夾**現在到底展不展得開」。 +// 這張票的現象是他螢幕上的數字,對帳就要拿他螢幕上那個資料夾來對。 +// +// 只讀磁碟、不碰網路、不寫 manifest,所以沒有「打錯實例」的風險; +// 仍然預設 skip,因為路徑是機器專屬的。 +// +// LIVE_TREE_ROOTS="/Users/x/Desktop/youlinhsieh-test1,/Users/x/Desktop/youlinhsieh-test2" \ +// go test -run TestLiveFolderTreeMatchesDisk -v ./ +// +// 判準(leo 原話):**`find -type d` 數出幾個目錄,雲端的樹就該有幾個節點** +//(收不收是另一回事,`skip_reason` 照標)。 +// 例外只有一種且必須說得出口:**真的沒走進去**的那幾類(gitignore/工具產物/ +// 建置目錄/worktree/巢狀 repo)——它們仍然佔一個節點,但底下不生子節點, +// 所以本測試把它們的子樹從地端這一側也扣掉,兩邊才是同一個問題的兩個答案。 + +import ( + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" +) + +// diskDirsExcludingPrunedSubtrees=地端目錄清單,但「真的沒走進去」的那幾類只算它自己。 +func diskDirsExcludingPrunedSubtrees(t *testing.T, root string, plan IngestPlan) []string { + t.Helper() + var out []string + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil || !d.IsDir() { + return err //nolint:nilerr // 讀不到的目錄交給 WalkDir 自己回報 + } + if p == root { + out = append(out, "") + return nil + } + if strings.HasPrefix(d.Name(), ".") { + return filepath.SkipDir + } + rel, _ := filepath.Rel(root, p) + rel = filepath.ToSlash(rel) + out = append(out, rel) + if skip, _ := plan.SkipsDirWhy(rel, p); skip { + return filepath.SkipDir // 節點留著(上一行已經 append),子樹不算 + } + return nil + }) + if err != nil { + t.Fatal(err) + } + sort.Strings(out) + return out +} + +func TestLiveFolderTreeMatchesDisk(t *testing.T) { + raw := strings.TrimSpace(os.Getenv("LIVE_TREE_ROOTS")) + if raw == "" { + t.Skip("設 LIVE_TREE_ROOTS=<資料夾,資料夾> 才跑(路徑是機器專屬的)") + } + for _, root := range strings.Split(raw, ",") { + root = strings.TrimSpace(root) + if root == "" { + continue + } + t.Run(filepath.Base(root), func(t *testing.T) { + plan := PlanIngest(root) + m := &Manifest{Entries: map[string]*ManifestEntry{}} + p, err := Scan(root, m, ScanOptions{Plan: plan}) + if err != nil { + t.Fatal(err) + } + tree := BuildFolderTree(root, "live", p.DirStats, m.Entries, p.AllExcludedDirs, plan, time.Now()) + + onTree := map[string]FolderNode{} + for _, n := range tree.Nodes { + onTree[n.Path] = n + } + want := diskDirsExcludingPrunedSubtrees(t, root, plan) + + t.Logf("收檔策略:%s — %s", plan.Mode, plan.Reason) + t.Logf("地端目錄 %d(扣掉沒走進去的子樹)/樹上節點 %d/truncated=%v", + len(want), tree.TotalNodes, tree.Truncated) + for _, n := range tree.Nodes { + t.Logf(" %-46s tot=%-4d sync=%-4d excl=%-4d skipped=%v %s", + "'"+n.Path+"'", n.TotalFiles, n.SyncedFiles, n.ExcludedFiles, n.Skipped, n.SkipReason) + } + + var missing []string + for _, w := range want { + if _, ok := onTree[w]; !ok { + missing = append(missing, w) + } + } + if len(missing) > 0 { + t.Fatalf("地端有、樹上沒有的資料夾(「不收」又被實作成「不走進去」了):%v", missing) + } + if tree.TotalNodes != len(want) { + t.Fatalf("樹上節點 %d ≠ 地端資料夾 %d", tree.TotalNodes, len(want)) + } + if tree.Truncated { + t.Fatalf("被截斷了——MaxFolderTreeNodes=%d 不夠這個資料夾用,不准靜默截斷", + MaxFolderTreeNodes) + } + }) + } +} diff --git a/foldertree_traversal_test.go b/foldertree_traversal_test.go new file mode 100644 index 0000000..1843943 --- /dev/null +++ b/foldertree_traversal_test.go @@ -0,0 +1,169 @@ +// foldertree_traversal_test.go — 「雲端的樹要看得到所有的 folder」的迴歸網 +// (inkstone/Arcrun#180,leo 2026-08-28)。 +// +// leo 給的判準是機械的,本檔就照它寫: +// +// find -type d | wc -l == 樹的 total_nodes +// (實測差距:youlinhsieh-test1 地端 12 個目錄,雲端的樹只有 3 個節點) +// +// 🔴 這一組釘死的是「**展開 ≠ 收檔**」:收不收是另一個判斷, +// 不准再用「不走進去」來實作「不收」——那會讓使用者連自己有哪些子資料夾都看不到。 +package collector + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// countLocalDirs=`find -type d -not -path '*/.*'` 的 Go 版(含根自己)。 +func countLocalDirs(t *testing.T, root string) int { + t.Helper() + n := 0 + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil || !d.IsDir() { + return err + } + if p != root && strings.HasPrefix(d.Name(), ".") { + return filepath.SkipDir + } + n++ + return nil + }) + if err != nil { + t.Fatal(err) + } + return n +} + +func treeOf(t *testing.T, root string) FolderTree { + t.Helper() + m := &Manifest{Entries: map[string]*ManifestEntry{}} + plan := PlanIngest(root) + p, err := Scan(root, m, ScanOptions{Plan: plan}) + if err != nil { + t.Fatal(err) + } + return BuildFolderTree(root, "lib", p.DirStats, m.Entries, p.AllExcludedDirs, plan, time.Now()) +} + +func write(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// 這棵樹照著 leo 貼的 `youlinhsieh-test1` 真實形狀擺: +// 一個「一般資料夾」(mode=all),底下一整棵 system-dev——以前整棵被剪掉。 +func fixtureLikeTest1(t *testing.T) string { + t.Helper() + root := t.TempDir() + // 範本鋪下去的那幾個,內容一律從內嵌快照原樣取——判準是「內容逐字相同」 + // (templateuntouched.go),自己捏一段假字串會被正確地判成「使用者寫的」。 + snap := func(rel string) string { + data, err := templateFS.ReadFile(templateFSRoot + "/" + rel) + if err != nil { + t.Fatalf("快照裡沒有 %s:%v", rel, err) + } + return string(data) + } + write(t, filepath.Join(root, "火星座標_短片劇本_v1.md"), "# 火星座標\n") + write(t, filepath.Join(root, "scripts", "sdd-active-check.sh"), snap("scripts/sdd-active-check.sh")) + write(t, filepath.Join(root, "system-dev", "VERSION"), snap("system-dev/VERSION")) + write(t, filepath.Join(root, "system-dev", "wiki", "status.md"), snap("system-dev/wiki/status.md")) + // 快照裡沒有的真內容——leo 要的那 9 張卡就是這一種 + write(t, filepath.Join(root, "system-dev", "wiki", "cards", "arcrun-火星座標_短片劇本_v1.md"), "# 卡\n") + write(t, filepath.Join(root, "system-dev", "docs", "3-specs", "TEMPLATE-sdd", "design.md"), snap("system-dev/docs/3-specs/TEMPLATE-sdd/design.md")) + write(t, filepath.Join(root, "system-dev", "workflows", "tasks-project-sync.yaml"), snap("system-dev/workflows/tasks-project-sync.yaml")) + return root +} + +func TestFolderTree_地端有幾個資料夾樹上就要有幾個節點(t *testing.T) { + root := fixtureLikeTest1(t) + want := countLocalDirs(t, root) // 根+scripts+system-dev+wiki+cards+docs+3-specs+TEMPLATE-sdd+workflows = 9 + tree := treeOf(t, root) + if tree.TotalNodes != want { + var got []string + for _, n := range tree.Nodes { + got = append(got, n.Path) + } + t.Fatalf("樹的節點數 %d ≠ 地端資料夾數 %d\n樹上有:%v", tree.TotalNodes, want, got) + } + if tree.Truncated { + t.Fatalf("這麼小的一棵樹不該被截斷") + } +} + +func TestFolderTree_不收的那一層仍然展得開而且說得出理由(t *testing.T) { + root := fixtureLikeTest1(t) + tree := treeOf(t, root) + byPath := map[string]FolderNode{} + for _, n := range tree.Nodes { + byPath[n.Path] = n + } + + // ① system-dev 底下每一層都在(以前整棵不存在) + for _, want := range []string{ + "system-dev", "system-dev/wiki", "system-dev/wiki/cards", + "system-dev/docs", "system-dev/docs/3-specs", + "system-dev/docs/3-specs/TEMPLATE-sdd", "system-dev/workflows", + } { + if _, ok := byPath[want]; !ok { + t.Fatalf("樹上少了 %q——「不收」又被實作成「不走進去」了", want) + } + } + + // ② 數字是真的:cards 底下實際有 1 個檔,不是我們編的 0; + // 而且它是真內容 ⇒ 要被收(leo:「cards 在 system-dev 下…所以不允許跳過它」) + if got := byPath["system-dev/wiki/cards"]; got.TotalFiles != 1 || got.ExcludedFiles != 0 { + t.Fatalf("cards:total=%d excluded=%d,期望 1/0(走進去了要數真的,而且真內容要收)", + got.TotalFiles, got.ExcludedFiles) + } + + // ③ 一個檔都沒收的那幾層(全是範本空殼),理由要講得出來 + for _, p := range []string{"system-dev/wiki", "scripts", "system-dev/workflows"} { + n := byPath[p] + if n.ExcludedFiles != n.TotalFiles || n.TotalFiles == 0 { + t.Fatalf("%s 期望「全部排除」,實際 total=%d excluded=%d", p, n.TotalFiles, n.ExcludedFiles) + } + if !n.Skipped || n.SkipReason == "" { + t.Fatalf("%s 一個檔都沒收,卻沒有講理由(skipped=%v reason=%q)", p, n.Skipped, n.SkipReason) + } + } + + // ④ 收檔範圍沒有跟著放寬——範本檔仍然不是知識 + if n := byPath[""]; n.SyncedFiles+n.PendingFiles == 0 { + t.Fatalf("根層那份真文件應該還是要收") + } +} + +// 剪枝只剩「走進去也沒有意義」的那幾種,而且仍然留節點+理由。 +func TestFolderTree_依賴目錄仍然剪枝但看得見(t *testing.T) { + root := t.TempDir() + write(t, filepath.Join(root, "筆記.md"), "# 筆記\n") + write(t, filepath.Join(root, "node_modules", "left-pad", "index.js"), "module.exports=1\n") + tree := treeOf(t, root) + + var nm *FolderNode + for i := range tree.Nodes { + if tree.Nodes[i].Path == "node_modules" { + nm = &tree.Nodes[i] + } + if strings.HasPrefix(tree.Nodes[i].Path, "node_modules/") { + t.Fatalf("不該走進 node_modules,卻生出了 %q", tree.Nodes[i].Path) + } + } + if nm == nil { + t.Fatal("node_modules 剪掉了就更該列出來——安靜地少收與講一個 0 是同一件事") + } + if !nm.Skipped || nm.SkipReason == "" { + t.Fatalf("node_modules 沒有理由:skipped=%v reason=%q", nm.Skipped, nm.SkipReason) + } +} diff --git a/ingestplan.go b/ingestplan.go index abf3b2f..0a50a09 100644 --- a/ingestplan.go +++ b/ingestplan.go @@ -160,15 +160,30 @@ var toolOwnedFileNames = map[string]bool{ "Gemfile.lock": true, "poetry.lock": true, "pnpm-workspace.yaml": true, } -// templateOwnedDirNames=system-dev-template 鋪出來的產物區,任何深度都不當原稿 -// (daemon-beta task 2,2026-08-06,原始出處 commit b4fee43)。 +// 🔴 這裡以前有第四張表:`templateOwnedDirNames = {"system-dev": true}` +// ——「目錄名等於 system-dev ⇒ 任何深度、所有模式,整棵剪掉」。 +// **inkstone/Arcrun#180(leo 2026-08-28)把它拿掉了,但不是放行,是換成分辨。** // -// 🔴 這一條以前是 direct.go 自己手捏的第二張表(`SkipDirNames{"system-dev": true}`), -// 而 #104 的排除清單在這裡——**兩張表分居兩處,於是 2026-08-16 讀源碼的人只看到其中一張, -// 把「清單根本沒接上」當成了真兇**(實際上兩張都有接上,見 direct.go 的 `Plan: plan`)。 -// 收成同一個地方,就不會再有第二張表可以漏看。 -// curated-wiki 模式要收的正是 `system-dev/wiki` ⇒ 那條路由 onPathTo 放行。 -var templateOwnedDirNames = map[string]bool{"system-dev": true} +// leo 的原話:「**cards 在 system-dev 下,wiki 就在裡面,所以不允許跳過它**, +// 而且**所有的 system-dev 都可以展開,因為就算沒有可萃的它也有下層**。」 +// +// 實據(`~/Desktop/youlinhsieh-test1`): +// +// system-dev/wiki/ ← 6 個範本空殼(Jul 28 同一秒鋪下來的) +// system-dev/wiki/cards/ ← 9 張真的卡(火星座標/Leo-Hsieh-填答/小果被AFTEE詐貸…) +// +// 一張名字表沒有辦法分辨這兩層——它只看得到「這個目錄叫 system-dev」, +// 於是把空殼和真內容一起丟掉,而且**連底下有哪些子資料夾都沒生出來** +// (地端 find 有 13 個目錄,雲端的樹只有 3 個節點)。 +// +// 換掉的是問題本身: +// +// 舊:這個目錄叫什麼名字? ⇒ 整棵剪掉 +// 新:這一個檔,和安裝器鋪下去的原版一不一樣? ⇒ 逐檔決定(templateuntouched.go) +// +// 空殼與原版逐字相同 ⇒ 照舊不收(紅線①:不准變成放行); +// 使用者寫過、或安裝器根本沒鋪過的檔(那 9 張卡)⇒ 收。 +// 而目錄一律照走 ⇒ 樹長得出來(紅線②:展開 ≠ 收檔)。 // projectManifestFiles=「有人在這一層跑建置工具」的佐證檔。 // @@ -463,13 +478,23 @@ func dirQualifiesAsAutoDoc(absDir string) bool { // SkipsDir 回答「走訪時要不要整棵跳過這個目錄」。relSlash 是相對監看根的路徑。 // -// 三類跳過,全模式適用: -// 1. noise(依賴/建置產物/範本)——名字判準,見 noiseDirNames -// 2. linked worktree——`.git` 是檔案。#104 實據裡的 `.claude/worktrees/…` 與 -// `products/arcrun-rag-wt60ship` 全是這一種:主 repo 的第二份簽出,收了就是重複 -// 3. 巢狀 repo(自己有 `.git` 的子目錄)——那是別的專案,見 otherWikiDirs 的說明 +// 🔴 **「不走進去」與「不收這裡的檔」是兩件事**(inkstone/Arcrun#180 紅線②, +// leo 2026-08-28:「所有的 system-dev 都可以展開,**因為就算沒有可萃的它也有下層**」)。 +// 這支只回答前者,而且**只剩「走進去也沒有意義」的那幾種**: // -// 再加上模式限定的:curated-wiki 只走那一份 wiki 的路;docs-only 只走文件目錄的路。 +// 1. 使用者自己宣告不要的(`.gitignore`) +// 2. 工具產生的(別人的套件、快取、建置產物)——名字本身就不是人話 +// 3. 泛用名的建置目錄(旁邊擺著產生它的專案檔才算) +// 4. linked worktree——同一個 repo 的第二份簽出,整棵是重複的 +// 5. 巢狀 repo——那是另一個獨立專案,要收請自己加進看守清單 +// +// 這五種每一種都是「裡面長什麼樣不必知道」,剪掉不會讓使用者少看到自己的東西 +// (而且 `node_modules` 這種走進去就是幾萬個節點,畫面反而看不了)。 +// +// **拿掉的是「收檔範圍」那幾條**:以前 curated-wiki 只走那份 wiki 的路、docs-only 只走 +// 文件區、範本目錄整棵剪掉——那三條講的是「要不要**收**」,卻長在剪枝上, +// 於是樹在那一節就斷了(實測:地端 13 個目錄,雲端的樹只有 3 個節點)。 +// 它們搬去 CollectsDirWhy,只影響收檔,不影響走訪。 // (隱藏目錄由 Scan 自己擋,那條規則比本檔更早存在,不搬過來。) func (p IngestPlan) SkipsDir(relSlash, absPath string) bool { skip, _ := p.SkipsDirWhy(relSlash, absPath) @@ -496,10 +521,6 @@ func (p IngestPlan) SkipsDirWhy(relSlash, absPath string) (bool, string) { if ambiguousBuildDirNames[name] && looksGenerated(absPath) { return true, "這是建置工具產生的目錄(旁邊就是產生它的專案檔)" } - // ④ template 鋪出來的產物區(任何深度)。curated-wiki 要收的那條路例外。 - if templateOwnedDirNames[name] && !(p.Mode == IngestCuratedWiki && onPathTo(relSlash, p.WikiRelDir)) { - return true, "這是開發範本鋪出來的目錄,不是你的知識" - } if IsLinkedWorktree(absPath) { return true, "這是同一個專案的第二份簽出(git worktree),內容與主資料夾重複" } @@ -507,21 +528,31 @@ func (p IngestPlan) SkipsDirWhy(relSlash, absPath string) (bool, string) { if IsRepoRoot(absPath) { return true, "這是另一個獨立的專案,要收請把它自己加進看守清單" } + return false, "" +} + +// CollectsDirWhy 回答「這一層的檔案,這次收不收」——**走訪照走,只影響收檔**。 +// +// 回 (false, 理由) = 這一層的檔不收,理由講給使用者聽;(true, "") = 照收檔規則走。 +// +// 這裡裝的正是從剪枝搬過來的那幾條收檔範圍:curated-wiki 只讀那份整理好的 wiki、 +// docs-only 只讀文件區。**它們是好的判斷,錯的只是它們以前長在剪枝上** +// ——把「不收」實作成「不走進去」,代價是使用者連自己有哪些子資料夾都看不到。 +func (p IngestPlan) CollectsDirWhy(relSlash string) (bool, string) { switch p.Mode { case IngestCuratedWiki: - // 只有「通往那份 wiki 的路」與「那份 wiki 底下」要走。 if !onPathTo(relSlash, p.WikiRelDir) { - return true, "這次只讀你整理好的 " + p.WikiRelDir + return false, "這次只讀你整理好的 " + p.WikiRelDir } case IngestDocsOnly: for _, d := range p.DocRelDirs { if onPathTo(relSlash, d) { - return false, "" + return true, "" } } - return true, "這是一個開發專案,這次只讀文件區,不讀程式碼" + return false, "這是一個開發專案,這次只讀文件區,不讀程式碼" } - return false, "" + return true, "" } // KeepsFile 回答「這個檔要不要收」。relSlash 是相對監看根的路徑。 @@ -539,6 +570,56 @@ func (p IngestPlan) KeepsFile(relSlash string) bool { if toolOwnedFileNames[filepath.Base(relSlash)] { return false } + // 🔴 inkstone/Arcrun#134(2026-08-28 實測):**我們自己產的卡不是原稿。** + // + // curated-wiki 模式收的是「他親手整理的 `system-dev/wiki/`」,而那個目錄底下 + // 同時住著 daemon 上一版寫進去的卡(`cards/arcrun-*.md`,帶 MachineMark)。 + // 少了這一問,daemon 會把自己 8/4 產的卡當成新原稿再萃一次,得到「卡片的卡片」 + // ——實測 `youlinhsieh-test1` 一輪長出 10 份第二代文件卡(`arcrun-換柱`、 + // `arcrun-官架子`…),而它們又會變成第三代的原稿。 + // + // 判準用 IsMachineOwnedRel 而不是「在不在某個目錄」:那支是規約的完整判準 + // (帶 MachineMark 前綴 ∪ 路徑上有 `.arcrun-rag/` 或 `.wiki/`), + // 而使用者自己寫的卡不帶前綴 ⇒ 照收不誤,curated-wiki 的規格(#104 第二層, + // 「那份 wiki 是他寫的,正是他要我們讀的」)不受影響。 + if InMachineOwnedDir(relSlash) { + return false + } + // 🔴 inkstone/Arcrun#180(2026-08-28)把上面那一問**收窄成只在 curated-wiki 生效**, + // 因為 #134 與 #180 的迴歸網在同一個路徑上要求相反的答案: + // + // #134 cardloop_test.go:`system-dev/wiki/cards/arcrun-換柱.md` **不准收** + // #180 template_not_knowledge_test.go:`system-dev/wiki/cards/arcrun-火星座標_短片劇本_v1.md` **必須收** + // + // 同一個目錄、同一個前綴 ⇒ **靠名字分不開**,實測 #151 併入後四種模式全部回 false, + // leo 的那 9 張卡一張都收不到(而他 08-14 的原話是「這些庫都早就萃好了⋯⋯直接 ingest」, + // #180 票上第 3 條驗收就是「那 9 張卡的內容進得了雲端」)。 + // + // 收窄的依據是 #134 自己寫下的理由——它整段講的是 curated-wiki: + // 「curated-wiki 模式收的是『他親手整理的 system-dev/wiki/』,而那個目錄底下 + // 同時住著 daemon 上一版寫進去的卡」。**接一個開發 repo 時那個重疊是結構性的** + //(`cardsRelDir` 就等於 `system-dev/wiki/cards`)⇒ 該擋。 + // 一般資料夾/筆記庫(mode=all)沒有那個結構性重疊,而 leo 對這一種明確表過態。 + // + // 🔴 **這一格是我的假設,不是 leo 的裁決**:兩張票的驗收互斥,需要他選一邊。 + // 假設寫在這裡與 PR/票上,錯了打回即可(`inkstone/Arcrun#180`)。 + // 迴圈安不安全:#134 三個環裡真正關掉迴圈的是 tidy/snapshotCards 那兩個 + //(產物只落 `.wiki/`,上面那一問就擋得住)。實測 4 輪 mode=all 收這 9 張卡, + // `system-dev/wiki/cards/` 從頭到尾維持 9 個檔、沒有第二代。 + // + // 🔴 第二格收窄:**再加一問「在不在卡片產物區」**。 + // `IsMarked` 只看 basename 前綴,而 `arcrun-` 是這個專案的人也會用的命名習慣—— + // 實測 leo 的 `youlinhsieh-test1` **根層**就有三個他自己寫的檔中招: + // `arcrun-1457驗收-20260827.md`/`arcrun-md-test.md`/ + // `arcrun-複驗用-請勿刪-20260827c.md`(檔名裡就寫著「請勿刪」) + // 對照組實跑(#151 原樣、dry-run):這三個連同那 9 張卡共 **12 個檔被排進「下架」** + // ——不是不收而已,是**把雲端已經有的內容撤掉**。 + // #134 要擋的是「daemon 寫在他 wiki 裡的卡」,那些卡一律住在卡片產物區 + //(`system-dev/wiki/cards/`/`.arcrun-rag/wiki/cards/`,`cardRelFor` 的落點), + // 所以多這一問不會放過任何一個它要擋的東西,卻救回使用者自己命名的檔。 + if p.Mode == IngestCuratedWiki && IsMarked(filepath.Base(relSlash)) && isUnderCardDir(relSlash) { + return false + } switch p.Mode { case IngestCuratedWiki: return strings.HasPrefix(relSlash, p.WikiRelDir+"/") diff --git a/machinemark.go b/machinemark.go index 3da9c2b..11e9ea3 100644 --- a/machinemark.go +++ b/machinemark.go @@ -83,9 +83,25 @@ func UnmarkName(base string) string { // ⚠️ 例外只有這兩個目錄。監看根底下**其他任何位置**的新檔,一律得帶前綴—— // 想在別處寫一個「名字不能改」的檔時,先想清楚那個檔為什麼不能住進這兩個目錄。 func IsMachineOwnedRel(relSlash string) bool { - if IsMarked(pathBase(relSlash)) { - return true - } + return IsMarked(pathBase(relSlash)) || InMachineOwnedDir(relSlash) +} + +// InMachineOwnedDir 只回答上面那兩條路的**第二條**:這個路徑上有沒有一層是 +// `.arcrun-rag/` 或 `.wiki/`。 +// +// 🔴 為什麼要把它單獨切出來(inkstone/Arcrun#180 ↔ #134,2026-08-28): +// 兩條路的**證據強度不一樣**,而收檔判準需要分得開它們—— +// +// 目錄(本函式)=**位置**證明它是產物。`.wiki/` 是這一版寫卡的唯一落點, +// 任何時候走到那裡的東西都是我們此刻正在維護的產物 ⇒ 鐵證。 +// 前綴(IsMarked)=**歷史**說某一版的 daemon 寫過它。它不保證「現在還是產物」: +// leo 的 `youlinhsieh-test1` 底下那 9 張 `cards/arcrun-*.md` +// 是 8/4 舊版寫的,而他 08-14 明講「這些庫都早就萃好了⋯⋯直接 ingest」 +// ——對他而言那就是他的知識。 +// +// ⇒ 要「絕不把產物當原稿」時用本函式;要「連歷史產物都不碰」時才用 IsMachineOwnedRel。 +// 規約本身(每個新檔都要帶標記)沒有變,vault_footprint_test.go 仍然用完整判準。 +func InMachineOwnedDir(relSlash string) bool { for _, seg := range strings.Split(relSlash, "/") { if seg == workspaceRelDir || seg == wikiRelDir { return true diff --git a/scan.go b/scan.go index 31707e2..cff9b4e 100644 --- a/scan.go +++ b/scan.go @@ -164,6 +164,10 @@ type dirStat struct { supported int // 通過所有閘、進了 manifest 的(=分子的候選) unsupported int // 副檔名還讀不了的(docLikeExt 與其他) excluded int // 收檔策略/範本身分決定不收的(程式碼多半落這裡) + // excludeWhy=這一層的檔被排除的理由(產品文案,第一個排除的檔說了算)。 + // #180:以前不收就是「不走進去」,理由掛在被剪掉的目錄上;現在走進去了, + // 理由得跟著檔案的排除一起記下來,不然畫面只剩一個沒有解釋的 `0/6`。 + excludeWhy string } // MaxExcludedDirsListed:最多逐筆列幾個被跳過的目錄。超過的只反映在 ExcludedDirCount @@ -360,10 +364,19 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { if p != root && opts.SkipDirNames[name] { return filepath.SkipDir // 名單目錄(呼叫端自訂)整棵跳過 } - // #104:依策略整棵跳過(使用者的 .gitignore/依賴/建置產物/範本/ - // worktree/巢狀 repo/以及非本次策略要收的區域)。 + // #104:依策略整棵剪掉(使用者的 .gitignore/依賴/建置產物/ + // worktree/巢狀 repo)。 // 🔴 每一次剪枝都要留下「哪一個、為什麼」——票上的紅線是排除規則要看得見, // 而 2026-08-16 實測發現原本整棵剪掉的部分完全沒有被記錄。 + // + // 🔴 inkstone/Arcrun#180(leo 2026-08-28):**「不收」不准再實作成「不走進去」。** + // leo 原話:「我要的就是雲端看到**所有的 folder** 像 tree 一樣呈現,去遍歷就好了」 + // 「所有的 system-dev 都可以展開,**因為就算沒有可萃的它也有下層**」。 + // 實測差距:`youlinhsieh-test1` 地端 `find -type d` 有 13 個目錄, + // 雲端的樹只有 3 個節點——樹在 `system-dev` 那一節就斷了。 + // ⇒ 收檔範圍那幾條(curated-wiki 只走 wiki/docs-only 只走文件區/ + // 範本目錄)已從 SkipsDirWhy 搬到 CollectsDirWhy:**走訪照走、節點照生、 + // 數字照算**,只是標明「這一層我不收」。 if p != root { if rel, ok := relOf(); ok { if skip, why := opts.Plan.SkipsDirWhy(rel, p); skip { @@ -375,6 +388,11 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { if _, ok := dirStats[rel]; !ok { dirStats[rel] = &dirStat{} } + // #180:走進來了,但這一層的檔這次不收 ⇒ 節點照生、子節點照長, + // 只是把理由掛上去(畫面仍然講得出「為什麼這裡沒收」)。 + if collect, why := opts.Plan.CollectsDirWhy(rel); !collect { + excludedDirs = append(excludedDirs, ExcludedDir{Path: rel, Reason: why}) + } } } return nil @@ -390,6 +408,13 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { s := statOf(rel) s.total++ s.excluded++ + if s.excludeWhy == "" { + if _, why := opts.Plan.CollectsDirWhy(folderOfRel(rel)); why != "" { + s.excludeWhy = why + } else { + s.excludeWhy = "這一層的檔案不在這次的收檔範圍裡" + } + } return nil } if abs, aerr := filepath.Abs(p); aerr == nil && opts.SkipPaths[abs] { @@ -406,16 +431,33 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { // · 我們代裝 template 的資料夾(沒有 `.git`)⇒ 那些檔是**我們鋪的**,照舊不收 // · 使用者自己的 repo(有 `.git`)⇒ 那份 wiki 是**他寫的**,正是他要我們讀的 // 判準只有 PlanIngest 一個地方,見 ingestplan.go。 + // + // 🔴 inkstone/Arcrun#180:**不收照舊,但不准再從畫面上消失。** + // leo 要的是「知道資料夾下有幾個檔案被萃」——以前這裡直接 `return nil`, + // 那些檔連分母都沒進去,於是 `system-dev/wiki`(實際 6 個檔)在樹上是 `0/0`、 + // `scripts`(實際 1 個檔)也是 `0/0`。**兩個 0 都是我們自己編的數字。** + // 現在照樣不收,但算進 total/excluded,使用者看得到「這裡有 6 個,我一個都沒收」。 + // + // 🔴 inkstone/Arcrun#180 驗收條件 2/3:判準從**路徑前綴**換成**內容** + // (templateuntouched.go)。舊的 `TemplateOwns` 把 `system-dev/` 整棵當範本, + // 分不出同一棵樹底下的兩種東西——`system-dev/wiki/status.md`(安裝器鋪的空殼) + // 與 `system-dev/wiki/cards/*.md`(9 張真的卡)於是一起被丟掉。 + // 現在逐檔比對:和快照逐字相同=空殼,不收;其餘照常判斷。 if rel, rerr := filepath.Rel(root, p); rerr == nil { relSlash := filepath.ToSlash(rel) - if TemplateOwns(relSlash) && !opts.Plan.OverridesTemplateOwned(relSlash) { + if TemplateUntouched(relSlash, p) { + s := statOf(relSlash) + s.total++ + s.excluded++ + if s.excludeWhy == "" { + s.excludeWhy = "這是安裝範本時鋪下來的空白樣板,你還沒在上面寫過東西" + } return nil } } ext := strings.ToLower(filepath.Ext(name)) - // #44 線 A:走到這裡的都是「使用者自己的、看得見的」檔——上面三道 - // (隱藏檔/SkipPaths 的機器檔/TemplateOwns 我們自己鋪的範本)刻意不算進分母, - // 理由與 scan.go 既有註解同一條:**我們自己鋪的東西不該佔用他的注意力**。 + // #44 線 A:走到這裡的都是「使用者自己的、看得見的」檔(隱藏檔與 SkipPaths 的 + // 機器檔刻意不算進分母:**我們自己寫的東西不該佔用他的注意力**)。 if rel, ok := relOf(); ok { s := statOf(rel) s.total++ diff --git a/template_not_knowledge_test.go b/template_not_knowledge_test.go index 505a611..a2e3b95 100644 --- a/template_not_knowledge_test.go +++ b/template_not_knowledge_test.go @@ -27,10 +27,21 @@ func TestTemplateFilesAreNotKnowledge(t *testing.T) { t.Fatal(err) } } - // template 的東西(**沒有隱藏**) - write("CLAUDE.md", "開發用設定") - write("scripts/sdd-active-check.sh", "#!/bin/sh") - write("system-dev/wiki/status.md", "開發用 wiki") + // template 的東西(**沒有隱藏**)。 + // 🔴 2026-08-28(inkstone/Arcrun#180):內容改成從內嵌快照原樣鋪出來, + // 不再自己捏一段假字串——判準已經從「路徑前綴」換成「內容與原版逐字相同」 + // (templateuntouched.go),假內容就不是範本空殼,那是使用者寫的東西。 + // 這也讓這支測試更貼近它要守的事故:**daemon 自己鋪下去的那份**不該被自己吃掉。 + fromSnapshot := func(rel string) string { + data, err := templateFS.ReadFile(templateFSRoot + "/" + rel) + if err != nil { + t.Fatalf("內嵌範本快照裡沒有 %s:%v", rel, err) + } + return string(data) + } + write("CLAUDE.md", fromSnapshot("CLAUDE.md")) + write("scripts/sdd-active-check.sh", fromSnapshot("scripts/sdd-active-check.sh")) + write("system-dev/wiki/status.md", fromSnapshot("system-dev/wiki/status.md")) // 使用者真正的內容 write("我的筆記.md", "這是我要搜尋的東西") @@ -58,3 +69,85 @@ func TestTemplateFilesAreNotKnowledge(t *testing.T) { res.SkippedOther, res.SkippedOtherNames) } } + +// TestTemplateShellVsRealContent 釘住 inkstone/Arcrun#180(leo 2026-08-28)新增的那半題: +// +// 「**cards 在 system-dev 下,wiki 就在裡面,所以不允許跳過它**」 +// +// 同一棵 `system-dev/` 底下並存兩種東西(`youlinhsieh-test1` 實況): +// +// system-dev/wiki/status.md ← 安裝器鋪的空殼,逐字等於快照 ⇒ 不是知識 +// system-dev/wiki/cards/*.md ← 使用者資料夾裡真的卡 ⇒ 是知識 +// +// 舊的路徑前綴判準把兩者一起丟掉。這支釘的就是「分辨得出來」, +// 而且釘的是**兩個方向**——放行與誤殺都會紅。 +func TestTemplateShellVsRealContent(t *testing.T) { + root := t.TempDir() + write := func(rel, body string) { + p := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + shell, err := templateFS.ReadFile(templateFSRoot + "/system-dev/wiki/status.md") + if err != nil { + t.Fatal(err) + } + // ① 原封不動的空殼 + write("system-dev/wiki/status.md", string(shell)) + // ② 同一個路徑的另一個檔,但使用者寫過(只差一行) + write("system-dev/wiki/mistakes.md", string(shell)+"\n2026-08-28:我自己寫的一條。\n") + // ③ 快照裡根本沒有的真內容——那 9 張卡就是這一種 + write("system-dev/wiki/cards/arcrun-火星座標_短片劇本_v1.md", "# 火星座標\n這是一張真的卡。\n") + + m := &Manifest{Entries: map[string]*ManifestEntry{}} + res, err := Scan(root, m, ScanOptions{}) + if err != nil { + t.Fatal(err) + } + got := map[string]bool{} + for _, e := range res.Events { + got[e.Path] = true + } + if got["system-dev/wiki/status.md"] { + t.Error("原封不動的範本空殼被當成知識收了——紅線①「不准變成放行」") + } + if !got["system-dev/wiki/mistakes.md"] { + t.Error("使用者只要寫過一個字就是他的知識,卻被當成範本丟掉") + } + if !got["system-dev/wiki/cards/arcrun-火星座標_短片劇本_v1.md"] { + t.Error("system-dev 底下真的卡沒被收——leo:「cards 在 system-dev 下…所以不允許跳過它」") + } +} + +// 判準不看名字:同一份範本鋪在叫別的名字的資料夾裡,一樣認得出來; +// 而一個剛好也叫 system-dev、裡面是使用者東西的資料夾,不該再被誤殺。 +func TestTemplateShell_判準不看資料夾名字(t *testing.T) { + shell, err := templateFS.ReadFile(templateFSRoot + "/system-dev/wiki/status.md") + if err != nil { + t.Fatal(err) + } + if TemplateUntouched("我的專案筆記/wiki/status.md", "/nonexistent") { + t.Error("快照裡沒有這個相對路徑,不該回 true") + } + root := t.TempDir() + p := filepath.Join(root, "system-dev", "wiki", "status.md") + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("這個資料夾剛好也叫 system-dev,但裡面是我的東西"), 0o644); err != nil { + t.Fatal(err) + } + if TemplateUntouched("system-dev/wiki/status.md", p) { + t.Error("名字一樣但內容不一樣,判準不該只看名字") + } + if err := os.WriteFile(p, shell, 0o644); err != nil { + t.Fatal(err) + } + if !TemplateUntouched("system-dev/wiki/status.md", p) { + t.Error("內容逐字等於快照,應該認得出是空殼") + } +} diff --git a/templateuntouched.go b/templateuntouched.go new file mode 100644 index 0000000..1559060 --- /dev/null +++ b/templateuntouched.go @@ -0,0 +1,87 @@ +// templateuntouched.go — 「範本空殼」與「真內容」怎麼分辨 +// (inkstone/Arcrun#180 驗收條件 2 與 3,leo 2026-08-28)。 +// +// ── 題目 ───────────────────────────────────────────────────────────────────── +// +// leo 原話:「**cards 在 system-dev 下,wiki 就在裡面,所以不允許跳過它**」 +// +// 實據(`~/Desktop/youlinhsieh-test1`,同一棵 `system-dev/` 底下兩種東西並存): +// +// system-dev/wiki/status.md ← 安裝器鋪的空殼,內容是「(初始化後填入)」 +// system-dev/wiki/cards/*.md ← 9 張真的卡(火星座標/Leo-Hsieh-填答/小果被AFTEE詐貸…) +// +// 舊判準是**路徑前綴**(`TemplateOwns`:`system-dev/` 與 `scripts/` 整棵不是知識), +// 它分不出這兩層——把空殼和真內容一起丟掉。 +// +// 🔴 而修法不准是「把 system-dev 放行」(票上紅線①):那會讓所有裝過 template 的 repo +// 開始收一堆一模一樣的空殼,變成另一個方向的錯。**要的是分辨。** +// +// ── 判準 ───────────────────────────────────────────────────────────────────── +// +// daemon **自己就是那個安裝器**(template_install.go 的 `//go:embed all:templatefs` +// 把整份範本快照打進二進位)⇒ 原版就在手上,可以逐字比對,不必猜: +// +// 快照裡有同名檔,**且 sha256 逐字相同** ⇒ 空殼:安裝器鋪的,他一個字都沒動 ⇒ 不收 +// 快照裡沒有,或內容不一樣 ⇒ 他寫的/他放的 ⇒ 照常判斷 +// +// **為什麼它不會再誤殺一份現成的 wiki**:範本的 `status.md` 寫的是「(初始化後填入)」, +// **使用者只要寫過一個字,雜湊就對不上,判斷立刻翻面**。 +// 判準不看名字、不看深度、不看模式 ⇒ 換個資料夾名、換個安裝位置,答案都一樣 +// (這也是紅線:不准用白名單,「下一個叫別的名字的資料夾照樣中」)。 +// +// ⚠️ 這支只回答「是不是原封不動的範本」。**其餘的收檔判斷一條都沒有動** +// (副檔名白名單、`.gitignore`、模式範圍、隱藏檔)——它只取代 `TemplateOwns` +// 在掃描時的那一格。tidy.go 仍然用 `TemplateOwns`:它問的是另一題 +// (「哪些是範本殘留、該搬進收容處」),不是「這是不是知識」。 +package collector + +import ( + "crypto/sha256" + "encoding/hex" + "io/fs" + "os" + "path/filepath" + "sync" +) + +// templateSnapshotHashes:內嵌範本快照裡每個檔的 sha256(key=斜線相對路徑)。 +// 只算一次——快照是編譯期就固定的東西,執行期不會變。 +var templateSnapshotHashes = sync.OnceValue(func() map[string]string { + out := map[string]string{} + _ = fs.WalkDir(templateFS, templateFSRoot, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + rel, rerr := filepath.Rel(templateFSRoot, p) + if rerr != nil { + return nil + } + data, rerr := templateFS.ReadFile(p) + if rerr != nil { + return nil + } + sum := sha256.Sum256(data) + out[filepath.ToSlash(rel)] = hex.EncodeToString(sum[:]) + return nil + }) + return out +}) + +// TemplateUntouched 回答「這個檔是不是安裝器鋪下去、而且使用者一個字都沒動過的空殼」。 +// +// relSlash=相對監看根的路徑(斜線分隔);absPath=那個檔在磁碟上的位置。 +// +// 🔴 讀不到檔一律回 false——**認不出來就不要排除**。誤留一份空殼只是雜訊, +// 誤丟一份使用者的知識是資料遺失,兩邊的代價不對等(同 cleanup.go 的規矩②)。 +func TemplateUntouched(relSlash, absPath string) bool { + want, ok := templateSnapshotHashes()[filepath.ToSlash(relSlash)] + if !ok { + return false // 快照裡沒有這個路徑 ⇒ 不是安裝器鋪的 + } + data, err := os.ReadFile(absPath) + if err != nil { + return false + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) == want +} diff --git a/tidy.go b/tidy.go index abbd2f1..39f6fe3 100644 --- a/tidy.go +++ b/tidy.go @@ -119,7 +119,37 @@ func collectCardItems(absRoot string) []TidyItem { for _, relDir := range cardDirsToScan() { base := filepath.Join(absRoot, filepath.FromSlash(relDir)) _ = filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { + if err != nil { + return nil + } + // 🔴 inkstone/Arcrun#134(2026-08-28 實測):**隱藏目錄整棵跳過。** + // + // `/.wiki/` 是規範定的卡片正式落點(machinemark.go 例外 ③), + // 而卡片產物區 `system-dev/wiki/cards/` 底下也會長出一個。少了這一行, + // 這支就把 `cards/.wiki/換柱.md`「歸位」成 `cards/arcrun-換柱.md` + // ——把**產物搬回 Scan 看得見的地方**,於是下一輪它被當成新原稿再萃一次, + // 產出「卡片的卡片」,再被搬出來⋯⋯迴圈不會停。 + // 實測 `youlinhsieh-test1`:`cards/` 20 分鐘內從 9 個檔長到 55 個, + // 出現 `arcrun-換柱(arcrun-換柱).md` 這種第三代檔名。 + // + // `collectTemplateItems` 同一輪(f71e5a2)就寫了同一行同一個理由 + // (「收容處自己就在 `.arcrun-rag/` 底下,不跳過的話第二次跑會把 + // 搬過去的東西再搬一次」)——這支漏掉了。當時 `.wiki/` 還不存在 + // (2026-08-15 才隨 ef5e6c5 進來),所以不是被否決,是沒人回來補。 + // + // 📌 `inkstone/Arcrun#180` 從**另一面**撞到同一個洞,兩件事一起記著: + // · #134 看到的是「產物被搬出去 ⇒ 下一輪再萃 ⇒ 卡萃卡」 + // · #180 看到的是「萃取端**讀不到自己一秒前寫的卡**」—— + // 8 份文件全部 `讀卡片失敗:open …/.wiki/結晶管線.md: no such file`, + // 於是 `system-dev/wiki/cards/` 底下那 9 張現成的卡一張都上不了雲端。 + // ⇒ 同一行修好兩個症狀。 + // ⚠️ 只跳**子**目錄(`p != base`):`.arcrun-rag/wiki/cards` 是 base 自己, + // #60 第三輪正是把卡搬進那裡——連 base 一起跳掉等於把上一輪的修復關掉。 + // `TestMigrateCardNames_收容處自己仍然掃得到` 守這一格。 + if d.IsDir() { + if p != base && strings.HasPrefix(d.Name(), ".") { + return filepath.SkipDir + } return nil } name := d.Name() diff --git a/tidy_wiki_untouched_test.go b/tidy_wiki_untouched_test.go new file mode 100644 index 0000000..8c90294 --- /dev/null +++ b/tidy_wiki_untouched_test.go @@ -0,0 +1,79 @@ +// tidy_wiki_untouched_test.go — 舊制的「卡片歸位」不准動新制 `.wiki/` 裡的卡 +// (inkstone/Arcrun#180,2026-08-28 實撞)。 +// +// 為什麼需要這一張網:`MigrateCardNames` 是 **daemon 每輪自動跑**的(direct.go +// runDirectOnceRoot 開頭),而它掃的目錄與新制卡的家在某些資料夾上**完全重疊**—— +// 使用者把整理好的卡放在 `system-dev/wiki/cards/`,那些卡自己就是原稿, +// 萃出來的新卡於是落在 `system-dev/wiki/cards/.wiki/`。 +// +// 兩條規則撞上的當下沒有人會看到錯誤訊息:舊制默默把卡搬走並改名, +// 萃取端下一步才報「讀卡片失敗」,而那句話指向的是**它自己一秒前寫出來的檔**。 +// ⇒ 這種 bug 只有機械網抓得到,靠讀程式碼讀不出來(本票就是這樣被繞了三輪)。 +package collector + +import ( + "os" + "path/filepath" + "testing" +) + +func mustWriteFile(t *testing.T, root, rel, body string) { + t.Helper() + p := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestMigrateCardNames_不准把新制wiki裡的卡拖出來(t *testing.T) { + root := t.TempDir() + // leo 的 youlinhsieh-test1 的形狀:整理好的卡住在 system-dev/wiki/cards/, + // 它們自己被當原稿萃過一輪,新卡落在同一層的 .wiki/。 + mustWriteFile(t, root, "system-dev/wiki/cards/arcrun-火星座標_短片劇本_v1.md", "# 火星座標\n") + mustWriteFile(t, root, "system-dev/wiki/cards/.wiki/火星座標_短片劇本_v1.md", "# 火星座標\n") + mustWriteFile(t, root, "system-dev/wiki/cards/.wiki/官架子.md", "# 官架子\n") + mustWriteFile(t, root, "system-dev/wiki/cards/.wiki/00-INDEX.md", "# 00-INDEX\n") + mustWriteFile(t, root, "system-dev/wiki/cards/.wiki/.gitignore", "*\n") + + MigrateCardNames(root) + + for _, rel := range []string{ + "system-dev/wiki/cards/.wiki/火星座標_短片劇本_v1.md", + "system-dev/wiki/cards/.wiki/官架子.md", + "system-dev/wiki/cards/.wiki/00-INDEX.md", + "system-dev/wiki/cards/.wiki/.gitignore", + } { + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); err != nil { + t.Errorf("%s 不見了——舊制的歸位又走進 .wiki/ 了:%v", rel, err) + } + } + // 反面:不准在上一層生出帶前綴的複本(那正是「卡萃卡」增生的起點)。 + for _, rel := range []string{ + "system-dev/wiki/cards/arcrun-官架子.md", + "system-dev/wiki/cards/arcrun-00-INDEX.md", + "system-dev/wiki/cards/arcrun-.gitignore", + } { + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); err == nil { + t.Errorf("多出了 %s——`.wiki/` 裡的東西被拖出來改名了", rel) + } + } +} + +// 上一輪(#60 第三輪)的修復不准被這次的「跳過點開頭目錄」關掉: +// `.arcrun-rag/wiki/cards/` 這個**收容處自己**是隱藏路徑,它仍然要被掃到。 +func TestMigrateCardNames_收容處自己仍然掃得到(t *testing.T) { + root := t.TempDir() + mustWriteFile(t, root, ".arcrun-rag/wiki/cards/沒帶標記的舊卡.md", "# 舊卡\n") + + mig := MigrateCardNames(root) + + if mig.Moved != 1 { + t.Fatalf("收容處裡沒帶標記的舊卡應該仍然被認出來(#60 第三輪的行為),實際 moved=%d", mig.Moved) + } + if _, err := os.Stat(filepath.Join(root, ".arcrun-rag/wiki/cards/沒帶標記的舊卡.md")); err == nil { + t.Fatal("它應該已經歸位了,原位置不該還在") + } +}