refactor(collector): 剪掉臍帶——collector/ 自己就算得出自己的版本號(D95 第一輪②)

leo 2026-08-17:「身為管理者,你要從頭到尾**不要有很多扭曲**,因為你根本不記得
你做的這些扭曲,**每次都要查**,很直接,源碼、產出物,從 stage 到 prod。」

`collector/` 原本要伸手到 repo 根才算得出自己的版本,共四條:

  ① `daemon-version.py` 讀 `docs-site/src/content/docs/help/changelog.md`
  ② `daemon-version.py` 讀 `ROOT/DAEMON_LINE`
  ③ `daemon-version.py` 的原始碼指紋從 repo 根 `git ls-files collector`
  ④ `changelog-section.sh` 讀 `$REPO_ROOT/docs-site/...` 並 exec
     `$REPO_ROOT/installer/scripts/daemon-notes.mjs`
  ⑤(複驗才發現的第五條)`build-msix.sh` 用 `git rev-parse --show-toplevel`/../../.env
     推 InkStoneCo 頂層拿 MS Store Identity

現在全部落在 `collector/` 內部:

  · `collector/CHANGELOG.md`  ← 新家。**原檔同時裝著兩條版本線**
    (桌面版 `v0.18.x` + 雲端引擎 `1.4.x`),已按版號格式拆開;
    雲端那 17 段原地不動留在 docs-site,桌面版這 30 段搬過來。
  · `collector/DAEMON_LINE`   ← 從 repo 根搬進來
  · 指紋改以 `collector/` 為根算(`cwd=COLLECTOR`、pathspec `.`)
    ⇒ 相對路徑前綴變了、而路徑有進雜湊 ⇒ `FINGERPRINT_ALGO` 3→4,
      照既有設計讓帳本自動整本作廢重記(不要手改 JSON)
  · `daemon-notes.mjs` 實作搬進 `collector/cmd/arcrun-app/`,
    `installer/scripts/daemon-notes.mjs` 變薄殼轉呼叫
    ⇒ **根可以往內伸手,collector 不可以往外伸手**,方向單向
  · `build-msix.sh` 改成往上找「帶著那把鍵的 .env」,
    leo 08-06「腳本自己去讀不要再問人」原樣保留,但不再綁目錄結構

驗收閘:`collector/check-standalone.sh`——不是 grep,是**行為證明**:
把 collector/ 的檔案單獨複製到 repo 之外的臨時目錄、在那裡 git init,
再跑版本計算與打包前置閘。跑得起來=真的自足。

