2 Commits

8 changed files with 490 additions and 41 deletions
+9 -1
View File
@@ -47,7 +47,15 @@
---
## 下一版(未發佈
## 0.18.412026-08-27
- 🔴 **不叫「docs」的文件夾,現在也認得出來**:以前只有名字剛好叫 `docs``doc``documentation` 的資料夾會被讀,一個放滿 PDF、Word、筆記的資料夾只要名字不一樣(例如一個舊專案的存檔夾),就整個被當成程式碼跳過,畫面上也不會說為什麼。現在只要一個資料夾裡完全沒有程式碼、有看得懂的文件,不管叫什麼名字都會被收進來——你不用先學會「哪個按鈕可以救回一個資料夾」,大部分情況它一開始就是對的。
## 0.18.402026-08-27
- 🔴 **卡住的檔案不再擋住後面所有人**:以前只要有幾個檔一直收不上去(格式讀不了、雲端當天額度滿了…),它們就會**每一輪都繼續佔著最前面的位子**,即使那一輪根本沒去試它們。結果是排在後面、本來好好的檔案永遠輪不到——你等再久、跑再多輪都一樣,數字只會往上長不會往下掉。現在退避中的檔案會讓開,後面的檔案遞補得上來,佇列真的會往前走。
## 0.18.392026-08-27
- 🔴 **「已送上去」從今以後是真的送上去了**:以前只要雲端把請求收下來,小幫手就算它成功——就算雲端其實根本沒把它寫進你的知識庫。畫面上是綠的、數字也在跑,而 AI 一句都查不到,而且那些檔案因為被蓋了「已送」的章,**永遠不會再試一次**。現在它會看雲端真正的回覆:沒寫進去就誠實標成失敗、不蓋章、下一輪重新送。(實測:一個開發資料夾 26 份標「已送達」,雲端實際只有 4 份。)
- **太大的檔案現在會好好跟你說**:以前十幾萬字的大檔會一直送、一直失敗,而你看到的是一串沒人讀得懂的錯誤訊息,還會把每天的免費額度燒掉。現在它會先量一下,太大就不送,並且告訴你「這份檔約幾萬字、拆小一點就會自動收進來」。
+11 -9
View File
@@ -1,10 +1,10 @@
{
"_algo": 4,
"version": "0.18.38",
"fingerprint": "7add5da65c671548",
"version": "0.18.41",
"fingerprint": "8033de0cdec3537b",
"files": {
".gitignore": "4d56952b0fb13bf8f9b6c13a6d4c34a075bac3af447636a1df4335d7576e2f97",
"CHANGELOG.md": "9dd02f82a4f5417520fb20642aec004aa25c241e404621aacf6f37e549602b2c",
"CHANGELOG.md": "7f8a82a8a89483c09b76ed23773f8ef4dc56dae398e558130d40de4ddbdea107",
"DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d",
"README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f",
"check-standalone.sh": "65fbce096326791c2f103a51e76d0ad79e9e510e2a7700b49ac2d2d81f73c853",
@@ -122,11 +122,11 @@
"convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394",
"convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18",
"convert_wiring_test.go": "09e97bf32ace245b55acc7d65ee5c0bb7fd84f8fb1aab61f603f9e9d18b22f13",
"direct.go": "70ff75a04cdfff09453ff858d3e468e110057e555c402e5969221072fefe1a41",
"direct.go": "edea1388f4305d71b7738f131ed8513ac5de7b8d04db7d98f603d70ea80bf53c",
"direct_extract_test.go": "317d0057d2d368fabc406351a7477f72e074d2b8c8459b1dc021febdf16902f4",
"direct_multi_test.go": "a76593fef26231e18b7402f6f7376624d2471f8a719b3ce08fc4a0f5a3d3cf1e",
"direct_pacing.go": "2690b2fff858250028886bc3cd7793be54b4d4a03ddca41485476cb04b79f7c3",
"direct_pacing_test.go": "ba5c140082931a50c3728078502803c654c2fb9dacecda679f8f40e6ab61a0c5",
"direct_pacing.go": "b54838ce85f72ecd2d3388fa7cf89050c87fb10bd67dca07cd18d49568a8837c",
"direct_pacing_test.go": "f118ec03ab0bf51871037aa51eb442b98074e218430d244cb7d54b1d9791d738",
"direct_quota_test.go": "0d4747db434fd10f367ff88c8253263e2f2ce3559cd767de2f0b2c77bdf7a080",
"direct_retire_test.go": "fbff728362313c546c1fc01cc74094672e22a8171d4bc0329ce1b3d118c82b3c",
"direct_sync_now_test.go": "dc7a6e3d5646a470e889d30f0224cdb86be832b8cf85ffef8629e42ec577622b",
@@ -135,7 +135,7 @@
"extract_gemma.go": "9d127fa467b8c760ec46180123ec0fdadd4336290555687f16baf0b80b24c6f2",
"extract_gemma_test.go": "1fae16cc43e3de9728b88ea7151da9c836bf32cb5bead2805fbadc5e788fbab8",
"extract_test.go": "63560ecca929c6507d93b3f52988bb7ce2cd6137140eb373c520eac5ee052c76",
"extract_workersai.go": "ea77c25d2a14d905e3c7f3a7caaa3dff2fa5ceb71c98c772a258ef0e770106f3",
"extract_workersai.go": "d8f192472883e7a7f5d034d9b0e0d8be46f284bef2c500f934ec740379efffde",
"extract_workersai_test.go": "188822f4c44ee5bf3b1400e1475af314ad831296538a68c5e1006341ba63765e",
"folderindex.go": "f3dd186fd7e882ff293b3f8aa6b2582b018290ee5f14bbdc02a7ac131834fd69",
"folderindex_helpers_test.go": "2cf17b70c396225f728e8e160cfc75d7bdc76ac1ce8e574d1450b020050c0734",
@@ -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",
@@ -240,6 +240,8 @@
"tidy_test.go": "80fd37d7abf9f9fd075006da42aea73a3843c49f969e62623687519102dfe3ea",
"trigger.go": "f689f701bef08401f5d47f5b3d783d24a88fc9a9426fe41bdbbb3f9b152ab405",
"trigger_test.go": "0c2482d18cea0568cd2a0f5eae5b02a0984b9b2bc83461eb882c630d6381ca02",
"triggeroutcome.go": "25832326ecb3694d8a3a0f8a115ce8df6327b70ff61b0bec1357d57e53e81836",
"triggeroutcome_test.go": "fc429ea5da193e6250b10f7d87690c293c21a0b4db763eedbc04fac9e1995e96",
"upload.go": "a41bb459eac8f6c114be567a2d7c53bbdb11b0e31f0bda4b78776502664273cb",
"upload_test.go": "f97d2f3bf2213e6fff94b77b7120aa4e4f23f58c7e413c0416f02be387f2cb91",
"upstream_error_visible_test.go": "6b72c903594b3eba47cfdeb40654f1f5c341757f79dfbc5c05351cd5b99c9368",
+4 -1
View File
@@ -8,5 +8,8 @@
"0.18.35": "3bd7f3fb0124bda4",
"0.18.36": "abb2f2cc2498f071",
"0.18.37": "3bb5c94f68a26a06",
"0.18.38": "7add5da65c671548"
"0.18.38": "7add5da65c671548",
"0.18.39": "316426f50e886003",
"0.18.40": "a4485965c128ef9d",
"0.18.41": "8033de0cdec3537b"
}
+21 -3
View File
@@ -1503,13 +1503,31 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
// - 上限:巨量積壓(實據 27,164 檔)不該一輪湧完;超過上限的事件本輪不碰,
// manifest 未標 ingested ⇒ 下一輪 Scan() 自然重新出現(且若使用者這期間
// 寫了新檔,新檔的 mtime 更新,下一輪排序會插到最前面,不會被積壓卡住)。
//
// 🔴 arcrun-rag#104 comment 4480t2172026-08-27):cap 過去套在「排序後的原始
// 清單」上,而不是套在「這輪真的會被嘗試」的事件上。退避中/已達重試上限的檔案
// 不會因為正在退避就往後排(mtime 沒變、排序不變)⇒ 只要前 perRunCap 名一直是
// 同一批持續失敗的檔案,它們就會**永久佔滿名額**,排在後面的事件不管跑幾輪都
// 排不到——這正是 leo 實測到的「1691→1880→1936 筆從不減少」,佇列本身就是問題。
// 修法:cap 改套在 partitionRetryEligible 分出來的 ready(會真的嘗試)事件上;
// 退避中的事件(waiting)不佔嘗試名額,讓排在它們後面、真正還沒被嘗試過的事件
// 有機會遞補上來。waiting 仍要展示(診斷用、不是安靜消失),但同樣設一個上限,
// 避免巨量積壓(實據 KB 資料夾 ~1900 筆退避中)把單輪結果與 status.json 灌爆。
orderedEvents := sortEventsNewestFirst(absRoot, payload.Events)
perRunCap := cfg.effectiveMaxEventsPerRun()
readyEvents, waitingEvents := partitionRetryEligible(m, orderedEvents, now, cfg.ForceSync, qs.inCooldown(runNow))
deferredCount := 0
if len(orderedEvents) > perRunCap {
deferredCount = len(orderedEvents) - perRunCap
orderedEvents = orderedEvents[:perRunCap]
if len(readyEvents) > perRunCap {
deferredCount = len(readyEvents) - perRunCap
readyEvents = readyEvents[:perRunCap]
}
visibleWaiting := waitingEvents
if len(visibleWaiting) > perRunCap {
deferredCount += len(visibleWaiting) - perRunCap
visibleWaiting = visibleWaiting[:perRunCap]
}
orderedEvents = append(readyEvents, visibleWaiting...)
for _, ev := range orderedEvents {
switch ev.Type {
+38
View File
@@ -107,3 +107,41 @@ func sortEventsNewestFirst(absRoot string, events []Event) []Event {
func resumeAfterCapMessage(remaining int) string {
return fmt.Sprintf("已排入佇列,下一輪會繼續處理(本輪上限已到,還有 %d 筆等待)", remaining)
}
// partitionRetryEligible 把「已依 mtime 新到舊排序」的事件分成兩組:
// - ready:這輪真的會被嘗試(會呼叫 pace()/打雲端)——沒在退避中、沒達重試上限、
// 帳號沒在額度冷卻中。
// - waiting:這輪不會被嘗試,只是單純交代原因——退避窗口未到、已達
// MaxFailBeforeSkip、或整個帳號正在額度冷卻。
//
// removed 事件不受退避/額度冷卻管制(下架本來就不看 ShouldRetry,見 direct.go 的
// case "removed"),一律歸 ready,維持既有行為不變。
//
// 🔴 為什麼要在 cap 之前先分這一刀(arcrun-rag#104 comment 4480t217):
// 舊版直接對排序後的原始清單套用 perRunCap`orderedEvents[:perRunCap]`)。
// mtime 不會因為一個檔正在退避就變新或變舊,排序因此是穩定的——只要前 perRunCap
// 名裡有幾個持續失敗的檔案,它們會**每一輪都繼續佔著那幾個名額**(即使這一輪
// 根本不會被嘗試,只是被跳過),排在它們後面、從沒被嘗試過的事件因此永遠排不到,
// 不管跑幾百輪都一樣。這正是 leo 實測「1691→1880→1936 筆從不減少」的真因:
// 不是處理得慢,是那些筆數的候補名單裡,有一大段從頭到尾沒拿到出場機會。
//
// 呼叫端該把 cap 套在這裡回傳的 ready 上,讓「退避中」的事件不佔嘗試名額,
// 把機會讓給排在它們後面、真正還沒被嘗試過的事件。
func partitionRetryEligible(m *Manifest, events []Event, now int64, force bool, coolingDown bool) (ready, waiting []Event) {
for _, ev := range events {
if ev.Type == "removed" {
ready = append(ready, ev)
continue
}
if coolingDown {
waiting = append(waiting, ev)
continue
}
if m.ShouldRetry(ev.Path, now, force) {
ready = append(ready, ev)
} else {
waiting = append(waiting, ev)
}
}
return ready, waiting
}
+125
View File
@@ -201,6 +201,131 @@ func TestDirect_LargeBacklog_ProcessedInNewestFirstBatches(t *testing.T) {
}
}
// ── 4) 退避中的檔案不該永久佔滿單輪名額(arcrun-rag#104 comment 4480t217)──
//
// 背景:leo 實測 leo21c 帳號的積壓「1691→1880→1936 筆從不減少」,
// 「已經好久沒有加過任何檔案,哪來的這些筆數?那就是之前卡住的,就是你要解決的問題」
// 「佇列就是問題本身」。
//
// 根因:舊版把 perRunCap 套在「排序後的原始清單」上。mtime 最新的幾個檔如果持續
// 失敗(進入退避),它們不會因為在退避就往後排,於是每一輪都繼續佔著最前面的
// perRunCap 個名額——即使這一輪根本不會被嘗試,只是被跳過。排在它們後面、
// 從沒被嘗試過的健康檔案因此永遠排不到,不管跑幾輪都一樣。
//
// 這個測試重現該情境:3 個 mtime 最新的檔一直失敗(模擬持續性錯誤,例如票上量到的
// 雲端 subrequest 上限或萃取回傳格式錯誤),5 個 mtime較舊、原本會成功的健康檔案
// 排在它們後面。單輪上限=3。
//
// - 第一輪:全部檔案都還沒失敗過(FailCount=0),cap 選中 mtime 最新的 3 個
// (也就是那 3 個會一直失敗的檔),全部失敗,記下退避(下次重試在 60 秒後)。
// - 第二輪(緊接著呼叫,真實時間遠不到 60 秒):那 3 個檔仍在退避中。
// 舊版行為:cap 依然套在原始排序上,選中的還是同一批退避中的檔案 ⇒
// 這一輪 0 個健康檔案被嘗試,健康檔案永遠排不到。
// 修好後的行為:退避中的檔案被分流到 waiting、不佔 ready 的名額,
// cap 改套用在 ready 上 ⇒ 健康檔案的前 3 名遞補上來,這一輪就會被嘗試並成功。
func TestDirect_StarvedBacklog_HealthyFilesEventuallyGetATurn(t *testing.T) {
root := t.TempDir()
// 3 個「一直失敗」的檔,mtime 最新(若 bug 還在,會永久佔滿 cap)。
blockers := []string{"blocker-a", "blocker-b", "blocker-c"}
for i, n := range blockers {
writeFile(t, root, n+".md", "持續失敗的內容 "+n, baseTime.Add(time.Duration(10+i)*time.Minute))
}
// 5 個「健康」的檔,mtime 較舊(排在後面,理應遞補上來)。
healthy := []string{"h5", "h4", "h3", "h2", "h1"}
for i, n := range healthy {
writeFile(t, root, n+".md", "健康內容 "+n, baseTime.Add(time.Duration(4-i)*time.Minute))
}
failingPages := map[string]bool{"blocker-a": true, "blocker-b": true, "blocker-c": true}
var mu sync.Mutex
var succeededPages []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if answeredFolderTreePost(w, r) {
return
}
body, _ := io.ReadAll(r.Body)
var m map[string]any
_ = json.Unmarshal(body, &m)
pageName, _ := m["page_name"].(string)
if strings.HasPrefix(pageName, "資料夾總覽") {
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
return
}
if failingPages[pageName] {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"success":false,"error":"boom"}`))
return
}
mu.Lock()
succeededPages = append(succeededPages, pageName)
mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
defer srv.Close()
defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"candidates": []map[string]any{{
"content": map[string]any{"parts": []map[string]any{{"text": cardFixture("卡", "測試")}}},
}},
})
})()
cfg := &DirectConfig{
WatchFolders: []string{root},
Manifest: filepath.Join(t.TempDir(), "m.json"),
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
MaxEventsPerRun: 3,
}
// 第一輪:8 個檔都還沒失敗過,cap 選中 mtime 最新的 3 個(blocker-a/b/c),全部失敗。
results1, _, _ := RunDirectOnce(cfg, false)
if got := ingestedPaths(results1); len(got) != 0 {
t.Fatalf("第一輪不該有任何成功(cap 選中的 3 個全會失敗),got %v", got)
}
var failedCount int
for _, r := range results1 {
if r.Status == "failed" {
failedCount++
}
}
if failedCount != 3 {
t.Fatalf("第一輪應該有 3 筆真的被嘗試且失敗(blocker-a/b/c),got %d%+v", failedCount, results1)
}
// 第二輪:緊接著呼叫(真實時間遠不到 60 秒退避窗口)。blocker-a/b/c 仍在退避中。
results2, _, _ := RunDirectOnce(cfg, false)
ingested2 := ingestedPaths(results2)
if len(ingested2) != 3 {
t.Fatalf("🔴 第二輪應該有 3 個健康檔案遞補上來被嘗試並成功——"+
"如果這裡是 0,代表退避中的 blocker-a/b/c 又佔滿了本輪名額,"+
"健康檔案永遠排不到(這正是 leo 實測「佇列從不減少」的那個 bug)。got %d%+v",
len(ingested2), results2)
}
wantSecond := []string{"h5.md", "h4.md", "h3.md"}
for i, w := range wantSecond {
if ingested2[i] != w {
t.Fatalf("第二輪順序=%vwant %v(健康檔案仍照 mtime 新到舊遞補)", ingested2, wantSecond)
}
}
// blocker-a/b/c 這一輪不該再被真的嘗試(還在退避中)——它們只會以「skipped」出現。
for _, r := range results2 {
if r.Path == "blocker-a.md" || r.Path == "blocker-b.md" || r.Path == "blocker-c.md" {
if r.Status != "skipped" {
t.Fatalf("退避中的 %s 這一輪不該被真的嘗試,got status=%s", r.Path, r.Status)
}
}
}
mu.Lock()
defer mu.Unlock()
if len(succeededPages) != 3 {
t.Fatalf("雲端應該收到 3 筆健康卡片,got %d: %v", len(succeededPages), succeededPages)
}
}
func ingestedPaths(results []DirectResult) []string {
var out []string
for _, r := range results {
+117 -21
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"`
@@ -237,7 +240,7 @@ func PlanIngest(absRoot string) IngestPlan {
}
evidence := shape.Evidence()
others := otherWikiDirs(absRoot)
aux := scanAuxDirs(absRoot)
if wiki := findCuratedWiki(absRoot); wiki != "" {
return IngestPlan{
Mode: IngestCuratedWiki,
@@ -245,12 +248,12 @@ func PlanIngest(absRoot string) IngestPlan {
WikiRelDir: wiki,
Reason: "這是一個開發專案(" + evidence + "),而且你已經整理好一份知識庫(" + wiki + ")——" +
"我直接讀那一份就好,不再把整個專案的原始碼與零散檔案重萃一次。",
OtherWikiDirs: others,
OtherWikiDirs: aux.otherWikiDirs,
ignore: ignore,
}
}
docs := existingDocDirs(absRoot)
docs := mergeDocDirs(existingDocDirs(absRoot), aux.autoDocDirs)
reason := "這是一個開發專案(" + evidence + "),我只讀文件、不讀程式碼。"
if len(docs) > 0 {
reason = "這是一個開發專案(" + evidence + "),我只讀文件(" +
@@ -261,11 +264,33 @@ func PlanIngest(absRoot string) IngestPlan {
Shape: shape,
DocRelDirs: docs,
Reason: reason,
OtherWikiDirs: others,
OtherWikiDirs: aux.otherWikiDirs,
ignore: ignore,
}
}
// mergeDocDirs 合併「固定三名字」與「內容判準自動找到的」兩份文件目錄清單,去重排序。
// 兩份清單本來就可能重疊(例如根層真的叫 `docs` 又剛好零程式碼)——去重才不會讓
// Reason 文案讀起來像「docs、docs」。
func mergeDocDirs(fixed, auto []string) []string {
seen := map[string]bool{}
var out []string
for _, d := range fixed {
if !seen[d] {
seen[d] = true
out = append(out, d)
}
}
for _, d := range auto {
if !seen[d] {
seen[d] = true
out = append(out, d)
}
}
sort.Strings(out)
return out
}
// findCuratedWiki 回傳第一個「存在且真的有內容」的現成 wiki 目錄(相對路徑),沒有回空字串。
//
// 「有內容」=底下至少有一個 `.md`。空目錄不算——不然一個剛跑完 template 安裝、
@@ -315,24 +340,38 @@ func existingDocDirs(absRoot string) []string {
return out
}
// otherWikiDirs 找出監看根底下**其他**地方的 wiki(子專案自己的知識庫)。
// auxDirScan=一次全樹走訪同時算出來的兩種結果(合併走訪,見 scanAuxDirs)。
type auxDirScan struct {
otherWikiDirs []string // 子專案自己的 wiki(找到但刻意不收,只講出來)
autoDocDirs []string // Phase 0:內容判準找到的「非標準命名文件目錄」(要收)
}
// scanAuxDirs 走訪一次監看根,同時算出「其他子專案的 wiki 在哪」與
// 「Phase 0:非標準命名但整棵零程式碼的文件目錄」(`inkstone/arcrun-rag#136`
// leo 2026-08-24 第三次追加,優先度高於樹狀 UI)。
//
// 🔴 為什麼找出來卻不收:leo 的 `InkStoneCo` 底下有 `products/*`、`matrix/*` 這些
// **各自獨立的 repo**,每一個都有自己的 `system-dev/wiki`。它們是**別的專案**的知識,
// 混進這個資料夾的知識庫裡,AR-Mira 搜一個主題就會回一堆分不清屬於誰的東西。
// 要收哪一個,是使用者的決定——把那個子專案自己加進看守清單即可
// 🔴 兩者原本是兩支各自獨立的函式(otherWikiDirs/新的內容判準),這裡合併成一次
// 走訪——理由是研究文件(`docs-only-skip-visibility-override-research.md` §2.5
// 明講的經濟性:兩者都要「走一次監看根、套用同一組排除規則、在沒被排除的節點上
// 做判斷」,差別只在判斷內容。合併之後每輪同步只走一次樹,不是兩次
//
// 但**一定要講出來**:使用者接了一個 monorepo 卻只看到 32 張卡,不告訴他其餘的在哪
// 他只會覺得東西不見了(票上的紅線:不要讓他猜)。
// 🔴 合併還解決了一個正確性問題(不是效能問題):如果分開各自跑一次 WalkDir
// 一個子專案自己的 `xxx/wiki/` 會被 otherWikiDirs 判定「找到但刻意不收」,
// 但新的內容判準若獨立走訪,會用「零程式碼+有文件」的邏輯把同一個 `wiki` 目錄
// **又收了一次**,直接推翻 otherWikiDirs 那條「這是別人的知識,不混進來」的
// 既有設計(該函式原本的說明就在解釋為什麼刻意不收)。同一次走訪、同一個節點只判
// 一次,兩件事天生不會互相打架。
//
// 走訪時套用與 Scan 相同的跳過規則(隱藏目錄、noise、linked worktree
// 已知的 curated 位置自己),所以出貨用 worktree 與 templatefs 的那十幾份不會列進來。
func otherWikiDirs(absRoot string) []string {
seen := map[string]bool{}
// 🔴 判準只在**通過既有排除規則、沒被任何一條攔下**的節點上跑(隱藏目錄
// `toolOwnedDirNames`、`ambiguousBuildDirNames`+`looksGenerated`、linked worktree
// ——與 `SkipsDirWhy` 的優先序一致,所以 `node_modules` 底下的任何內容
// **根本沒有機會被走到**,不會重新引入 `#104` 的套件洩漏洞。
func scanAuxDirs(absRoot string) auxDirScan {
seenWiki := map[string]bool{}
for _, rel := range curatedWikiCandidates {
seen[rel] = true
seenWiki[rel] = true
}
var out []string
var result auxDirScan
_ = filepath.WalkDir(absRoot, func(p string, d os.DirEntry, err error) error {
if err != nil || !d.IsDir() || p == absRoot {
return nil
@@ -351,14 +390,71 @@ func otherWikiDirs(absRoot string) []string {
return nil
}
relSlash := filepath.ToSlash(rel)
if name == "wiki" && !seen[relSlash] && dirHasMarkdown(p) {
out = append(out, relSlash)
// ① 別人的 wiki——找到就講出來,但不收、不繼續往下走訪(其餘同舊行為)。
if name == "wiki" && !seenWiki[relSlash] && dirHasMarkdown(p) {
result.otherWikiDirs = append(result.otherWikiDirs, relSlash)
return filepath.SkipDir
}
// ② Phase 0:這個目錄「自己直接放的檔案」零程式碼、且至少一個文件類副檔名
// ⇒ 即使名字不叫 docs,也當文件目錄收。
//
// 🔴 只看「這個目錄自己直接放的檔案」,不遞迴檢查整棵子樹——
// 研究文件原本建議整棵子樹零程式碼才算,但真實案例
// `pms_v1_legacy` 巢狀 `pms-backup/pms_db_backup.sql`)證明那樣會讓
// `pms_v1_legacy` 自己直接放的 `PMS_USER_STORIES.md``PMS_GAP_ANALYSIS.md`
// 因為巢狀兩層深的一個 .sql 備份檔而整批繼續被跳過——治標的判準沒解決
// 票上真正的案例。改成逐層各自判斷之後,`pms_v1_legacy` 用自己的直接內容
// 合格,`pms-backup`(自己直接放著 .sql)不合格但不影響外層,而巢狀更深的
// `pms-backup/.wiki`(自己直接內容零程式碼+有 .md)又重新合格——
// 這與 Scan/畫面本來就「每個節點只算自己直接放的檔案」(`total_files` 等
// 欄位的既有語意,見 `collector/foldertree.go`)一致,不是另立一套算法。
//
// 合格就整棵收(`SkipsDirWhy``KeepsFile` 的 `onPathTo` 前綴比對本來就會
// 涵蓋子孫),不必再往下走訪找子孫裡還有沒有另一個合格點。
// 不合格則繼續遞迴——巢狀更深處仍可能有獨立合格的文件子目錄。
if !seenWiki[relSlash] && dirQualifiesAsAutoDoc(p) {
result.autoDocDirs = append(result.autoDocDirs, relSlash)
return filepath.SkipDir
}
return nil
})
sort.Strings(out)
return out
sort.Strings(result.otherWikiDirs)
sort.Strings(result.autoDocDirs)
return result
}
// dirQualifiesAsAutoDoc 回答「這個目錄自己直接放的檔案,算不算文件目錄」——
// Phase 0 的核心判準(`inkstone/arcrun-rag#136`):零程式碼副檔名+至少一個
// 文件類副檔名。**只看直接放在這個目錄裡的檔案**,不遞迴看子目錄(見呼叫端說明)。
//
// 🔴 「零程式碼」而非「程式碼佔比低於門檻」:leo 08-24 第三次留言明講不要用比例——
// 一個真正的程式碼目錄(`scripts/` 這種文件寫得多的)不該因為比例低就被誤判成文件夾。
// 副檔名表沿用既有的(`codeFileExts``allowedExt``docLikeExt`),不重新發明一套。
func dirQualifiesAsAutoDoc(absDir string) bool {
entries, err := os.ReadDir(absDir)
if err != nil {
return false
}
hasDoc := false
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.HasPrefix(name, ".") {
continue
}
ext := strings.ToLower(filepath.Ext(name))
if codeFileExts[ext] {
return false // 一個程式碼副檔名就整個不合格——零門檻,見上方說明
}
if allowedExt[ext] || docLikeExt[ext] {
hasDoc = true
}
}
return hasDoc
}
// ─────────────────────────────────────────────────────────────────────────────
+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)
}
}
}
// 🔴 接線:**模式選擇的判準只有一個地方**。
//
// 這一票的同款形狀出現過四次(能力做好了,卻不在會被執行的那條路上)。