3 Commits

33 changed files with 2059 additions and 137 deletions
+25
View File
@@ -47,6 +47,31 @@
---
## 下一版(未發佈)
- 🔴 **小幫手不再把自己整理出來的筆記當成新資料再整理一次**:以前它整理完一份文件、把筆記寫進資料夾之後,下一輪掃描又會把那些筆記當成「新來的檔案」再整理一遍,整理出來的東西又變成下一輪的材料——一輪接一輪停不下來。你會看到資料夾裡冒出越來越多名字奇怪的檔(像是同一個詞被括號包起來重複一次),每一輪都白白用掉一次當天的整理額度,知識庫裡也被塞進一堆「筆記的筆記」。現在它認得出哪些檔是自己寫的,一律跳過。
- **已經被繁殖出來的那些檔要自己刪**:這次的修正只保證不再繼續長,**不會回頭清掉先前多出來的檔**——那些檔跟你自己的東西混在同一個資料夾裡,我們寧可不替你決定刪哪一個。名字裡有重複括號的(例如「換柱(換柱)」)就是這次多出來的,可以直接刪掉。
- 🔴 **裝過開發範本的資料夾,現在分得出「空白樣板」與「你真的寫過的東西」**:以前只要路徑在 `system-dev/` 底下就整批不收,於是同一個資料夾裡**你自己的筆記與現成的知識卡也一起被丟掉**(實測:一個桌面資料夾底下有 9 張整理好的卡,一張都沒上去)。現在改成逐個檔比對——**內容和安裝時鋪下去的原版一模一樣**(你還沒動過)才不收;你寫過一個字、或那個檔根本不是範本鋪的,就是你的知識,照收。
- **這個分辨不看資料夾叫什麼名字**:換個名字裝、裝在別的位置,答案都一樣;反過來,一個剛好也叫 `system-dev` 但裡面是你自己東西的資料夾,不會再被誤殺。
- 🔴 **資料夾樹現在真的是「所有的資料夾」**:以前有幾種資料夾我們決定不收,而「不收」的做法是**連走都不走進去**——結果是那個資料夾底下有哪幾層、哪些子資料夾,你在小幫手與知識庫網頁上**一個都看不到**,樹到那一節就斷了。(實測:桌面上一個資料夾實際有 12 層資料夾,畫面上只有 3 個。)現在每一層都會列出來,跟你在檔案總管裡看到的一樣。
- **不收的那一層,照樣告訴你「底下有幾個檔、我收了幾個、為什麼沒收」**:以前這種資料夾要嘛不存在,要嘛顯示一個我們自己編的 `0 / 0`。現在數字是真的(例如「0 / 9」),旁邊寫著原因(例如「這是開發範本鋪下來的檔,不是你寫的東西」)——你先看得到那裡有東西,才輪得到決定要不要收它。
- **收檔範圍一個字都沒有放寬**:這一版只改「看得到什麼」,沒有改「收什麼」。原本不收的檔案現在仍然不收。
- 🔴 **把整理好的卡片放在資料夾裡,不會再被小幫手自己攪亂**:如果你把整理過的知識卡放在 `system-dev/wiki/cards/` 這種位置,小幫手替它們整理出來的新卡片會**被舊版的「卡片歸位」動作硬拖出來、改成別的檔名**——接著它連自己一秒前寫的卡都找不到,那批文件於是一份都上不去;被拖出來的卡又被當成你的新文件再整理一次,檔案愈長愈多(`某某(某某)``某某(某某)(某某)`…)。(實測:一個資料夾裡因此多出 70 個沒人要的檔案,9 份現成的卡一份也沒進到雲端。)現在那批卡片放在哪裡就留在哪裡。
## 0.18.422026-08-27
- 🔴 **問 AI「這份資料的原文在哪」,現在答得出你走得到的位置**:以前它會回一個像 `../某某檔.pdf` 的東西——那是程式自己擺放檔案的相對位置,你拿著它打不開任何檔案;原稿如果放在子資料夾裡,連是哪一層都看不出來。現在每張卡的「出處」寫的是**哪一台電腦 › 哪一個知識庫 › 庫內哪一個路徑**(例如 `youlinhsieh@Leo-MBA youlinhsieh-test1 會議紀錄/2026/某某.md`),照著三格走就找得到那個檔。
- **以前已經整理過的筆記也會一起換掉,不用重新整理一次**:小幫手會把本機既有的卡片改成新寫法並重新送上去一次,**不會再花一次 AI 額度、內容其餘一個字都不動**;每個資料夾只做一次,做完就不再重複。
- **刻意不寫完整的電腦路徑**:卡片會被別台電腦上的 AI 讀到,而完整路徑只在原本那台成立——寫了反而給你一個看起來精確、實際上打不開的答案。
## 0.18.412026-08-27
- 🔴 **不叫「docs」的文件夾,現在也認得出來**:以前只有名字剛好叫 `docs``doc``documentation` 的資料夾會被讀,一個放滿 PDF、Word、筆記的資料夾只要名字不一樣(例如一個舊專案的存檔夾),就整個被當成程式碼跳過,畫面上也不會說為什麼。現在只要一個資料夾裡完全沒有程式碼、有看得懂的文件,不管叫什麼名字都會被收進來——你不用先學會「哪個按鈕可以救回一個資料夾」,大部分情況它一開始就是對的。
## 0.18.402026-08-27
- 🔴 **卡住的檔案不再擋住後面所有人**:以前只要有幾個檔一直收不上去(格式讀不了、雲端當天額度滿了…),它們就會**每一輪都繼續佔著最前面的位子**,即使那一輪根本沒去試它們。結果是排在後面、本來好好的檔案永遠輪不到——你等再久、跑再多輪都一樣,數字只會往上長不會往下掉。現在退避中的檔案會讓開,後面的檔案遞補得上來,佇列真的會往前走。
## 0.18.392026-08-27
- 🔴 **「已送上去」從今以後是真的送上去了**:以前只要雲端把請求收下來,小幫手就算它成功——就算雲端其實根本沒把它寫進你的知識庫。畫面上是綠的、數字也在跑,而 AI 一句都查不到,而且那些檔案因為被蓋了「已送」的章,**永遠不會再試一次**。現在它會看雲端真正的回覆:沒寫進去就誠實標成失敗、不蓋章、下一輪重新送。(實測:一個開發資料夾 26 份標「已送達」,雲端實際只有 4 份。)
+118
View File
@@ -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`
// 這種第三代檔名。那不是萃取品質問題,是**產物被自己的整理程序拖回原稿區**:
//
// ① 萃取寫 `<node>/.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)
}
}
}
// TestMachineOwnedCardsAreNotIngestedAsSourcecurated-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)
}
}
// TestSnapshotCardsIgnoresWikiDirclaude 路的同一個洞。
// 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)
}
}
+68
View File
@@ -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"
// ① 接開發 repocurated-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)
}
}
}
+22 -18
View File
@@ -1,10 +1,10 @@
{
"_algo": 4,
"version": "0.18.39",
"fingerprint": "316426f50e886003",
"version": "0.18.42",
"fingerprint": "43e0c6ac08233f07",
"files": {
".gitignore": "4d56952b0fb13bf8f9b6c13a6d4c34a075bac3af447636a1df4335d7576e2f97",
"CHANGELOG.md": "f6f3858cd587278528f68ff7290722b6e67fcd763a5b159791169c49387d5502",
"CHANGELOG.md": "6cbc29aad162ab41f6db0ef6550bf6d5c44c01544598816d33b58ad585446528",
"DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d",
"README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f",
"check-standalone.sh": "65fbce096326791c2f103a51e76d0ad79e9e510e2a7700b49ac2d2d81f73c853",
@@ -121,22 +121,22 @@
"convert_table.go": "5313b96e4d85d65e6c3c8b51e7c0e25aee11a47fdbda534fe8432fbea87836c5",
"convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394",
"convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18",
"convert_wiring_test.go": "09e97bf32ace245b55acc7d65ee5c0bb7fd84f8fb1aab61f603f9e9d18b22f13",
"direct.go": "8f5adcbfe51e6b21b4cb585fd02c7a2f66ffa28770b61aa9722abd424c35c8a3",
"convert_wiring_test.go": "3389817e596341369a3fc102846bf5b081cee668e86ebf4830eb633978de652b",
"direct.go": "b4dc846cd62b0d57754afeb66658e35f2578afd6ca256e95ee8f72c0f03507b9",
"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",
"direct_t182_test.go": "1c64a9d0f41b98504e10a20a96f28e8a47cced4923171ef40dc800b861d0275b",
"extract.go": "76c40068d378d6036e330bb56e41caec2f80b9b219352586744194df560c8147",
"extract_gemma.go": "9d127fa467b8c760ec46180123ec0fdadd4336290555687f16baf0b80b24c6f2",
"extract_gemma_test.go": "1fae16cc43e3de9728b88ea7151da9c836bf32cb5bead2805fbadc5e788fbab8",
"extract_gemma.go": "53483c6ea3992759abbae45562bfc418d4df1728b44fa87b3bd30324d28fc4f4",
"extract_gemma_test.go": "439f12c1c6fb534b31f983305a4d7898ef66dfcbe697714be08a6b571e4e57c0",
"extract_test.go": "63560ecca929c6507d93b3f52988bb7ce2cd6137140eb373c520eac5ee052c76",
"extract_workersai.go": "d8f192472883e7a7f5d034d9b0e0d8be46f284bef2c500f934ec740379efffde",
"extract_workersai_test.go": "188822f4c44ee5bf3b1400e1475af314ad831296538a68c5e1006341ba63765e",
"extract_workersai.go": "577ea44f3224b54cac9042dcb11edcc29404313683c3c92d4267b07b82f35097",
"extract_workersai_test.go": "8850e7128f03ecb4754a4663281800dc73d556fab1d80367924b58bae3dd8ec3",
"folderindex.go": "f3dd186fd7e882ff293b3f8aa6b2582b018290ee5f14bbdc02a7ac131834fd69",
"folderindex_helpers_test.go": "2cf17b70c396225f728e8e160cfc75d7bdc76ac1ce8e574d1450b020050c0734",
"folderindex_test.go": "a5319eb1e92473cb5c32d3beddb7889997556227824de300dd39953b43beab70",
@@ -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",
@@ -161,7 +161,7 @@
"machineid_test.go": "5b54ac62ee1fb28b8f0a873888aeddb844af0aa74472bfb849573cfc6691a290",
"machinemark.go": "b93464c2ee89a73c0e0da38bcce5dc1ba6c1deba2fc483f28c6021c3ffdee4a0",
"main.go": "fe2f69739f3ee92713f8aaca925068ecf97b33d476eee826fc1e0d5def8f0d10",
"manifest.go": "61a9403a3da46f270ce6a1610b9307fd19b794521b71a7a8f22cea36a6ea35cd",
"manifest.go": "c6b6fbdd5cc6963345048aa1b0a32f61b7b0b5b84616a15827aea290bd3758e1",
"manifest_extractedby_test.go": "8019fe35675a1e801b7bda829ee1d647de32db03c029b1c651f71573e644c69e",
"manifest_retry_test.go": "e6b4a321ccddfeed8cdbfabe6d031d5a9c59a33b47baf0b6d21150fa8406748a",
"multiaccount_connected_test.go": "ba1817b73a5dd0dd5d300750e54d5c53bd82a75ef6e43caeddfcb2303d64ff66",
@@ -182,6 +182,9 @@
"scan_dedup_test.go": "580ec58596ca54aa3d29b417e7d098fd2e3455fd39bf10e7da02d453c0786df0",
"scan_skipped_test.go": "983ecabb5d93388a501836d15dd445789b7f3028a7f3b966e04425ad4d5bfd9c",
"scan_test.go": "d0e472686ff41da2421bfd45b63099fd75877e6fc72f26dfdd1a07dc64c9f0cd",
"sourcerepair.go": "2ac80c82ca0cb376515448ecc4c38ab542cc8c885f3cc5bc8b604a94c064c7f3",
"sourcerepair_live_test.go": "2a7f5d6fada420a7f55fa52b87361842755c68c1cb58f8c3fd0153341f709a0a",
"sourcerepair_test.go": "2a1ddddaad8ac7adc36350859e2d742c0fea12b99370a4d4831d390db1cc6720",
"supervisor/hidewindow_other.go": "291b4ecb0783ff3e813c68b5464161d9af2f84d31c773c7f1f710f218bb5d610",
"supervisor/hidewindow_windows.go": "29796dbe90ecb35e6f1451dacf956d42928c6a393271befc5c7f6863d5315fb3",
"supervisor/supervisor.go": "cb17332ce5a7190627839e624895c56b508635edbd2c29758f794d5c3bb39388",
@@ -241,16 +244,17 @@
"trigger.go": "f689f701bef08401f5d47f5b3d783d24a88fc9a9426fe41bdbbb3f9b152ab405",
"trigger_test.go": "0c2482d18cea0568cd2a0f5eae5b02a0984b9b2bc83461eb882c630d6381ca02",
"triggeroutcome.go": "25832326ecb3694d8a3a0f8a115ce8df6327b70ff61b0bec1357d57e53e81836",
"triggeroutcome_test.go": "fc429ea5da193e6250b10f7d87690c293c21a0b4db763eedbc04fac9e1995e96",
"triggeroutcome_test.go": "0f736ba4125cf3da8e736791400a8091d0372fc0a4d252c4a1b293a4483d7720",
"upload.go": "a41bb459eac8f6c114be567a2d7c53bbdb11b0e31f0bda4b78776502664273cb",
"upload_test.go": "f97d2f3bf2213e6fff94b77b7120aa4e4f23f58c7e413c0416f02be387f2cb91",
"upstream_error_visible_test.go": "6b72c903594b3eba47cfdeb40654f1f5c341757f79dfbc5c05351cd5b99c9368",
"vault.go": "91d61613d37da5c16b24e08c70d59cf4c9cde9375737573d65921949087f1465",
"vault_footprint_test.go": "e3909a160675f4d750c4ad0ed608c0e8872e38bc5c125b6389c6b1e5f1b4afdb",
"vault_footprint_test.go": "fcfc18b90b038f16fd12c5533aff66acf40ee44b28fd4972880d8ebed807e3b1",
"vault_subdir_test.go": "ecdd3221342e6b922ae29a7f873c4c599bd22bfb716b0a5044ba2f11a1e08d32",
"vault_test.go": "f7ba916d0ec4fc69ea7cc3832ebca0aee83e00f4ae93077038519d06817b4c21",
"verify-60.sh": "323347b19a366d5bcf8ddb65c9800dde32027d0170675f90e0ea46d6d1fcadc4",
"wikishape.go": "b6e55f1d006c4eb4bec37448d82d56e70ad7f577d7f97dc5bb00480cc1de7f23",
"wikishape_test.go": "910d019d13302e8cab0978fd15d201f96db2a533751779f2707f6d90ac3e148e"
"wikishape.go": "75901acc73abaa9b9340f13c110d90e1972207326acf8fc9ea9d6912b88524c1",
"wikishape_origin_test.go": "83310bb8476fb2af0eee1d7924cfc02695f280ffeeae63e5fbd7c70d4a8b0543",
"wikishape_test.go": "1727392a92f16b9e0b7666d752cbdd49bdf37edf310cb841cbb90f8b1a4939a5"
}
}
+4 -1
View File
@@ -9,5 +9,8 @@
"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",
"0.18.42": "43e0c6ac08233f07"
}
+14 -3
View File
@@ -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) {
+ `<span class="tw">${kids.length ? '<i></i>' : ''}</span>`
+ `<span class="ic">${kids.length && open ? '📂' : '📁'}</span>`
+ `<span class="nm">${esc(n.name || '(未命名)')}</span>`;
if (n.skipped) {
if (n.skipped && n.total_files === 0) {
// 🔴 整棵沒走進去 ⇒ 不准顯示 0/0(那會是我們自己編的數字),改講理由。
const why = n.skip_reason || '(小幫手沒說明理由)';
row += `<span class="skip">整個資料夾未收</span>`
+ `<span class="why" title="${esc(why)}">${esc(why)}</span>`;
} else if (n.skipped) {
// #180:走進去了、數字是真的,只是這一層一個檔都沒收
// ⇒ **數字照顯示**(他要知道底下有幾個),理由也照講。
const why = n.skip_reason || '(小幫手沒說明理由)';
row += `<span class="num">${s.synced} / ${s.total}</span>`
+ `<span class="why" title="${esc(why)}">${esc(why)}</span>`;
} else {
const full = s.total > 0 && s.synced === s.total;
row += `<span class="num${full ? ' full' : ''}">${s.synced} / ${s.total}</span>`;
+4 -4
View File
@@ -67,7 +67,7 @@ func TestWiring_docx轉檔發生在送LLM之前(t *testing.T) {
writeDocx(t, filepath.Join(dir, rel), `<w:p><w:r><w:t>維修費用 350,000 元</w:t></w:r></w:p>`)
// 空 API key → 函式最前面就擋下,證明不了轉檔。給假 key 讓它走到轉檔那一步。
_, err := ExtractWithGemma("fake-key-for-test", "", dir, rel)
_, err := ExtractWithGemma("fake-key-for-test", "", dir, rel, testOrigin())
if err == nil {
t.Fatal("用假 key 不該成功")
}
@@ -90,7 +90,7 @@ func TestWiring_抽不出文字要明確失敗不靜默(t *testing.T) {
rel := "空的.docx"
writeDocx(t, filepath.Join(dir, rel), `<w:p><w:r><w:t> </w:t></w:r></w:p>`)
_, err := ExtractWithGemma("fake-key-for-test", "", dir, rel)
_, err := ExtractWithGemma("fake-key-for-test", "", dir, rel, testOrigin())
if err == nil {
t.Fatal("抽不出文字應該失敗")
}
@@ -109,7 +109,7 @@ func TestWiring_未支援格式與無文字要能區分(t *testing.T) {
rel := "簡報.ppt"
os.WriteFile(filepath.Join(dir, rel), []byte("\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1fake"), 0o644)
_, err := ExtractWithGemma("fake-key-for-test", "", dir, rel)
_, err := ExtractWithGemma("fake-key-for-test", "", dir, rel, testOrigin())
if err == nil {
t.Fatal("未支援格式應該失敗")
}
@@ -127,7 +127,7 @@ func TestWiring_md不受轉檔層影響(t *testing.T) {
rel := "筆記.md"
os.WriteFile(filepath.Join(dir, rel), []byte("# 標題\n\n內容"), 0o644)
_, err := ExtractWithGemma("fake-key-for-test", "", dir, rel)
_, err := ExtractWithGemma("fake-key-for-test", "", dir, rel, testOrigin())
if err == nil {
t.Fatal("假 key 不該成功")
}
+31 -2
View File
@@ -1391,6 +1391,26 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
saveManifest() // 拔掉的章與對帳時間當下就落盤(斷點續傳同款)
}
// 🔴 既有卡片的「### 出處」就地修正(`inkstone/Arcrun#167`)——一次性、修完蓋章。
// 舊卡的出處寫的是 `../<檔名>`daemon 自己的目錄結構),AI 照著答、使用者走不到。
// 修產生端只治得了新卡,票上寫死「不能只修新的」⇒ 這裡把本機既有的卡重畫那一塊
// 並原樣重推(零 LLM,workflow 進門先刪同名舊 blocks ⇒ 取代不疊加)。見 sourcerepair.go。
if sr := repairCardSourceBlocks(cfg, absRoot, m, dryRun, false, runNow); sr != nil {
if sr.Repushed > 0 {
results = append(results, DirectResult{
Type: "resync", Path: absRoot, Status: "noticed",
Error: fmt.Sprintf("已修好 %d 份筆記的「原文位置」寫法(從前寫的是程式內部路徑,現在會告訴你在哪台機器、哪個知識庫、庫內哪一個檔)", sr.Repushed),
})
}
if sr.Err != "" {
results = append(results, DirectResult{
Type: "resync", Path: absRoot, Status: "noticed",
Error: "更新「原文位置」時有一份沒成功(不影響同步,稍後自動再試):" + sr.Err,
})
}
saveManifest()
}
// 2026-08-07 task 3Scan() 會把 removed 的路徑從 m.Entries 整批拿掉(rebuild 語意,
// 見 scan.go 步驟 7)——但那只是「偵測到不見了」,不代表下架 POST 已經成功。
// 沒有這份快照的話,本輪只要有任何一個 added/modified 事件先觸發了下面的
@@ -1578,6 +1598,15 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
pace() // 2026-08-07:每次要觸發雲端(萃取/POST)之前先節流一下
if cfg.Extractor != "" {
// 四步定稿:本地萃卡 → 每張卡 POST rag_ingest_card(原文不出機)
// 🔴 `inkstone/Arcrun#167`:卡片的「### 出處」要寫得出「哪台機器 /
// 哪個庫 / 庫內什麼路徑」,所以萃卡前先把這三件備好交給塑形層。
// 三件與下面 cardBody 送雲端的 machine/library/path 是**同一組值**
// ——卡上寫的與雲端存的從此對得起來(不再各說各話)。
cardOrigin := SourceOrigin{
MachineLabel: cfg.machineIdentity().Label,
Library: cfg.libraryFor(absRoot),
LibraryPath: ev.Path,
}
var cards []string
var xerr error
switch cfg.Extractor {
@@ -1590,9 +1619,9 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
// ⇒ 探測在 RunDirectOnceProbeWorkersAI),結果寫進 status.json
// 托盤那行「狀態:」直接告訴用戶該做什麼。
// 靜默退回會讓用戶**永遠不知道自己的雲端還沒更新**——正是要避免的黑箱。
cards, xerr = ExtractWithWorkersAI(cfg.CypherURL, cfg.APIKey, absRoot, ev.Path)
cards, xerr = ExtractWithWorkersAI(cfg.CypherURL, cfg.APIKey, absRoot, ev.Path, cardOrigin)
case "gemma":
cards, xerr = ExtractWithGemma(cfg.GeminiAPIKey, cfg.LLMModel, absRoot, ev.Path)
cards, xerr = ExtractWithGemma(cfg.GeminiAPIKey, cfg.LLMModel, absRoot, ev.Path, cardOrigin)
default:
// t176claude 路先不支援(RunDirectOnce 開頭已正規化)。
// 走到這裡代表 config 有沒見過的值——誠實報錯,不要靜默跳過(禁假綠)。
+16 -1
View File
@@ -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#1342026-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)
+2 -2
View File
@@ -88,7 +88,7 @@ func cleanGemmaCard(text, pageName string) string {
// ExtractWithGemma 讀原稿 → 呼 Gemini 萃卡 → 卡片落地 system-dev/wiki/cards/。
// 回傳產出的卡片相對路徑(單檔一卡)。
func ExtractWithGemma(apiKey, model, absRoot, relPath string) ([]string, error) {
func ExtractWithGemma(apiKey, model, absRoot, relPath string, origin SourceOrigin) ([]string, error) {
if apiKey == "" {
return nil, fmt.Errorf("gemma 萃取路需要 gemini_api_keyconfig")
}
@@ -171,7 +171,7 @@ func ExtractWithGemma(apiKey, model, absRoot, relPath string) ([]string, error)
if perr != nil {
return nil, perr
}
cards, berr := BuildWikiDoc(absRoot, relPath, srcText, ex, time.Now())
cards, berr := BuildWikiDoc(absRoot, relPath, srcText, ex, origin, time.Now())
if berr != nil {
return nil, berr
}
+5 -5
View File
@@ -44,7 +44,7 @@ func TestExtractWithGemmaThinkingModel(t *testing.T) {
}},
})
})()
cards, err := ExtractWithGemma("k123", "gemma-test", root, "會議記錄.md")
cards, err := ExtractWithGemma("k123", "gemma-test", root, "會議記錄.md", testOrigin())
if err != nil {
t.Fatal(err)
}
@@ -79,14 +79,14 @@ func TestExtractWithGemmaThoughtOnly(t *testing.T) {
}},
})
})()
if _, err := ExtractWithGemma("k", "m", root, "x.md"); err == nil {
if _, err := ExtractWithGemma("k", "m", root, "x.md", testOrigin()); err == nil {
t.Fatal("thought-only 應報錯")
}
}
// 缺 key=引導訊息。
func TestExtractWithGemmaNoKey(t *testing.T) {
if _, err := ExtractWithGemma("", "m", t.TempDir(), "x.md"); err == nil {
if _, err := ExtractWithGemma("", "m", t.TempDir(), "x.md", testOrigin()); err == nil {
t.Fatal("缺 key 應報錯")
}
}
@@ -124,7 +124,7 @@ func TestExtractWithGemma_VaultDoesNotGainPages(t *testing.T) {
pagesBefore := countMD(t, filepath.Join(root, "pages")) + countMD(t, filepath.Join(root, "journals")) + countTopLevelMD(t, root)
cards, err := ExtractWithGemma("k123", "gemma-test", root, srcRel)
cards, err := ExtractWithGemma("k123", "gemma-test", root, srcRel, testOrigin())
if err != nil {
t.Fatal(err)
}
@@ -181,7 +181,7 @@ func TestExtractWithGemma_VaultExistingCardNotClobbered(t *testing.T) {
})
})()
_, err := ExtractWithGemma("k123", "gemma-test", root, srcRel)
_, err := ExtractWithGemma("k123", "gemma-test", root, srcRel, testOrigin())
if err == nil {
t.Fatal("目標位置被別人佔用時應報錯,不得無聲覆蓋")
}
+15 -3
View File
@@ -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」,而可用輸入是
@@ -94,7 +95,7 @@ func tooBigForWorkersAI(srcText, relPath string) string {
// ExtractWithWorkersAI 讀原稿 → 送自己雲端的 /portal/daemon/extract 萃卡 → 卡片落地。
// cypherURL/apiKey 用的是 daemon 既有的連線憑證(送卡片上雲時同一把,見 direct.go)。
// 回傳產出的卡片相對路徑(單檔一卡),與 ExtractWithGemma 契約一致。
func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string) ([]string, error) {
func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string, origin SourceOrigin) ([]string, error) {
if strings.TrimSpace(cypherURL) == "" {
return nil, fmt.Errorf("workers-ai 萃取路需要 cypher_urlconfig")
}
@@ -183,7 +184,7 @@ func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string) ([]string,
if perr != nil {
return nil, perr
}
cards, berr := BuildWikiDoc(absRoot, relPath, srcText, ex, time.Now())
cards, berr := BuildWikiDoc(absRoot, relPath, srcText, ex, origin, time.Now())
if berr != nil {
return nil, berr
}
@@ -201,6 +202,17 @@ func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string) ([]string,
// vault 落隱藏目錄,且**檔名一律帶 arcrun- 前綴**(第二輪:光換目錄擋不住撞名,
// 因為 Logseq 的頁名是 basename)。落地前先查目標存不存在、不無條件覆蓋(safeWriteCard)。
cardRel := cardRelFor(absRoot, pageName)
// 🔴 inkstone/Arcrun#1802026-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
+5 -5
View File
@@ -45,7 +45,7 @@ func TestExtractWithWorkersAI_VaultRedirectsAndDoesNotClobber(t *testing.T) {
})
defer closeFn()
cards, err := ExtractWithWorkersAI(url, "key123", root, srcRel)
cards, err := ExtractWithWorkersAI(url, "key123", root, srcRel, testOrigin())
if err != nil {
t.Fatal(err)
}
@@ -94,7 +94,7 @@ func TestExtractWithWorkersAI_NonVaultUnchanged(t *testing.T) {
})
defer closeFn()
cards, err := ExtractWithWorkersAI(url, "key123", root, srcRel)
cards, err := ExtractWithWorkersAI(url, "key123", root, srcRel, testOrigin())
if err != nil {
t.Fatal(err)
}
@@ -131,7 +131,7 @@ func TestExtractWithWorkersAI_SendsSharedPrompt(t *testing.T) {
})
defer closeFn()
if _, err := ExtractWithWorkersAI(url, "key123", root, srcName); err != nil {
if _, err := ExtractWithWorkersAI(url, "key123", root, srcName, testOrigin()); err != nil {
t.Fatal(err)
}
srcText, err := ConvertToText(srcName, []byte(srcBody))
@@ -181,7 +181,7 @@ func TestExtractWorkersAIAndGemmaProduceIdenticalWiki(t *testing.T) {
t.Fatal(err)
}
undo := gemmaCardStub(t, fixture)
cardsG, errG := ExtractWithGemma("k-test", "", rootG, srcName)
cardsG, errG := ExtractWithGemma("k-test", "", rootG, srcName, testOrigin())
undo()
if errG != nil {
t.Fatal(errG)
@@ -195,7 +195,7 @@ func TestExtractWorkersAIAndGemmaProduceIdenticalWiki(t *testing.T) {
url, closeFn := workersAIStub(t, func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "output": fixture})
})
cardsW, errW := ExtractWithWorkersAI(url, "key123", rootW, srcName)
cardsW, errW := ExtractWithWorkersAI(url, "key123", rootW, srcName, testOrigin())
closeFn()
if errW != nil {
t.Fatal(errW)
+36 -3
View File
@@ -58,7 +58,22 @@ const (
//
// 🔴 **超過不准安靜地截掉**——`FolderTree.Truncated` 會被送上去、由畫面講出來
//(同 #104「排除規則要看得見」那條紅線:安靜地少講與講一個 0,對使用者是同一件事)。
MaxFolderTreeNodes = 300
//
// 🔴 2026-08-28inkstone/Arcrun#180)從 300 提到 2000。leo 的規格是
// 「**雲端看到所有的 folder**」,而剪枝拿掉之後節點會變多——300 會讓「看得到全部」
// 在大資料夾上靜默失效(`Truncated` 雖然誠實,但那是在說「我做不到你要的事」)。
//
// **實測(本次改動後,同一台機器)**:
//
// youlinhsieh-test1 12 個節點(地端 `find -type d` 非隱藏 12
// youlinhsieh-test2 13(地端 13
// pms 53(地端 360——差額是 node_modulesdist.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-28inkstone/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 現況。已送達且送上去之後沒再改過=已同步,其餘=還在路上。
+116
View File
@@ -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 <root> -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(扣掉沒走進去的子樹)/樹上節點 %dtruncated=%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)
}
})
}
}
+169
View File
@@ -0,0 +1,169 @@
// foldertree_traversal_test.go — 「雲端的樹要看得到所有的 folder」的迴歸網
// inkstone/Arcrun#180leo 2026-08-28)。
//
// leo 給的判準是機械的,本檔就照它寫:
//
// find <root> -type d wc -l == 樹的 total_nodes
// (實測差距:youlinhsieh-test1 地端 12 個目錄,雲端的樹只有 3 個節點)
//
// 🔴 這一組釘死的是「**展開 ≠ 收檔**」:收不收是另一個判斷,
// 不准再用「不走進去」來實作「不收」——那會讓使用者連自己有哪些子資料夾都看不到。
package collector
import (
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// countLocalDirs`find <root> -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) // 根+scriptssystem-devwikicardsdocs3-specsTEMPLATE-sddworkflows 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("cardstotal=%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)
}
}
+221 -44
View File
@@ -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"`
@@ -157,15 +160,30 @@ var toolOwnedFileNames = map[string]bool{
"Gemfile.lock": true, "poetry.lock": true, "pnpm-workspace.yaml": true,
}
// templateOwnedDirNamessystem-dev-template 鋪出來的產物區,任何深度都不當原稿
// daemon-beta task 22026-08-06,原始出處 commit b4fee43
// 🔴 這裡以前有第四張表:`templateOwnedDirNames = {"system-dev": true}`
// ——「目錄名等於 system-dev ⇒ 任何深度、所有模式,整棵剪掉」
// **inkstone/Arcrun#180leo 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=「有人在這一層跑建置工具」的佐證檔。
//
@@ -237,7 +255,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 +263,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 +279,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 +355,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 +405,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
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -367,13 +478,23 @@ func otherWikiDirs(absRoot string) []string {
// 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)
@@ -400,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),內容與主資料夾重複"
}
@@ -411,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 是相對監看根的路徑。
@@ -443,6 +570,56 @@ func (p IngestPlan) KeepsFile(relSlash string) bool {
if toolOwnedFileNames[filepath.Base(relSlash)] {
return false
}
// 🔴 inkstone/Arcrun#1342026-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#1802026-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 三個環裡真正關掉迴圈的是 tidysnapshotCards 那兩個
//(產物只落 `.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+"/")
+165 -6
View File
@@ -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("策略=%swant %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("送了 %vwant %v程式碼旁邊的 .md 不該收)", got, want)
t.Fatalf("送了 %vwant %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("策略=%swant %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("送出 %vwant %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)
}
}
}
// 🔴 接線:**模式選擇的判準只有一個地方**。
//
// 這一票的同款形狀出現過四次(能力做好了,卻不在會被執行的那條路上)。
+19 -3
View File
@@ -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 ↔ #1342026-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
+7
View File
@@ -107,6 +107,13 @@ type Manifest struct {
// 雲端一壞就是每 5 秒撞一次。內容變了(雜湊不同)視同新卡,立即可再試。
InventoryFailHash string `json:"inventory_fail_hash,omitempty"`
InventoryNextRetry int64 `json:"inventory_next_retry,omitempty"`
// SourceOriginRepairedAt=這個監看根的「### 出處」已全部改成
// 「機器 › 知識庫 › 庫內路徑」的時間(unix 秒,0=還沒修過)。
// 🔴 `inkstone/Arcrun#167`:舊卡寫的是 `../<檔名>`daemon 的內部目錄結構),
// AI 照著答,使用者走不到那個檔。修產生端只治得了新卡,既有的要就地重推
// (見 sourcerepair.go)。這一格的存在是為了**修完就不再每輪掃全庫**——
// 沒有它,這個修復會變成一個永遠在跑的背景工作。
SourceOriginRepairedAt int64 `json:"source_origin_repaired_at,omitempty"`
// PendingTakedowns=改名/搬移後「舊路徑」在雲端知識庫裡還沒下架成功的待辦清單
// key=舊相對路徑,value=該路徑當時導出的頁名)。InkStoneCo#44 ⑩:
+48 -6
View File
@@ -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#180leo 2026-08-28):**「不收」不准再實作成「不走進去」。**
// leo 原話:「我要的就是雲端看到**所有的 folder** 像 tree 一樣呈現,去遍歷就好了」
// 「所有的 system-dev 都可以展開,**因為就算沒有可萃的它也有下層**」。
// 實測差距:`youlinhsieh-test1` 地端 `find -type d` 有 13 個目錄,
// 雲端的樹只有 3 個節點——樹在 `system-dev` 那一節就斷了。
// ⇒ 收檔範圍那幾條(curated-wiki 只走 wikidocs-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 驗收條件 23:判準從**路徑前綴**換成**內容**
// 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++
+213
View File
@@ -0,0 +1,213 @@
// sourcerepair.go — 既有卡片的「### 出處」就地修正(`inkstone/Arcrun#167`)。
//
// 病(leo 2026-08-27 用 n8n 實測抓到):卡片內文的「### 出處」寫的是 `../<檔名>`
// 那是**卡片檔相對於原檔**的內部目錄結構。它被當成知識內容存進 KBDB,於是任何
// 接上 MCP 的 AI 問「原文在哪」都照著答——而使用者拿著 `../小果被AFTEE詐貸.pdf`
// 走不到任何地方。原稿在子資料夾時更慘:資料夾整段被弄丟。
//
// 修在產生端(wikishape.go 的 renderSourceLine)只治得了**新卡**。
// 票上寫死了「不能只修新的」⇒ 這支檔是對**既有資料**的處置。
//
// 為什麼是「就地修 + 重推」而不是「重萃」(三選一的取捨):
//
// 重萃 每份檔要再燒一次 AI 額度,而且模型每次判斷不同 ⇒ 使用者眼前的卡會無故變樣。
// 出處那一塊**根本不是模型寫的**(它是機械組裝的),為它重萃是白花錢。
// 留著 票上明文禁止。
// 就地修 ✅ 卡片檔在本機還在 ⇒ 重畫那一塊、原樣重推。零 LLM、內容其餘一字不動。
//
// 重推為什麼是安全的:`rag_ingest_card` 進門先刪同 page_name+同 source_path 的舊
// blockstriplets 再寫(見該 workflow 的 list_old_blockspick_stale)⇒ 同一張卡
// 推第二次是取代,不是疊加。
//
// 三條自我約束:
// - **一次只修一批**sourceRepairBatch):巨量資料夾不該一輪湧完。
// - **修完蓋章就不再跑**Manifest.SourceOriginRepairedAt):不是每輪掃一次全庫。
// - **推失敗不蓋章**:下一輪繼續,但也不重推已經成功的(章是逐檔記在 wiki manifest 上)。
package collector
import (
"os"
"path"
"path/filepath"
"strings"
"time"
)
// sourceRepairBatch=單輪最多修幾份文件(含重推)。
// 與 cloudAuditBatch 同一個精神:積壓再多也不該一輪湧完。
const sourceRepairBatch = 20
// legacySourceMark=舊形出處的指紋。只認這一個字面,認不到就不動它
//(已經是新形的、或使用者自己手改過的,一律不碰)。
const legacySourceMark = "- `../"
// sourceHeading=出處那一塊的標題行。
const sourceHeading = "### 出處"
type sourceRepairResult struct {
Scanned int // 這一輪看過幾份文件
Rewrote int // 本機卡片被重畫的份數
Repushed int // 成功重推上雲的份數
Err string // 最後一個錯誤(原文照留,leo 2026-08-06 的原則)
Done bool // 這個監看根已經全部處理完
}
// hasLegacySourceLine 回答「這張卡的出處還是舊形嗎」。
func hasLegacySourceLine(card string) bool {
i := strings.Index(card, sourceHeading)
if i < 0 {
return false
}
return strings.Contains(card[i:], legacySourceMark)
}
// rewriteSourceBlock 把卡片裡的「### 出處」整塊換成新形。
// 找不到那一塊=不是本塑形層產的卡,原樣回傳(不亂加東西)。
func rewriteSourceBlock(card string, o SourceOrigin, cardName string) string {
i := strings.Index(card, sourceHeading)
if i < 0 {
return card
}
rest := card[i+len(sourceHeading):]
// 區塊結束=下一個標題行或檔尾。出處是「## 關聯」的最後一個 H3,
// 實務上就是檔尾,但仍照界線處理(之後有人在它後面加段落也不會被吃掉)。
end := len(card)
for off := 0; ; {
nl := strings.Index(rest[off:], "\n")
if nl < 0 {
break
}
off += nl + 1
line := rest[off:]
if j := strings.Index(line, "\n"); j >= 0 {
line = line[:j]
}
if strings.HasPrefix(line, "#") {
end = i + len(sourceHeading) + off
break
}
}
var b strings.Builder
renderSourceLine(&b, o, cardName)
return card[:i] + b.String() + card[end:]
}
// cardNameOfFile 從卡片相對路徑取卡名(=檔名去副檔名;BuildWikiDoc 的落點規則)。
func cardNameOfFile(rel string) string {
return strings.TrimSuffix(path.Base(filepath.ToSlash(rel)), ".md")
}
// repairCardSourceBlocks 掃一個監看根的 wiki manifest,把還是舊形出處的卡就地修好,
// 並把**文件卡**(唯一上雲的那張)原樣重推一次。
//
// dryRun 時只看不改(與整條 direct 路徑的 --dry-run 語意一致)。
// force=不看「本機卡是不是還是舊形」,一律重推一次。
// 這是給**手動補救**用的(雲端被清空過、或某一輪推到一半被中斷 ⇒ 本機新形、雲端舊形
// ⇒ 常規判準會判「不用修」而永遠不補推)。daemon 常規路徑一律 false。
func repairCardSourceBlocks(cfg *DirectConfig, absRoot string, m *Manifest, dryRun, force bool, now time.Time) *sourceRepairResult {
if m == nil || (!force && m.SourceOriginRepairedAt > 0) {
return nil // 這個根已經修完,不再每輪掃
}
wm := loadWikiManifest(absRoot)
if len(wm.Docs) == 0 {
if !dryRun {
m.SourceOriginRepairedAt = now.Unix() // 沒有 .wiki 產物=沒東西可修,蓋章收工
}
return nil
}
mach := cfg.machineIdentity()
library := cfg.libraryFor(absRoot)
res := &sourceRepairResult{}
remaining := 0
for di := range wm.Docs {
d := &wm.Docs[di]
if len(d.Cards) == 0 {
continue
}
libPath := path.Join(nodeFromKey(d.Node), filepath.ToSlash(d.Path))
// 這一份的卡有沒有舊形出處?先看文件卡(第一張),它就是上雲的那張。
docAbs := filepath.Join(absRoot, filepath.FromSlash(d.Cards[0]))
body, rerr := os.ReadFile(docAbs)
if rerr != nil {
continue // 卡不見了(使用者刪過/被搬走)=不是這支檔的題目
}
// 🔴 判準只看**文件卡**(=唯一上雲的那張),而且它是「已修好**且已推上去**」的
// 唯一憑據。實撞(2026-08-27,第一輪跑到一半被中斷):先改本機再推,被中斷之後
// 本機已是新形、雲端還是舊的 ⇒ 下一輪看本機判「不用修」⇒ **那份永遠不會補推**。
// 所以順序是**先推、推成功才寫本機**——推失敗的話本機留著舊形,下一輪自然重試。
if !force && !hasLegacySourceLine(string(body)) {
// 文件卡已是新形(代表也推過了);概念卡只落本機、不上雲,順手補齊即可。
for _, c := range d.Cards[1:] {
abs := filepath.Join(absRoot, filepath.FromSlash(c))
raw, e := os.ReadFile(abs)
if e != nil || !hasLegacySourceLine(string(raw)) {
continue
}
fixed := rewriteSourceBlock(string(raw), SourceOrigin{
MachineLabel: mach.Label, Library: library, LibraryPath: libPath,
}, cardNameOfFile(c))
if werr := writeWikiFile(absRoot, abs, []byte(fixed)); werr != nil {
res.Err = werr.Error()
}
}
continue
}
res.Scanned++
if res.Scanned > sourceRepairBatch {
remaining++
continue
}
if dryRun {
continue
}
origin := SourceOrigin{MachineLabel: mach.Label, Library: library, LibraryPath: libPath}
docCard := rewriteSourceBlock(string(body), origin, cardNameOfFile(d.Cards[0]))
// ① 先重推(零 LLM)。欄位與 direct.go 送新卡時逐欄一致,
// workflow 進門會先刪同 page_name+同 source_path 的舊 blocks ⇒ 取代不疊加。
status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.CardIngestWF), map[string]any{
"page_name": pageNameOf(libPath),
"path": libPath,
"card_content": docCard,
"library": library,
"machine": mach.ID,
"machine_label": mach.Label,
})
if perr != nil || status < 200 || status >= 300 {
if perr != nil {
res.Err = perr.Error()
} else {
res.Err = "重推知識庫失敗(HTTP " + itoa(status) + ""
}
remaining++ // 這一份沒推成功 ⇒ 本機不動(留著舊形當重試訊號)、這個根不能蓋章
continue
}
// ② 推成功了才寫本機(文件卡+概念卡)。
for _, c := range d.Cards {
abs := filepath.Join(absRoot, filepath.FromSlash(c))
raw, e := os.ReadFile(abs)
if e != nil {
continue
}
fixed := rewriteSourceBlock(string(raw), origin, cardNameOfFile(c))
if fixed == string(raw) {
continue
}
if werr := writeWikiFile(absRoot, abs, []byte(fixed)); werr != nil {
res.Err = werr.Error()
}
}
res.Rewrote++
res.Repushed++
}
if !dryRun && remaining == 0 {
m.SourceOriginRepairedAt = now.Unix()
res.Done = true
}
if res.Scanned == 0 && res.Rewrote == 0 {
return nil
}
return res
}
+67
View File
@@ -0,0 +1,67 @@
package collector
// sourcerepair_live_test.go — 對**真實實例**跑一次既有資料修復(`inkstone/Arcrun#167`)。
//
// 🔴 為什麼留在 repo 裡而不是用完就丟(同 probe_real_manual_test.go 的理由):
// 這一票的驗收條件是「**MCP client 問『原文在哪』拿到的答案能不能走到那個檔**」——
// 那不是 fixture 答得出來的。下一個人要重跑存量修復(換機器、換庫、雲端重裝過)
// 就跑這一支,不必再從頭想一次怎麼推。
//
// 預設 skip,不進 CI。它會**真的寫進知識庫**,所以要明講打的是哪個實例:
//
// LIVE_REPAIR=1 \
// LIVE_CYPHER=https://arcrun-cypher-executor.youlin-hsieh-dev.workers.dev \
// LIVE_NS=<namespace> \
// LIVE_ROOTS="/Users/x/Desktop/youlinhsieh-test1" \
// # 加 LIVE_FORCE=1 =不看本機是不是已經新形,一律重推(雲端被清空、或上一輪中斷過時用)
// go test -run TestLiveRepairSourceBlocks -v ./
//
// 🔴 紅線:LIVE_CYPHER 只准指測試實例。本測試不讀 config.json(那份含多個帳號,
// 誤跑會打到不該打的實例)——實例位址一律由呼叫者當場指名。
import (
"os"
"strings"
"testing"
"time"
)
func TestLiveRepairSourceBlocks(t *testing.T) {
if os.Getenv("LIVE_REPAIR") == "" {
t.Skip("設 LIVE_REPAIR=1 才跑(會真的寫進知識庫)")
}
cypher := strings.TrimSpace(os.Getenv("LIVE_CYPHER"))
ns := strings.TrimSpace(os.Getenv("LIVE_NS"))
roots := strings.Split(os.Getenv("LIVE_ROOTS"), ",")
if cypher == "" || ns == "" || len(roots) == 0 {
t.Fatal("需要 LIVE_CYPHER / LIVE_NS / LIVE_ROOTS")
}
if strings.Contains(cypher, "leo21c") {
t.Fatal("紅線:不准打 leo21c")
}
for _, root := range roots {
root = strings.TrimSpace(root)
if root == "" {
continue
}
cfg := &DirectConfig{
CypherURL: strings.TrimSuffix(cypher, "/"), Namespace: ns, APIKey: ns,
CardIngestWF: "rag_ingest_card",
MachineLabel: os.Getenv("LIVE_MACHINE_LABEL"),
Manifest: os.Getenv("LIVE_MANIFEST"), // 空=機器身分不落檔,只在記憶體鑄
}
// 用一份**丟棄式**的 Manifest:修復的蓋章不寫進正在跑的 daemon 的帳本,
// 免得跟它搶同一個檔(daemon 之後自己會再跑一次,重推是取代不是疊加)。
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
res := repairCardSourceBlocks(cfg, root, m, false, os.Getenv("LIVE_FORCE") != "", time.Now())
if res == nil {
t.Logf("%s:沒有需要修的卡", root)
continue
}
t.Logf("%s:掃 %d 份、本機重畫 %d 份、重推 %d 份、err=%q",
root, res.Scanned, res.Rewrote, res.Repushed, res.Err)
if res.Err != "" {
t.Errorf("%s 有失敗:%s", root, res.Err)
}
}
}
+140
View File
@@ -0,0 +1,140 @@
package collector
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
const legacyCard = `---
tags: [測試]
gloss: 測試卡
created: 2026-08-01
updated: 2026-08-01
---
# 小果被AFTEE詐貸
← [[00-INDEX]]
## 摘要
一段摘要。
## 重點
- 一條重點
## 實體
- **小果**(人物)— 當事人
## 關聯
### 內文知識關係
- 小果 >` + `> 被詐貸 >` + `> AFTEE
### 卡片關係
- [[小果被AFTEE詐貸]] >` + `> 整理出 >` + `> [[貸款爭議]]
### 出處
- ` + "`../小果被AFTEE詐貸.pdf`" + ` >` + `> 提及 >` + `> 小果被AFTEE詐貸
`
// TestRepairCardSourceBlocks 驗既有資料的處置:舊形出處就地改寫+原樣重推。
// 這是票 `inkstone/Arcrun#167` 驗收條件「不能只修新的」的機械閘。
func TestRepairCardSourceBlocks(t *testing.T) {
root := t.TempDir()
cardRel := "子資料夾/.wiki/小果被AFTEE詐貸.md"
if err := os.MkdirAll(filepath.Join(root, "子資料夾", ".wiki"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(cardRel)), []byte(legacyCard), 0o644); err != nil {
t.Fatal(err)
}
wm := &wikiManifest{Version: 1, Docs: []wikiDoc{{
Node: "子資料夾", Path: "小果被AFTEE詐貸.pdf", Status: "extracted",
Card: "小果被AFTEE詐貸", Cards: []string{cardRel},
}}}
if err := saveWikiManifest(root, wm); err != nil {
t.Fatal(err)
}
var got map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &got)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
}))
defer srv.Close()
cfg := &DirectConfig{
CypherURL: srv.URL, APIKey: "k", Namespace: "ns",
CardIngestWF: "rag_ingest_card",
MachineLabel: "教育部 Leo 的 Mac",
}
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
res := repairCardSourceBlocks(cfg, root, m, false, false, time.Now())
if res == nil || res.Repushed != 1 {
t.Fatalf("沒有重推:%+v", res)
}
// ① 本機卡不再有內部相對路徑,且定位得到子資料夾。
fixed, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(cardRel)))
if err != nil {
t.Fatal(err)
}
card := string(fixed)
if strings.Contains(card, "../") {
t.Fatalf("本機卡仍帶 `../`\n%s", card)
}
if !strings.Contains(card, "子資料夾/小果被AFTEE詐貸.pdf") {
t.Fatalf("本機卡的出處定位不到子資料夾:\n%s", card)
}
if !strings.Contains(card, "教育部 Leo 的 Mac") {
t.Fatalf("本機卡的出處沒有機器標記:\n%s", card)
}
// ② 出處以外一字不動(重萃會讓內容變樣,就地修不會)。
if !strings.Contains(card, "## 摘要\n一段摘要。") || !strings.Contains(card, "- **小果**(人物)— 當事人") {
t.Fatalf("出處以外的內容被動到了:\n%s", card)
}
// ③ 送上雲的 payload 與 direct.go 送新卡時同一組欄位,且路徑是**庫內完整路徑**。
if got["path"] != "子資料夾/小果被AFTEE詐貸.pdf" {
t.Fatalf("重推的 path 不對:%v", got["path"])
}
if got["page_name"] != "小果被AFTEE詐貸" {
t.Fatalf("重推的 page_name 不對:%v", got["page_name"])
}
if s, _ := got["card_content"].(string); strings.Contains(s, "../") {
t.Fatalf("送上雲的卡片內容仍帶 `../`\n%s", s)
}
// ④ 修完蓋章 ⇒ 不會每輪重掃、更不會每輪重推。
if m.SourceOriginRepairedAt == 0 {
t.Fatal("修完沒蓋章,會變成每輪都跑的背景工作")
}
got = nil
if again := repairCardSourceBlocks(cfg, root, m, false, false, time.Now()); again != nil {
t.Fatalf("蓋章後又跑了一次:%+v", again)
}
if got != nil {
t.Fatal("蓋章後又重推了一次")
}
}
// TestRewriteSourceBlockLeavesNewShapeAlone:已經是新形的卡不該被再動一次
//(避免「每輪都改一點」讓使用者的檔案 mtime 無故跳動)。
func TestRewriteSourceBlockLeavesNewShapeAlone(t *testing.T) {
o := SourceOrigin{MachineLabel: "m", Library: "kb", LibraryPath: "a/b.md"}
var b strings.Builder
renderSourceLine(&b, o, "卡")
card := "## 關聯\n### 卡片關係\n" + b.String()
if hasLegacySourceLine(card) {
t.Fatalf("新形被誤判成舊形:\n%s", card)
}
if rewriteSourceBlock(card, o, "卡") != card {
t.Fatal("新形卡被重寫了")
}
}
+97 -4
View File
@@ -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-28inkstone/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#180leo 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("內容逐字等於快照,應該認得出是空殼")
}
}
+87
View File
@@ -0,0 +1,87 @@
// templateuntouched.go — 「範本空殼」與「真內容」怎麼分辨
// inkstone/Arcrun#180 驗收條件 2 與 3leo 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
}
+31 -1
View File
@@ -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#1342026-08-28 實測):**隱藏目錄整棵跳過。**
//
// `<node>/.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()
+79
View File
@@ -0,0 +1,79 @@
// tidy_wiki_untouched_test.go — 舊制的「卡片歸位」不准動新制 `.wiki/` 裡的卡
// inkstone/Arcrun#1802026-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("它應該已經歸位了,原位置不該還在")
}
}
+1 -1
View File
@@ -148,7 +148,7 @@ func TestExtractWithWorkersAI_太大的檔一個請求都不送(t *testing.T) {
}))
defer srv.Close()
_, err := ExtractWithWorkersAI(srv.URL, "k", root, "巨檔.md")
_, err := ExtractWithWorkersAI(srv.URL, "k", root, "巨檔.md", testOrigin())
if err == nil {
t.Fatal("太大的檔應該直接失敗")
}
+1 -1
View File
@@ -344,7 +344,7 @@ func TestVaultFootprint_ForeignWikiFileNotClobbered(t *testing.T) {
}
defer gemmaCardStub(t, cardFixture("會議記錄", "專案"))()
if _, err := ExtractWithGemma("k-test", "gemma-test", root, srcRel); err == nil {
if _, err := ExtractWithGemma("k-test", "gemma-test", root, srcRel, testOrigin()); err == nil {
t.Fatal("目標被佔用時應報錯,不得無聲覆蓋")
}
data, _ := os.ReadFile(filepath.Join(cardDir, "會議記錄.md"))
+89 -10
View File
@@ -386,13 +386,93 @@ func entityFallback(ents []WikiEntity, selfName, selfGloss string) []WikiEntity
return []WikiEntity{{Name: selfName, Type: "概念", Desc: firstNonEmpty(selfGloss, "本卡描述的概念")}}
}
func renderSourceLine(b *strings.Builder, srcRel, cardName string) {
// SourceOrigin=「這份原稿實際在哪裡」的三件式:哪台機器 / 哪個庫 / 庫內什麼路徑。
//
// 🔴 為什麼要有這個型別(`inkstone/Arcrun#167`leo 2026-08-27 用 n8n 實測抓到):
// 從前「### 出處」寫的是 `../<檔名>`——那是**卡片檔相對於原檔**的路徑,
// 也就是 daemon 自己的目錄結構(卡在 `<node>/.wiki/`,原檔在上一層)。
// 它有兩個要命的地方:
//
// ① 它是**內部結構**,卻被寫進知識內容 ⇒ 任何讀到那塊的 AI 都照著答,
// 而使用者拿著 `../小果被AFTEE詐貸.pdf` 走不到任何地方。
// ② 原稿在子資料夾時,`../<檔名>` 把**資料夾整段弄丟了**
// (實據:`system-dev/wiki/trees/2026-08-17-today.md` 的出處只剩
// `../2026-08-17-today.md`)⇒ 連「同一個庫裡是哪一份」都定位不到。
//
// 三件式是**同一份 metadata 早就在送的那三格**librarymachinesource_path
// 見 direct.go 的 cardBody 與 rag_ingest_card 的 post_block)——
// 這裡不是新增資料,是把已經有的東西寫成人看得懂的樣子。
//
// 🔴 為什麼**不寫絕對路徑**(票上要求說明判斷):卡會離開這台機器(上雲、被別台的
// AI 讀到),而絕對路徑只在鑄它的那台成立。給了它,跨機器問「原文在哪」拿到的
// 會是一個**看起來最精確、實際上打不開**的答案——那正是 `../` 這枚坑的同一個形狀。
// 「機器 + 庫 + 庫內路徑」才是三台機器上都成立的座標:認出機器 → 打開那個庫的
// 資料夾 → 走庫內路徑。絕對路徑該由**知道監看根在哪的那一端**(daemon/portal
// 現場組,不該固化進知識內容。
type SourceOrigin struct {
// MachineLabel=人看得懂的機器稱呼(daemon 的 machine.json / config 的 machine_label)。
MachineLabel string
// Library=這份原稿所屬的知識庫名(=監看根,daemon 的 libraryFor)。
Library string
// LibraryPath**庫內**相對路徑,含子資料夾,一律 forward slash。
// 與 metadata 的 source_path 同一個值 ⇒ 卡上寫的與雲端存的對得起來。
LibraryPath string
}
// unknownOriginMark=三件式缺格時的誠實標記。
// 寧可讓使用者看到「(未知)」,也不要給一個看起來精確、實際上走不到的路徑
// ——那正是 `../` 這枚坑的形狀。
const unknownOriginMark = "(未知)"
// originSep=三件式之間的分隔符,與 portal 的來源顯示同一種
//`youlinhsieh@Leo-MBA rt-lib 檔名 第 4 段`)⇒ 使用者在兩個地方看到同一種形狀。
const originSep = " "
// Human 回一句「這份原文在哪」:`機器 › 知識庫 › 庫內路徑`。
func (o SourceOrigin) Human() string {
mach := strings.TrimSpace(o.MachineLabel)
if mach == "" {
mach = "機器" + unknownOriginMark
}
lib := strings.TrimSpace(o.Library)
if lib == "" {
lib = "知識庫" + unknownOriginMark
}
return mach + originSep + lib + originSep + o.pathOrUnknown()
}
// Ref 回三元組主詞用的那個字串=`庫名/庫內路徑`。
// 庫名未知時退回純庫內路徑——**任何情況下都不帶 `../`**。
func (o SourceOrigin) Ref() string {
lib := strings.TrimSpace(o.Library)
if lib == "" {
return o.pathOrUnknown()
}
return lib + "/" + o.pathOrUnknown()
}
func (o SourceOrigin) pathOrUnknown() string {
p := strings.TrimSpace(filepath.ToSlash(o.LibraryPath))
p = strings.TrimPrefix(p, "./")
if p == "" {
return "庫內路徑" + unknownOriginMark
}
return p
}
// renderSourceLine 寫「### 出處」那一塊:**先一行人話位置,再一行三元組**。
//
// 人話那行刻意不含 `>>``## 關聯` 段的解析(`rag_ingest_card` 的 parse_card
// 只把含兩個 `>>` 的行收成三元組 ⇒ 它進得了知識內容、進不了圖,
// 正好是我們要的(給人讀的句子不該變成一條邊)。
func renderSourceLine(b *strings.Builder, o SourceOrigin, cardName string) {
b.WriteString("### 出處\n")
b.WriteString("- `" + srcRel + "`" + triSep + "提及" + triSep + cardName + "\n")
b.WriteString("- 原文位置(機器" + originSep + "知識庫" + originSep + "庫內路徑):`" + o.Human() + "`\n")
b.WriteString("- `" + o.Ref() + "`" + triSep + "提及" + triSep + cardName + "\n")
}
// renderDocCard 文件卡(=這份文件的 hub):重點是判斷句、行內連到概念卡。
func renderDocCard(d *wikiDoc, ex *DocExtract, conceptNames []string, srcRel string) string {
func renderDocCard(d *wikiDoc, ex *DocExtract, conceptNames []string, origin SourceOrigin) string {
allowed := map[string]bool{"00-INDEX": true, d.Card: true}
for _, c := range conceptNames {
allowed[c] = true
@@ -442,12 +522,12 @@ func renderDocCard(d *wikiDoc, ex *DocExtract, conceptNames []string, srcRel str
for _, c := range conceptNames {
b.WriteString("- [[" + d.Card + "]]" + triSep + "整理出" + triSep + "[[" + c + "]]\n")
}
renderSourceLine(&b, srcRel, d.Card)
renderSourceLine(&b, origin, d.Card)
return b.String()
}
// renderConceptCard 原子概念卡。extraRels=機械補上的反向邊(雙向連結保證)。
func renderConceptCard(c WikiConcept, cardName, docCard, srcRel, created, updated string,
func renderConceptCard(c WikiConcept, cardName, docCard string, origin SourceOrigin, created, updated string,
allowed map[string]bool, extraRels []string) string {
var b strings.Builder
renderFrontmatter(&b, cleanTags(c.Tags), firstNonEmpty(sanitizeProse(c.Gloss, map[string]bool{}), cardName), created, updated)
@@ -497,7 +577,7 @@ func renderConceptCard(c WikiConcept, cardName, docCard, srcRel, created, update
for _, line := range extraRels {
b.WriteString(line + "\n")
}
renderSourceLine(&b, srcRel, cardName)
renderSourceLine(&b, origin, cardName)
return b.String()
}
@@ -525,7 +605,7 @@ func writeWikiFile(absRoot, dest string, content []byte) error {
// BuildWikiDoc 把一份文件的萃取結果落成規範形的 `.wiki/` 產物。
// 回傳本次產出的卡(相對監看根、文件卡在第一個)。
// ex.NoConcept 為真(或概念數 0)時不產卡,改走「標空」路徑(回傳空清單)。
func BuildWikiDoc(absRoot, relPath, srcText string, ex *DocExtract, now time.Time) ([]string, error) {
func BuildWikiDoc(absRoot, relPath, srcText string, ex *DocExtract, origin SourceOrigin, now time.Time) ([]string, error) {
if ex == nil {
return nil, fmt.Errorf("BuildWikiDoc: 沒有萃取結果")
}
@@ -651,7 +731,6 @@ func BuildWikiDoc(absRoot, relPath, srcText string, ex *DocExtract, now time.Tim
}
// 渲染+寫檔(先清掉這份文件上一輪產的、這一輪不再存在的卡)。
srcRel := "../" + base
var newCards []string
rel := func(cardName string) string {
return filepath.ToSlash(filepath.Join(nodeFromKey(nodeKey), wikiRelDir, cardName+".md"))
@@ -684,14 +763,14 @@ func BuildWikiDoc(absRoot, relPath, srcText string, ex *DocExtract, now time.Tim
return r, writeWikiFile(absRoot, destAbs, []byte(content))
}
docRel, err := writeCard(docCard, renderDocCard(&entry, ex, conceptNames, srcRel))
docRel, err := writeCard(docCard, renderDocCard(&entry, ex, conceptNames, origin))
if err != nil {
return nil, err
}
newCards = append(newCards, docRel)
for i, c := range concepts {
name := conceptNames[i]
content := renderConceptCard(c, name, docCard, srcRel, created, todayOf(now), allowed, extraRels[name])
content := renderConceptCard(c, name, docCard, origin, created, todayOf(now), allowed, extraRels[name])
r, werr := writeCard(name, content)
if werr != nil {
return nil, werr
+123
View File
@@ -0,0 +1,123 @@
package collector
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// testOrigin=測試用的三件式(機器/庫/庫內路徑)。
// 內容不重要,重要的是**每個呼叫端都必須交出它**——`inkstone/Arcrun#167` 的教訓是
// 「出處寫的是什麼」不能靠預設值,得由知道自己在哪一台、哪個庫的那一端說出來。
func testOrigin() SourceOrigin {
return SourceOrigin{MachineLabel: "test@Machine", Library: "test-lib", LibraryPath: "測試文件.md"}
}
// wsOriginwikishape 測試用:庫名/機器固定,庫內路徑跟著受測的 relPath 走
//(子資料夾的檔要驗「資料夾沒被弄丟」,所以這一格不能寫死)。
func wsOrigin(relPath string) SourceOrigin {
return SourceOrigin{MachineLabel: "test@Machine", Library: "test-lib", LibraryPath: relPath}
}
// TestSourceOriginNeverEmitsInternalPath 是 `inkstone/Arcrun#167` 的迴歸閘。
//
// leo 2026-08-27 用 n8n 實測:問「原文 wiki 的位置在哪裏?」,AI 照著卡片內文答
// `../小果被AFTEE詐貸.pdf`——**使用者拿著它走不到任何地方**。
// 這裡把那個形狀鎖死:不管三件式缺哪一格,輸出都不准出現 `../`。
func TestSourceOriginNeverEmitsInternalPath(t *testing.T) {
cases := []struct {
name string
origin SourceOrigin
want []string
}{
{
"三件齊全",
SourceOrigin{MachineLabel: "youlinhsieh@Leo-MBA", Library: "youlinhsieh-test1", LibraryPath: "小果被AFTEE詐貸.pdf"},
[]string{"youlinhsieh@Leo-MBA youlinhsieh-test1 小果被AFTEE詐貸.pdf", "`youlinhsieh-test1/小果被AFTEE詐貸.pdf`"},
},
{
// 🔴 票上的驗收條件三:子資料夾裡的檔要定位得到。
// 舊寫法 `../<檔名>` 會把整段資料夾弄丟(實據:inkstoneco 庫的
// system-dev/wiki/trees/2026-08-17-today.md 出處只剩 `../2026-08-17-today.md`)。
"子資料夾不准被弄丟",
SourceOrigin{MachineLabel: "youlinhsieh@Leo-MBA", Library: "inkstoneco", LibraryPath: "system-dev/wiki/trees/2026-08-17-today.md"},
[]string{"inkstoneco system-dev/wiki/trees/2026-08-17-today.md", "`inkstoneco/system-dev/wiki/trees/2026-08-17-today.md`"},
},
{
"機器未知也要誠實,不得退回相對路徑",
SourceOrigin{Library: "kb", LibraryPath: "a/b.md"},
[]string{"機器(未知) kb a/b.md"},
},
{
"庫未知",
SourceOrigin{MachineLabel: "m", LibraryPath: "a/b.md"},
[]string{"m 知識庫(未知) a/b.md", "`a/b.md`"},
},
{
"路徑未知",
SourceOrigin{MachineLabel: "m", Library: "kb"},
[]string{"m kb 庫內路徑(未知)"},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var b strings.Builder
renderSourceLine(&b, c.origin, "某卡")
got := b.String()
if strings.Contains(got, "../") {
t.Fatalf("出處帶內部相對路徑:\n%s", got)
}
for _, w := range c.want {
if !strings.Contains(got, w) {
t.Fatalf("出處少了「%s」:\n%s", w, got)
}
}
// 人話那行不准變成三元組(`## 關聯` 的解析只收含兩個分隔符的行)。
human := strings.Split(strings.TrimSpace(got), "\n")[1]
if strings.Contains(human, triSep) {
t.Fatalf("人話位置那行含三元組分隔符,會被當成一條邊:%s", human)
}
})
}
}
// TestBuildWikiDocSourceLocatesSubfolderDoc 走完整落地路徑(不是只測 renderer):
// 子資料夾裡的原稿,產出的文件卡與概念卡都要寫得出「庫內完整路徑」。
func TestBuildWikiDocSourceLocatesSubfolderDoc(t *testing.T) {
root := t.TempDir()
rel := "專案/會議/週會.md"
if err := os.MkdirAll(filepath.Join(root, "專案", "會議"), 0o755); err != nil {
t.Fatal(err)
}
src := "# 週會\n\n甲和乙討論了進度。\n"
if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(rel)), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
ex := &DocExtract{
Gloss: "週會記錄", Summary: "甲乙討論進度", Points: []string{"討論了進度"},
Concepts: []WikiConcept{{Name: "進度", Gloss: "專案進度", Summary: "專案進度的討論"}},
}
origin := SourceOrigin{MachineLabel: "youlinhsieh@Leo-MBA", Library: "inkstoneco", LibraryPath: rel}
cards, err := BuildWikiDoc(root, rel, src, ex, origin, time.Date(2026, 8, 27, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
if len(cards) == 0 {
t.Fatal("沒產出卡")
}
for _, c := range cards {
body, rerr := os.ReadFile(filepath.Join(root, filepath.FromSlash(c)))
if rerr != nil {
t.Fatal(rerr)
}
card := string(body)
if strings.Contains(card, "../") {
t.Fatalf("%s 仍帶 `../`\n%s", c, card)
}
if !strings.Contains(card, "inkstoneco 專案/會議/週會.md") {
t.Fatalf("%s 的出處定位不到子資料夾:\n%s", c, card)
}
}
}
+21 -14
View File
@@ -40,7 +40,7 @@ func TestBuildWikiDoc_CardShapeMatchesSpec(t *testing.T) {
src := "# 測試文件\n\n內文若干"
mustWrite(t, filepath.Join(root, "測試文件.md"), src)
cards, err := BuildWikiDoc(root, "測試文件.md", src, wsExtract(), wsNow)
cards, err := BuildWikiDoc(root, "測試文件.md", src, wsExtract(), wsOrigin("測試文件.md"), wsNow)
if err != nil {
t.Fatal(err)
}
@@ -58,9 +58,16 @@ func TestBuildWikiDoc_CardShapeMatchesSpec(t *testing.T) {
t.Fatalf("文件卡缺「%s」:\n%s", want, doc)
}
}
// 出處路徑相對 .wiki,真的指得到原稿(lint「出處路徑指不到原稿」項)
if !strings.Contains(doc, "`../測試文件.md`") {
t.Fatalf("出處路徑不對:\n%s", doc)
// 🔴 `inkstone/Arcrun#167`:出處寫的是「哪台機器/哪個庫/庫內什麼路徑」,
// **不再是 `../`**(那是卡片檔相對原檔的內部結構,使用者拿著它走不到任何地方)。
if !strings.Contains(doc, "`test@Machine \u203a test-lib \u203a 測試文件.md`") {
t.Fatalf("出處沒寫成「機器 › 庫 › 庫內路徑」:\n%s", doc)
}
if !strings.Contains(doc, "`test-lib/測試文件.md`"+triSep+"提及") {
t.Fatalf("出處三元組主詞不是庫內定位:\n%s", doc)
}
if strings.Contains(doc, "../") {
t.Fatalf("出處仍帶內部相對路徑 `../`\n%s", doc)
}
ca := mustRead(t, filepath.Join(root, ".wiki", "概念甲.md"))
for _, want := range []string{
@@ -84,7 +91,7 @@ func TestBuildWikiDoc_EdgesAreBidirectional(t *testing.T) {
root := t.TempDir()
src := "# 測試文件\n內文"
mustWrite(t, filepath.Join(root, "測試文件.md"), src)
if _, err := BuildWikiDoc(root, "測試文件.md", src, wsExtract(), wsNow); err != nil {
if _, err := BuildWikiDoc(root, "測試文件.md", src, wsExtract(), wsOrigin("測試文件.md"), wsNow); err != nil {
t.Fatal(err)
}
cb := mustRead(t, filepath.Join(root, ".wiki", "概念乙.md"))
@@ -100,7 +107,7 @@ func TestBuildWikiDoc_LinksClosedAndIndexishFixed(t *testing.T) {
mustWrite(t, filepath.Join(root, "文件.md"), src)
ex := wsExtract()
ex.Points = []string{"[[概念甲]] — 這行是 index 式,要被改寫", "這裡提到 [[不存在的卡]] 應拆殼"}
if _, err := BuildWikiDoc(root, "文件.md", src, ex, wsNow); err != nil {
if _, err := BuildWikiDoc(root, "文件.md", src, ex, wsOrigin("文件.md"), wsNow); err != nil {
t.Fatal(err)
}
doc := mustRead(t, filepath.Join(root, ".wiki", "文件.md"))
@@ -117,7 +124,7 @@ func TestMarkDocNoConcept_ListedOnIndex(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "發票.txt"), "金額 100")
ex := &DocExtract{NoConcept: true, Reason: "純紀錄(發票),沒有可獨立成立的判斷"}
cards, err := BuildWikiDoc(root, "發票.txt", "金額 100", ex, wsNow)
cards, err := BuildWikiDoc(root, "發票.txt", "金額 100", ex, wsOrigin("發票.txt"), wsNow)
if err != nil {
t.Fatal(err)
}
@@ -139,7 +146,7 @@ func TestBuildWikiDoc_NestedNodeAndAncestors(t *testing.T) {
src := "# 週會決議\n內容"
mustWrite(t, filepath.Join(root, rel, "週會.md"), src)
if _, err := BuildWikiDoc(root, "專案/會議/週會.md", src, wsExtract(), wsNow); err != nil {
if _, err := BuildWikiDoc(root, "專案/會議/週會.md", src, wsExtract(), wsOrigin("專案/會議/週會.md"), wsNow); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(root, "專案", "會議", ".wiki", "週會決議.md")); err != nil {
@@ -173,14 +180,14 @@ func TestBuildWikiDoc_ReextractCleansOldCards(t *testing.T) {
root := t.TempDir()
src := "# 文件\n內文"
mustWrite(t, filepath.Join(root, "文件.md"), src)
if _, err := BuildWikiDoc(root, "文件.md", src, wsExtract(), wsNow); err != nil {
if _, err := BuildWikiDoc(root, "文件.md", src, wsExtract(), wsOrigin("文件.md"), wsNow); err != nil {
t.Fatal(err)
}
// 第二輪:概念換名
ex2 := wsExtract()
ex2.Concepts = []WikiConcept{{Name: "全新概念", Gloss: "新的一句話", Summary: "新摘要", Points: []string{"新判斷"}}}
ex2.Points = []string{"重點換成 [[全新概念]] 了"}
if _, err := BuildWikiDoc(root, "文件.md", src, ex2, wsNow.Add(24*time.Hour)); err != nil {
if _, err := BuildWikiDoc(root, "文件.md", src, ex2, wsOrigin("文件.md"), wsNow.Add(24*time.Hour)); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(root, ".wiki", "概念甲.md")); !os.IsNotExist(err) {
@@ -201,7 +208,7 @@ func TestRemoveWikiDoc_CleansEverything(t *testing.T) {
root := t.TempDir()
src := "# 文件\n內文"
mustWrite(t, filepath.Join(root, "文件.md"), src)
if _, err := BuildWikiDoc(root, "文件.md", src, wsExtract(), wsNow); err != nil {
if _, err := BuildWikiDoc(root, "文件.md", src, wsExtract(), wsOrigin("文件.md"), wsNow); err != nil {
t.Fatal(err)
}
if err := RemoveWikiDoc(root, "文件.md"); err != nil {
@@ -230,10 +237,10 @@ func TestBuildWikiDoc_SameConceptFromTwoDocsDisambiguated(t *testing.T) {
Points: []string{"重點連到 [[迭代]]"},
Concepts: []WikiConcept{{Name: "迭代", Gloss: "同名概念", Summary: "摘要", Points: []string{"判斷"}}}}
}
if _, err := BuildWikiDoc(root, "a.md", srcA, ex(), wsNow); err != nil {
if _, err := BuildWikiDoc(root, "a.md", srcA, ex(), wsOrigin("a.md"), wsNow); err != nil {
t.Fatal(err)
}
if _, err := BuildWikiDoc(root, "b.md", srcB, ex(), wsNow); err != nil {
if _, err := BuildWikiDoc(root, "b.md", srcB, ex(), wsOrigin("b.md"), wsNow); err != nil {
t.Fatal(err)
}
first := mustRead(t, filepath.Join(root, ".wiki", "迭代.md"))
@@ -255,7 +262,7 @@ func TestNodeIndex_FiveFieldsPerDocLine(t *testing.T) {
root := t.TempDir()
src := "# 測試文件\n內文"
mustWrite(t, filepath.Join(root, "測試文件.md"), src)
if _, err := BuildWikiDoc(root, "測試文件.md", src, wsExtract(), wsNow); err != nil {
if _, err := BuildWikiDoc(root, "測試文件.md", src, wsExtract(), wsOrigin("測試文件.md"), wsNow); err != nil {
t.Fatal(err)
}
idx := mustRead(t, filepath.Join(root, ".wiki", "00-INDEX.md"))