自己發現並修掉的三件:
  · `arcrun-tray/assets/store/` 與 `--setup` 其實還活著(見上一顆 commit)
  · 兩支同名的 `daemon-notes.mjs` CLI 守衛比的是**檔名尾綴**
    ⇒ 兩支一起開火,問雲端版號時 collector 那支先 exit(1),
      薄殼根本沒機會查 docs-site。改成比絕對路徑。
  · `collector/CHANGELOG.md` 的「怎麼出新版」說明若照抄那行標題,
    會被 `daemon-version.py` 的純字串比對當成「有待發佈內容」而誤升版
    ⇒ 說明裡刻意不寫成真的標題,並把這個邊角寫在檔案裡

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 13:05:33 +08:00
parent cef157c584
commit bd25efaae5
10 changed files with 725 additions and 22 deletions
+2 -1
View File
@@ -14,7 +14,8 @@ VERSION="${VERSION:-$(./daemon-version.py --stamp)}"
# 🔴 2026-08-06 機械閘:這一版的「更新內容」沒寫進 changelog ⇒ 不准打包。
# leo:「給版本號、本版更新內容、打包產品、顯示在前端,這一整串都應該是機械化」。
# 單一真相源=docs-site/.../help/changelog.mdmanifest.notes 與前端全部投影自它。
# 單一真相源=collector/CHANGELOG.mdmanifest.notes 與前端全部投影自它。
# 2026-08-18 D95 第一輪:從 docs-site 搬進 collector/daemon 才算得出自己的版本。)
# 理由與格式見 changelog-section.sh。
./changelog-section.sh "$VERSION" --check
+19 -2
View File
@@ -79,8 +79,25 @@ echo "🏷 msix 版本:${MSIX_VERSION}(來源 ${VERSION_RAW}"
# 我卻每次都當成「只有他有」而開口問。**腳本自己去讀,不要再問人。**
# (對應鐵律:`system-dev/wiki/credentials-map.md` —— 用任何金鑰前先查那張表;
# 查得到就自己拿,不要說「我沒有」。)
ENV_FILE="${ENV_FILE:-$(cd "$(dirname "$0")" && git rev-parse --show-toplevel 2>/dev/null)/../../.env}"
if [[ -z "${IDENTITY_NAME:-}" && -f "$ENV_FILE" ]]; then
#
# 🔴 2026-08-18D95 第一輪):原本是 `git rev-parse --show-toplevel`/../../.env
# ——那是**用本 repo 在磁碟上的位置**去推 InkStoneCo 頂層,
# collector/ 一旦搬成獨立 repo(或只是被 clone 到別處)就指到不存在的路徑。
# 改成**往上找那個帶著這把鍵的 .env**:leo 08-06 的要求原樣保留
# (腳本自己去讀、不再問人,金鑰的家仍是頂層那一份),
# 但**不再依賴目錄結構長什麼樣**。找不到就照舊落到 PLACEHOLDER 並印警告,
# 不會安靜出一顆送不了審的 msix。
if [[ -z "${ENV_FILE:-}" ]]; then
_d="$(pwd)" # 腳本開頭已 cd 到自己的目錄
while [[ "$_d" != "/" ]]; do
if [[ -f "$_d/.env" ]] && grep -qE '^MS_STORE_ARCRUN_APP_NAME=' "$_d/.env" 2>/dev/null; then
ENV_FILE="$_d/.env"; break
fi
_d="$(dirname "$_d")"
done
fi
ENV_FILE="${ENV_FILE:-}"
if [[ -z "${IDENTITY_NAME:-}" && -n "$ENV_FILE" && -f "$ENV_FILE" ]]; then
# 只取這三個鍵,不把整包 .env 灌進環境(其他都是不該碰的金鑰)
IDENTITY_NAME="$(grep -E '^MS_STORE_ARCRUN_APP_NAME=' "$ENV_FILE" | cut -d= -f2-)"
PUBLISHER="$(grep -E '^MS_STORE_ARCRUN_APP_PUBLISHER=' "$ENV_FILE" | cut -d= -f2-)"
+3 -2
View File
@@ -23,7 +23,7 @@ export PATH="$PATH:$(go env GOPATH)/bin"
# 🔴 2026-08-06:版本號**不再手打、也不再靠 git describe**。
# 舊寫法 `git describe --tags` 在這個 repo 永遠拿不到 v0.18.x(一個 tag 都沒有)
# ⇒ 版本只能靠打包時人工 `VERSION=v0.18.4 ./build-win.sh` 敲 ⇒ manifest 說謊的病根。
# 現在由 daemon-version.py 從 DAEMON_LINE + collector/ 的 commit 數算出來,
# 現在由 daemon-version.py 從 collector/DAEMON_LINE + collector/CHANGELOG.md 算出來,
# 兩條打包線(win/mac)在同一個 commit 上必得同一個號碼。理由全文見該腳本。
VERSION="${VERSION:-$(./daemon-version.py --stamp)}"
BUILD_TIME="$(date '+%Y%m%d-%H%M')"
@@ -31,7 +31,8 @@ echo "🏷 版本:${VERSION}build ${BUILD_TIME}"
# 🔴 2026-08-06 機械閘:這一版的「更新內容」沒寫進 changelog ⇒ 不准打包。
# leo:「給版本號、本版更新內容、打包產品、顯示在前端,這一整串都應該是機械化」。
# 單一真相源=docs-site/.../help/changelog.mdmanifest.notes 與前端全部投影自它。
# 單一真相源=collector/CHANGELOG.mdmanifest.notes 與前端全部投影自它。
# 2026-08-18 D95 第一輪:從 docs-site 搬進 collector/daemon 才算得出自己的版本。)
# 理由與格式見 changelog-section.sh。
./changelog-section.sh "$VERSION" --check
+13 -6
View File
@@ -5,7 +5,11 @@
#
# 答:改的是「**寫在哪**」,不是「誰來寫」。
# 文字一定得人寫(機器編不出「語意搜尋什麼都搜不到」這種人話),
# 但**只准寫一個地方** docs-site/src/content/docs/help/changelog.md(用戶語言那份)。
# 但**只准寫一個地方** collector/CHANGELOG.md(用戶語言那份)。
# 🔴 2026-08-18(D95 第一輪):這份檔案原本住在 repo 根的
# `docs-site/src/content/docs/help/changelog.md`,而且**同時裝著兩條版本線**
# (桌面版 v0.18.x + 雲端引擎 1.4.x)。已拆開:桌面版的搬進 `collector/`
# 雲端那條原地不動。理由:daemon 得往上伸手才拿得到自己的版本號=搬不成獨立 repo。
# 其餘全是投影、不准手改(=頂層 principles「投影不可手改,要改去改源頭」):
# · manifest.daemon.notes ← 打包時由本腳本抽出
# · docs-site 的「版本說明」頁 ← 本來就是它自己
@@ -20,8 +24,10 @@
# ./changelog-section.sh v0.18.7 --check # 只檢查存不存在
set -euo pipefail
cd "$(dirname "$0")"
REPO_ROOT="$(git rev-parse --show-toplevel)"
FILE="$REPO_ROOT/docs-site/src/content/docs/help/changelog.md"
# 🔴 一律以**自己的位置**定位(往上兩層=collector/),不問 git、不問 repo 根——
# `git rev-parse --show-toplevel` 就是往上伸手的入口,剪掉它才搬得成獨立 repo。
COLLECTOR="$(cd ../.. && pwd)"
FILE="$COLLECTOR/CHANGELOG.md"
VERSION="${1:?用法:changelog-section.sh <版本,例 v0.18.7> [--check]}"
CHECK_ONLY="${2:-}"
@@ -39,7 +45,7 @@ if [ -z "$(printf '%s' "$SECTION" | tr -d '[:space:]')" ]; then
cat >&2 <<MSG
❌ changelog 裡沒有 ${VERSION} 的段落,不准打包。
請在 docs-site/src/content/docs/help/changelog.md 最上面加一段:
請在 collector/CHANGELOG.md 最上面加一段:
## ${VERSION}$(date '+%Y-%m-%d')
@@ -55,7 +61,8 @@ fi
[ "$CHECK_ONLY" = "--check" ] && { echo " ✅ changelog 有 ${VERSION} 的段落"; exit 0; }
# 給 manifest.daemon.notes 用的投影——**只有一個投影器**:installer/scripts/daemon-notes.mjs
# 給 manifest.daemon.notes 用的投影——**只有一個投影器**:同目錄的 daemon-notes.mjs
# 2026-08-18 從 installer/scripts/ 搬來;那邊只剩一層薄殼轉呼叫本目錄這支。)
#
# 🔴 2026-08-08 修(leo 真機看到 v0.18.24 的更新畫面):
# leo 原話「**不要這麼長的散文,簡短講改了什麼,細節去 docs 讀。**」
@@ -71,4 +78,4 @@ fi
# 現在改成委派給那唯一的投影器(只取每條的粗體標題、串成一行、超長就截並導去 docs),
# 而 `installer/scripts/ship.mjs` 的 notes 步驟會在每次出貨時自動套用它。
# ⇒ 兩邊同一份規則,不會漂移。上面的 --check 閘原樣保留(build-*.sh 仍靠它)。
exec node "$REPO_ROOT/installer/scripts/daemon-notes.mjs" "$VERSION"
exec node ./daemon-notes.mjs "$VERSION"
+134
View File
@@ -0,0 +1,134 @@
/**
* daemon-notes.mjs — 使用者在「版本與更新」畫面看到的那一行,**由 changelog 機械導出**
*
* ── 這支解什麼病(leo 2026-08-08 真機看到 v0.18.24 的更新畫面)────────────
* leo 原話:「**不要這麼長的散文,簡短講改了什麼,細節去 docs 讀。**」
*
* 他看到的是**一整面文字牆**,而且 `**粗體**` 原樣露在畫面上。
* 真兇不是文案沒寫好,是**出貨當下靠人手工排版**:
* 出貨時用一段臨時 python 把 changelog 的換行折掉塞進 `manifest.daemon.notes`
* 於是四條變成一大段;那段轉換每次出貨都要重寫一次,而且**沒人檢查結果長什麼樣**。
*
* ⇒ 與「版本號由內容算」同一種解法:**這一行也由單一真相源導出,不由人當場捏**。
*
* ── 為什麼是「只取粗體標題、串成一行」──────────────────────────────────
* 畫面那個欄位是**純文字**`main.js:215` 是 `<div class="d">${esc(u.notes)}</div>`
* ① `esc()` ⇒ 任何 markdown 符號都會原樣露出來(leo 看到的 `**` 就是這樣來的)
* ② HTML 不保留換行(`.d` 沒有 white-space:pre)⇒ 塞 `\n` 進去也**不會**變成分行
* ⇒ 唯一能讀的形狀就是**一行短句**。而 changelog 每條的 `**粗體標題**` 本來就是
* 那條的一句話摘要——直接拿它,不必另外維護第二份文案(第二份必然漂移)。
*
* ── 🔴 2026-08-18D95 第一輪):搬進 collector/,且不再往上伸手 ──────────
* 本檔原本住在 `installer/scripts/`,讀的是 repo 根的 `docs-site/.../changelog.md`。
* 那讓 **daemon 的更新說明投影器住在 daemon 之外**,而它讀的檔也在 daemon 之外
* ⇒ `collector/` 沒辦法自己交出「這一版對用戶意味什麼」這句話。
*
* 現在:實作住這裡,讀的是**同一棵樹裡的** `collector/CHANGELOG.md`(自我定位,不問 git)。
* `installer/scripts/daemon-notes.mjs` 變成薄殼,轉呼叫本檔——
* **根可以往內伸手,collector 不可以往外伸手**,方向是單向的。
*
* 用法(collector 內部):
* import { notesForVersion } from './daemon-notes.mjs';
* notesForVersion('v0.18.24') // → 一行字,或 nullchangelog 沒這版)
*/
import { readFileSync, existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
/** daemon 的 changelog`collector/CHANGELOG.md`。由本檔位置往上兩層推出來,不問 git、不問 repo 根。 */
export const CHANGELOG_PATH = join(import.meta.dirname, '..', '..', 'CHANGELOG.md');
/** 畫面上一行讀得完的上限。超過就截,並改叫使用者去看說明文件。 */
export const NOTES_MAX = 100;
const TAIL = '(細節見說明文件)';
/** 把 markdown 行內語法剝成純文字——畫面不渲染 markdown,留著就是雜訊。 */
export function stripMarkdown(s) {
return String(s)
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // [字](連結) → 字
.replace(/[*_`]+/g, '') // 粗體/斜體/行內碼標記
.replace(/\s+/g, ' ') // 換行與連續空白 → 單一空白
.trim();
}
/**
* 從 changelog 取某一版的「一句話摘要」清單。
* 規則:只認**頂層條目**(行首 `- `)的第一個粗體片段——那就是該條的標題。
* 沒有粗體的條目退而取整行(截短),因為「有寫總比漏掉好」。
*/
export function headlinesFor(changelogText, version) {
const lines = changelogText.split('\n');
const start = lines.findIndex((l) => new RegExp(`^##\\s+${version.replace(/[.\\]/g, '\\$&')}(\\D|$)`).test(l.trim()));
if (start < 0) return null;
const out = [];
for (let i = start + 1; i < lines.length; i++) {
const l = lines[i];
if (/^##\s/.test(l)) break; // 下一版開始
if (!/^-\s/.test(l)) continue; // 只取頂層條目(續行、巢狀一律略過)
const body = l.replace(/^-\s*/, '');
const bold = body.match(/\*\*([^*]+)\*\*/);
let h = stripMarkdown(bold ? bold[1] : body);
h = h.replace(/^[^\p{L}\p{N}「((]+/u, ''); // 去掉開頭的 emoji/符號
h = h.replace(/[:,。.]+$/, ''); // 去掉尾標點(要串接)
if (h) out.push(h);
}
return out;
}
/**
* 組成畫面上那一行。回傳 null=changelog 裡沒有這一版(呼叫端該當成錯誤)。
* `changelogPath` 只給測試/薄殼覆寫用;正常呼叫不帶,走 collector 自己的 CHANGELOG.md。
*/
export function notesForVersion(version, changelogPath = CHANGELOG_PATH) {
if (!existsSync(changelogPath)) return null;
const heads = headlinesFor(readFileSync(changelogPath, 'utf8'), version);
if (!heads || !heads.length) return null;
let line = heads.join('・');
if (line.length > NOTES_MAX) {
// 截到「最後一個完整條目」為止,再掛尾巴——不要把句子切一半給使用者看。
const kept = [];
for (const h of heads) {
if ([...kept, h].join('・').length + TAIL.length > NOTES_MAX) break;
kept.push(h);
}
line = (kept.length ? kept.join('・') : heads[0].slice(0, NOTES_MAX - TAIL.length)) + TAIL;
}
return line;
}
/**
* 機械閘用:這一行本身可不可以送到使用者眼前?
* 回傳問題清單(空=通過)。這道閘存在的理由=**手寫的那一行沒有任何人檢查**。
*/
export function checkNotes(notes) {
const problems = [];
const s = String(notes ?? '');
if (!s.trim()) return ['manifest.daemon.notes 是空的——使用者按「檢查更新」看不到這版改了什麼'];
if (/[*_`#]|\]\(/.test(s)) {
problems.push(`manifest.daemon.notes 裡有 markdown 符號,畫面是純文字會原樣露出來:${JSON.stringify(s.slice(0, 60))}`);
}
if (/\n/.test(s)) {
problems.push('manifest.daemon.notes 有換行——畫面不保留換行(.d 沒有 white-space:pre),會擠成一坨');
}
if (s.length > NOTES_MAX + TAIL.length) {
problems.push(`manifest.daemon.notes 太長(${s.length} 字,上限 ${NOTES_MAX + TAIL.length})——leo 08-08:「不要這麼長的散文,簡短講改了什麼,細節去 docs 讀」`);
}
return problems;
}
// CLI:印出某版會顯示的那一行(出貨前想先看一眼時用)
// 🔴 2026-08-18:判斷「是不是直接跑本檔」要比**絕對路徑**,不能比檔名尾綴——
// `installer/scripts/daemon-notes.mjs` 薄殼同名,用尾綴比會讓兩支 CLI 一起開火
// (實撞:問雲端版號時本檔先 process.exit(1),薄殼根本沒機會查 docs-site)。
if (process.argv[1] && resolve(process.argv[1]) === import.meta.filename) {
const v = process.argv[2];
if (!v) { console.error('用法:node collector/cmd/arcrun-app/daemon-notes.mjs <版本,例 v0.18.24>'); process.exit(2); }
const line = notesForVersion(v);
if (!line) { console.error(`❌ changelog 沒有 ${v} 這一版(${CHANGELOG_PATH}`); process.exit(1); }
// stdout **只有那一行**——它會被別的腳本(changelog-section.sh)直接取用,
// 多印一個字就會被塞進 manifest。其餘一律走 stderr。
console.log(line);
console.error(`${line.length} 字)`);
const probs = checkNotes(line);
if (probs.length) { probs.forEach((p) => console.error('❌ ' + p)); process.exit(1); }
console.error('✅ 可以送到使用者眼前');
}
+37 -11
View File
@@ -15,8 +15,24 @@
但**每個 commit 都變成一版**(一天 5 個 commit 就跳 5 版),
而且每跳一版就要補一段 changelog——把機械化變成新的手工活。
── 🔴 2026-08-18(D95 第一輪):所有輸入都搬進 collector/ 了 ────────────
leo 08-17:「身為管理者,你要從頭到尾**不要有很多扭曲**,因為你根本不記得
你做的這些扭曲,**每次都要查**,很直接,源碼、產出物,從 stage 到 prod。」
本腳本原本往 **repo 根**伸手拿三樣東西:
① docs-site/src/content/docs/help/changelog.md(版本與更新說明)
② ROOT/DAEMON_LINE(版本線)
③ `git ls-files collector`(原始碼指紋,從 repo 根算)
⇒ `collector/` 算不出自己的版本,也**沒有資格被搬成獨立 repo**。
現在三樣全在 `collector/` 底下:`CHANGELOG.md``DAEMON_LINE`/指紋以 collector 為根。
本檔一律以**自己的位置**定位(`__file__` 往上兩層=collector/),
**不再呼叫 `git rev-parse --show-toplevel`**——那是往上伸手的入口。
(三道既有修補全部原樣保留:指紋演算法版本作廢重記、帳本自我參照排除、
沒有產物的版號可重戳。只有「以什麼為根」變了。)
── 現在的做法:版本由 changelog 決定,且不用手打數字 ──────────────
單一真相源= docs-site/src/content/docs/help/changelog.md(用戶語言那份)。
單一真相源= collector/CHANGELOG.md(用戶語言那份)。
· 要出新版 ⇒ 在檔案最上面加一段標題 `## 下一版(未發佈)`,底下寫白話更新內容。
· 打包時本腳本把它**戳成正式版號**(上一版 patch + 1)並補上今天日期。
@@ -40,17 +56,21 @@ import subprocess
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
ROOT = Path(subprocess.check_output(
["git", "rev-parse", "--show-toplevel"], cwd=HERE, text=True).strip())
CHANGELOG = ROOT / "docs-site/src/content/docs/help/changelog.md"
LINE_FILE = ROOT / "DAEMON_LINE"
HERE = Path(__file__).resolve().parent # collector/cmd/arcrun-app
# 🔴 daemon 的根=`collector/`,用**自己的位置**推出來,不問 git 也不問 repo 根。
# 這一行就是「臍帶剪掉了」的本體:往上兩層剛好是 collector/,再往上一步都不走。
COLLECTOR = HERE.parents[1]
CHANGELOG = COLLECTOR / "CHANGELOG.md"
LINE_FILE = COLLECTOR / "DAEMON_LINE"
UNRELEASED = "## 下一版(未發佈)"
# 版本 → 當時原始碼指紋。用來擋「同一個版號、不同的執行檔」。
SOURCE_LOCK = HERE / ".version-source.json"
# 指紋演算法版本:改算法時 +1,帳本會自動作廢重記(見 check_or_record)。
FINGERPRINT_ALGO = 3
# 🔴 3 → 42026-08-18):指紋的**根**從 repo 根換成 collector/,被雜湊的相對路徑
# 因此全部改變(`collector/cmd/…` → `cmd/…`)⇒ 舊帳本是「用不同單位量出來的數字」,
# 比對沒有意義。照既有設計改號讓它自動整本作廢重記,不要手改 JSON。
FINGERPRINT_ALGO = 4
RELEASED_RE = re.compile(r"^## v(\d+)\.(\d+)\.(\d+)", re.M)
@@ -72,11 +92,17 @@ def source_fingerprint():
會給出不同指紋、即使內容一模一樣——戳版號時檔案還沒提交(在 diff 裡),
提交後樹變了、diff 空了。結果只改 wiki 也被判「版號已對應另一份原始碼」。
改成對工作區檔案內容取值,與有沒有提交無關。
四修(2026-08-18D95 第一輪):
改成**以 `collector/` 為根**列檔(cwd=COLLECTOR、pathspec `.`),
不再從 repo 根列 `collector` 這個子目錄。涵蓋的檔案集合完全相同,
差別只有相對路徑前綴——而路徑有進雜湊,所以 FINGERPRINT_ALGO 跟著 +1。
這樣 collector/ 搬成獨立 repo 之後,同一段程式碼算出的仍是同一個值。
"""
try:
files = subprocess.check_output(
["git", "ls-files", "-co", "--exclude-standard", "collector"],
cwd=ROOT, text=True).split("\n")
["git", "ls-files", "-co", "--exclude-standard", "."],
cwd=COLLECTOR, text=True).split("\n")
except subprocess.CalledProcessError:
return "" # 不在 git 裡就不擋(例如從 tarball 解出來 build
# 🔴 2026-08-06 三修:**帳本自己不能算進指紋**(經典的自我參照)。
@@ -86,11 +112,11 @@ def source_fingerprint():
# dist/ 也早就 gitignore,不在名單裡。真兇只有帳本。)
# 只排除帳本一個檔——**不要順手把 build/ 整個排掉**,
# 那會讓「換 app icon」不算原始碼變更,等於把閘挖個洞。
skip = {str(SOURCE_LOCK.relative_to(ROOT))}
skip = {str(SOURCE_LOCK.relative_to(COLLECTOR))}
h = hashlib.sha256()
for rel in sorted(f for f in files if f.strip() and f not in skip):
fp = ROOT / rel
fp = COLLECTOR / rel
if not fp.is_file():
continue # 已刪除的檔案
h.update(rel.encode())