#!/usr/bin/env node // migrate-tz.mjs — 一次性遷移:修正既有人格資料裡的時區資料。 // // 背景 // ---- // `persona-lib.mjs` 的 `iso()`/`nowIso()`(約第 140 行)現在會把時刻位移到台北時區 // 再輸出 `+08:00` 字尾;`todayTaipei()`(約第 168 行)也是同一套位移邏輯,取代了 // 「錯誤的 `nowIso().slice(0, 10)`」(那個註解本身就是在講:舊版 `iso()` 曾經機械地 // 回傳 UTC `Z` 時間戳,沒有位移,直接切前 10 個字元拿到的是 UTC 當天,不是台北當天)。 // // 這兩個函式修好之後**不會回頭改舊資料**:在 UTC 16:00~23:59(台北已經跨到下一天) // 這八小時窗口內寫下的舊紀錄,時間戳本身是 `...Z` 格式(可以無損換算),但由它 // 切出來的日期鍵(例如「今天」的日記日期)算出來的是 UTC 當天,比實際的台北曆日 // 少一天。這支腳本就是把這兩類舊資料換算成正確答案: // // (1) 任何 `Z` 結尾的 ISO 時間戳字串 → 等值換算成 `+08:00` 格式(換算成同一個 // 瞬間在台北時區的牆上時間,不是機械加 8 小時:兩者在大多數時刻結果一樣, // 但概念上與程式碼上都要走「取 epoch → 用 iso() 同一套位移公式重新格式化」 // 這條路,才經得起夏令時間/閏秒之類未來變動)。 // (2) 任何「純日期」欄位(`yyyy-MM-dd`,沒有時間部分)→ 如果它所在的**同一個 // JSON 物件**裡,找得到剛好一個(不模糊)完整時間戳可以佐證「這筆記錄實際 // 發生在哪個台北曆日」,就用那個時間戳重算;同一物件裡的時間戳互相矛盾, // 或根本沒有時間戳可用,就不動它,只記到報告的「無法確定」清單。 // // 資料模型調查結論(見 persona-lib.mjs) // -------------------------------------- // * `state/mood.json`(`loadDayMood`/`updateDayMood`,約第 716~752 行): // `{ date, valence, arousal, samples, updated_at }` —— `date` 與 `updated_at` // 是同一個物件的兩個欄位,`updated_at` 就是這筆「當日心情底色」最後一次被寫入 // 的完整時間戳,可以直接拿來重算 `date` 應該是哪個台北曆日。這是本腳本能夠 // 自動判定重算的典型案例(也是題目說的「日記的單日紀錄」)。 // * `relations/graph.json` 的 `nodes[].rifts[]`(`openRifts`/`riftBrief`, // 約第 4854~4886 行;寫入邏輯在 persona.mjs 的 `relation rift`): // 每筆 `{ at, about, memory?, healed_at? }` 都是純日期鍵,但**同一筆 rift 物件 // 裡沒有伴隨的完整時間戳**——唯一的時間戳在它的上層節點(`node.updated_at`), // 而 `node.updated_at` 是整個節點共用、會被之後任何一次更新(改親近度、加另一件 // 還沒和好的事…)覆寫掉的欄位,不能拿來當「這一筆 rift 當時是幾點」的證據。 // 這一類欄位落進「無法確定,需人工檢視」清單,不強行用不可靠的上層時間戳去猜。 // * 自傳章節(`memory/chapters/*.md`)與長期記憶(`memory/long-term/*.md`)的 // `from`/`to`/`first_seen`/`last_seen` 等日期鍵存在 Markdown front matter 裡, // 不是 `.json` 檔——依題目指示本腳本只走 `.json`,這兩處天生不在掃描範圍內, // 如果之後要修,需要另一支專門處理 Markdown front matter 的腳本。 // * 其餘找到的日期鍵/時間戳欄位(`state/emotion.json` 的 `updated_at`/ // `streak.at`/`last_trigger.at`、`state/config.json` 的 `created_at`、 // `state/lock.json`/`guests.json`/`sleepers.json` 的 `*_at`、 // `state/loops.json` 的 `opened_at`/`touched_at`、 // `memory/import//work.json`/`names.json` 的 `created_at`/`updated_at`) // 全部都是完整時間戳,沒有「純日期」欄位,直接吃規則 (1) 就好。 // // 掃描範圍 // -------- // 只遞迴走訪 `PERSONA_HOME` 底下、排除 `.runtime`/`.rooms`(這兩個是跨人格的 // 執行期目錄,不是人格目錄)的每一個子目錄裡的每一個 `.json` 檔案;不動 // `.jsonl`(append-only 日誌,語意上是「歷史事件序列」,不是「這一筆的日期鍵」, // 且題目本身就是說 `.json`)。 // // 用法 // ---- // node migrate-tz.mjs [--dry-run] [--home ] // // 不帶 `--home` 時依序退回 `PERSONA_HOME` 環境變數、`~/.claude/personas`。 // // 安全機制 // -------- // a. 執行前一定先檢查是否有人格處於載入狀態(exclusive lock 或存活的 guest // 租約)——不管是不是 `--dry-run`。有就中止、不強行執行。 // b. 正式執行(非 `--dry-run`)一定先把整個 `PERSONA_HOME` 完整複製備份到 // `<上一層目錄>/personas.bak-/`(時間戳是台北時區)。 // `--dry-run` 不寫入任何檔案,連備份都不做。 // c. 先掃描「整個」`PERSONA_HOME`、算出全部異動計畫,**掃描階段完全不寫檔**; // 只有掃描完全結束、且沒有偵測到合併衝突,才會真正落筆——這樣「合併衝突 // 讓整批中止」才能保證「不寫入任何檔案」,不會有些檔案改了、有些沒改。 // d. 合併衝突:同一個陣列裡,兩筆以上物件成員共用同一個欄位名稱、原始值不同, // 重算後卻落在同一個台北日──這種情況不自動選一筆,整批中止、報告列出 // 衝突的原始內容,等人工決定怎麼合併再重跑。 // e. 執行完(非 dry-run)後自動驗證:全樹已經沒有 `Z` 結尾的時間戳字串; // 每一個被轉換過的時間戳欄位,轉換前後的 epoch 完全相等。任何一項不成立 // 都算嚴重錯誤(exit code 4),需要人工介入。 // // exit code:0 成功(含「沒有東西可遷移」)/1 一般錯誤/2 有人格正在載入而中止/ // 3 偵測到合併衝突而中止/4 執行後驗證失敗(嚴重錯誤)。 import fs from "node:fs"; import os from "node:os"; import path from "node:path"; // --------------------------------------------------------------------------- // // 路徑與時間工具(刻意仿照 persona-lib.mjs 的 iso()/todayTaipei(),見檔案開頭 // 註解;不 import persona-lib.mjs,讓這支一次性腳本不依賴那個模組今後怎麼演化) // --------------------------------------------------------------------------- // const TAIPEI_OFFSET_MS = 8 * 60 * 60 * 1000; const RUNTIME_DIRNAME = ".runtime"; const ROOMS_DIRNAME = ".rooms"; function expandUser(p) { if (p === "~") return os.homedir(); if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2)); return p; } function resolveHome(homeArg) { const raw = homeArg || process.env.PERSONA_HOME || "~/.claude/personas"; return path.resolve(expandUser(raw)); } /** 等同 persona-lib.mjs 的 iso():取 epoch,位移 8 小時,再格式化——不是機械加 8 小時。 */ function taipeiIsoFromEpoch(ms) { const truncated = Math.floor(ms / 1000) * 1000; const shifted = new Date(truncated + TAIPEI_OFFSET_MS); const pad = (n) => String(n).padStart(2, "0"); return ( `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}` + `T${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}+08:00` ); } /** 等同 persona-lib.mjs 的 todayTaipei():同一個瞬間換算成台北曆日。 */ function taipeiDateFromEpoch(ms) { const shifted = new Date(Math.floor(ms / 1000) * 1000 + TAIPEI_OFFSET_MS); const pad = (n) => String(n).padStart(2, "0"); return `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}`; } /** 備份檔名用的緊湊時間戳(台北時區):yyyyMMddHHmmss。 */ function taipeiCompactStamp(date = new Date()) { const shifted = new Date(Math.floor(date.getTime() / 1000) * 1000 + TAIPEI_OFFSET_MS); const pad = (n) => String(n).padStart(2, "0"); return ( `${shifted.getUTCFullYear()}${pad(shifted.getUTCMonth() + 1)}${pad(shifted.getUTCDate())}` + `${pad(shifted.getUTCHours())}${pad(shifted.getUTCMinutes())}${pad(shifted.getUTCSeconds())}` ); } // --------------------------------------------------------------------------- // // 值的形狀判斷(全部靠值的形狀,不靠欄位名稱白名單) // --------------------------------------------------------------------------- // const Z_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/; const TW_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00$/; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; /** 形狀對了還要日曆上真的存在(擋 2026-13-40 之類假日期),做法跟 persona-lib.mjs 的 isYmd 一樣。 */ function isValidCalendarDate(value) { if (!DATE_RE.test(value)) return false; const d = new Date(`${value}T00:00:00Z`); return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value; } /** 一個物件自己身上(不含巢狀子物件)所有完整時間戳,換算後的台北曆日集合。 */ function siblingTaipeiDates(obj) { const dates = new Set(); for (const value of Object.values(obj)) { if (typeof value !== "string") continue; if (Z_RE.test(value) || TW_RE.test(value)) { const t = new Date(value).getTime(); if (!Number.isNaN(t)) dates.add(taipeiDateFromEpoch(t)); } } return dates; } // --------------------------------------------------------------------------- // // 樹狀掃描:只算出「該怎麼改」,完全不動傳進來的物件、也不寫檔 // --------------------------------------------------------------------------- // /** * 掃一個檔案已經 parse 好的 JSON 樹。 * 回報三種結果: * changes 要改的欄位(timestamp:Z→+08:00;datekey:日期鍵重算) * undetermined 純日期欄位但推算不出正確台北日,維持原值 * conflicts 同一個陣列裡兩筆以上成員重算後撞在同一個台北日 */ function scanTree(root, fileLabel) { const changes = []; const undetermined = []; const conflicts = []; function visitObject(obj, pathArr) { const resolvable = siblingTaipeiDates(obj); for (const [key, value] of Object.entries(obj)) { if (typeof value === "string") { if (Z_RE.test(value)) { const epochOld = new Date(value).getTime(); const newValue = taipeiIsoFromEpoch(epochOld); changes.push({ file: fileLabel, path: [...pathArr, key], kind: "timestamp", oldValue: value, newValue, epochOld, epochNew: new Date(newValue).getTime(), }); } else if (DATE_RE.test(value) && isValidCalendarDate(value)) { if (resolvable.size === 1) { const [only] = resolvable; if (only !== value) { changes.push({ file: fileLabel, path: [...pathArr, key], kind: "datekey", oldValue: value, newValue: only }); } } else if (resolvable.size === 0) { undetermined.push({ file: fileLabel, path: [...pathArr, key], value, reason: "同一物件裡找不到可用的完整時間戳", }); } else { undetermined.push({ file: fileLabel, path: [...pathArr, key], value, reason: `同一物件裡有 ${resolvable.size} 個互相矛盾的時間戳(${[...resolvable].join(" / ")}),無法判斷歸屬哪個台北日`, }); } } } else if (value && typeof value === "object") { visit(value, [...pathArr, key]); } } } /** 同一個陣列裡,兩筆以上物件成員共用同一個欄位名稱、原始值不同,卻重算成同一個台北日。 */ function detectArrayConflicts(arr, pathArr) { const objItems = arr .map((item, i) => ({ item, i })) .filter(({ item }) => item && typeof item === "object" && !Array.isArray(item)); if (objItems.length < 2) return; const fieldNames = new Set(); for (const { item } of objItems) { for (const [k, v] of Object.entries(item)) { if (typeof v === "string" && DATE_RE.test(v) && isValidCalendarDate(v)) fieldNames.add(k); } } for (const field of fieldNames) { const groups = new Map(); // 新值 → [{index, original, item}] for (const { item, i } of objItems) { const raw = item[field]; if (typeof raw !== "string" || !DATE_RE.test(raw) || !isValidCalendarDate(raw)) continue; const dates = siblingTaipeiDates(item); if (dates.size !== 1) continue; // 這筆自己就推算不出來,不參與衝突判斷(會落進 undetermined) const [resolved] = dates; if (!groups.has(resolved)) groups.set(resolved, []); groups.get(resolved).push({ index: i, original: raw, item }); } for (const [newValue, group] of groups) { if (group.length < 2) continue; const distinctOriginals = new Set(group.map((g) => g.original)); if (distinctOriginals.size > 1) { conflicts.push({ file: fileLabel, path: [...pathArr], field, newValue, entries: group.map((g) => ({ index: g.index, path: [...pathArr, g.index, field], original: g.original, record: g.item, })), }); } } } } function visit(node, pathArr) { if (Array.isArray(node)) { detectArrayConflicts(node, pathArr); node.forEach((item, i) => { if (item && typeof item === "object") visit(item, [...pathArr, i]); }); return; } if (node && typeof node === "object") { visitObject(node, pathArr); } } if (Array.isArray(root) || (root && typeof root === "object")) { visit(root, []); } else if (typeof root === "string") { // 罕見邊界:整個檔案的內容就是一個字串(沒有其他欄位可以當 sibling)。 if (Z_RE.test(root)) { const epochOld = new Date(root).getTime(); const newValue = taipeiIsoFromEpoch(epochOld); changes.push({ file: fileLabel, path: [], kind: "timestamp", oldValue: root, newValue, epochOld, epochNew: new Date(newValue).getTime() }); } else if (DATE_RE.test(root) && isValidCalendarDate(root)) { undetermined.push({ file: fileLabel, path: [], value: root, reason: "檔案頂層就是日期字串,沒有其他欄位可用來推算" }); } } return { changes, undetermined, conflicts }; } function applyChanges(root, changes) { let newRoot = root; for (const change of changes) { if (change.path.length === 0) { newRoot = change.newValue; continue; } let cur = newRoot; for (let i = 0; i < change.path.length - 1; i++) cur = cur[change.path[i]]; cur[change.path[change.path.length - 1]] = change.newValue; } return newRoot; } // --------------------------------------------------------------------------- // // 檔案/目錄走訪 // --------------------------------------------------------------------------- // function listPersonaDirs(home) { let entries; try { entries = fs.readdirSync(home, { withFileTypes: true }); } catch { return null; // home 不存在 } return entries .filter((e) => e.isDirectory() && e.name !== RUNTIME_DIRNAME && e.name !== ROOMS_DIRNAME && !e.name.startsWith(".")) .map((e) => e.name) .sort(); } function collectJsonFiles(dir) { const out = []; const stack = [dir]; while (stack.length) { const cur = stack.pop(); let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; } for (const e of entries) { const full = path.join(cur, e.name); if (e.isDirectory()) stack.push(full); else if (e.isFile() && e.name.endsWith(".json")) out.push(full); } } return out.sort(); } // --------------------------------------------------------------------------- // // 前置檢查:有沒有人格正在被載入 // // 邏輯仿照 persona-lib.mjs 的 lockIsDead()/liveGuests()(約第 1607~1617 行、 // LEASE_SECONDS=900/GUEST_LEASE_SECONDS=1800,約第 23~24 行):只看心跳租約 // 有沒有過期,不靠 pid(CLI process 跑完就結束,不能拿 pid 存活判斷)。 // --------------------------------------------------------------------------- // const LEASE_SECONDS = 900; const GUEST_LEASE_SECONDS = 1800; function readJsonSafe(file) { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } } function checkActiveLocks(home, personaDirs) { const active = []; for (const slug of personaDirs) { const lock = readJsonSafe(path.join(home, slug, "state", "lock.json")); if (lock && typeof lock === "object" && lock.heartbeat_at) { const hbAge = (Date.now() - new Date(lock.heartbeat_at).getTime()) / 1000; const lease = Number(lock.lease_seconds) || LEASE_SECONDS; if (Number.isFinite(hbAge) && hbAge <= lease) { active.push({ slug, kind: "lock", session_id: lock.session_id, cwd: lock.cwd, heartbeat_at: lock.heartbeat_at }); } } const guestsData = readJsonSafe(path.join(home, slug, "state", "guests.json")); const guests = Array.isArray(guestsData?.guests) ? guestsData.guests : []; for (const g of guests) { if (!g || !g.heartbeat_at) continue; const hbAge = (Date.now() - new Date(g.heartbeat_at).getTime()) / 1000; if (Number.isFinite(hbAge) && hbAge <= GUEST_LEASE_SECONDS) { active.push({ slug, kind: "guest", session_id: g.session_id, room: g.room, heartbeat_at: g.heartbeat_at }); } } } return active; } // --------------------------------------------------------------------------- // // 備份 // --------------------------------------------------------------------------- // function backupHome(home) { const parent = path.dirname(home); const dest = path.join(parent, `personas.bak-${taipeiCompactStamp(new Date())}`); fs.cpSync(home, dest, { recursive: true }); return dest; } // --------------------------------------------------------------------------- // // 執行後驗證 // --------------------------------------------------------------------------- // function verifyNoLeftoverZ(home, personaDirs) { const offenders = []; for (const slug of personaDirs) { for (const file of collectJsonFiles(path.join(home, slug))) { const text = fs.readFileSync(file, "utf8"); // 粗略但夠用:找形似 ISO 時間戳、以 Z 結尾的片段(跟 Z_RE 同一個形狀, // 只是不要求整個字串就是它——用來抓「文件裡還殘留 Z 時間戳」這個粗粒度事實)。 const re = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z/g; let m; while ((m = re.exec(text))) offenders.push({ file, match: m[0] }); } } return offenders; } // --------------------------------------------------------------------------- // // CLI // --------------------------------------------------------------------------- // function parseArgs(argv) { const out = { dryRun: false, home: null, help: false }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === "--dry-run") out.dryRun = true; else if (a === "--home") out.home = argv[++i]; else if (a === "--help" || a === "-h") out.help = true; else { process.stderr.write(`未知的參數:${a}\n`); process.exit(1); } } return out; } function printHelp() { process.stdout.write( "用法:node migrate-tz.mjs [--dry-run] [--home ]\n" + " --dry-run 只掃描並印出報告,不寫入任何檔案(連備份都不做)\n" + " --home 指定 PERSONA_HOME;不帶時用環境變數 PERSONA_HOME,再退回 ~/.claude/personas\n", ); } function fmtPath(p) { return p.length ? `/${p.join("/")}` : "(root)"; } function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { printHelp(); return 0; } const home = resolveHome(args.home); console.log(`[migrate-tz] PERSONA_HOME = ${home}${args.dryRun ? "(--dry-run,不會寫入任何檔案)" : ""}`); const personaDirs = listPersonaDirs(home); if (personaDirs === null) { console.log(`[migrate-tz] 目錄不存在,沒有東西可遷移:${home}`); return 0; } if (personaDirs.length === 0) { console.log(`[migrate-tz] 目錄裡沒有人格子目錄,沒有東西可遷移:${home}`); return 0; } console.log(`[migrate-tz] 找到 ${personaDirs.length} 個人格目錄:${personaDirs.join(", ")}`); // a. 前置檢查:有沒有人格正在載入 const activeLocks = checkActiveLocks(home, personaDirs); if (activeLocks.length) { console.error("[migrate-tz] 中止:以下人格目前處於載入狀態,不強行執行:"); for (const a of activeLocks) { if (a.kind === "lock") { console.error(` - ${a.slug}:exclusive lock(session ${a.session_id},cwd ${a.cwd},最後心跳 ${a.heartbeat_at})`); } else { console.error(` - ${a.slug}:guest 租約(session ${a.session_id},room ${a.room},最後心跳 ${a.heartbeat_at})`); } } console.error("請先讓所有人格釋放鎖(結束對話 / persona status --release)再重跑這支腳本。"); return 2; } console.log("[migrate-tz] 前置檢查通過:沒有人格處於載入狀態。"); // b. 備份(dry-run 完全不寫檔,連備份都不做) let backupDir = null; if (!args.dryRun) { backupDir = backupHome(home); console.log(`[migrate-tz] 已備份到:${backupDir}`); } // c/d/e. 掃描整個 PERSONA_HOME,算出全部異動計畫;掃描階段完全不寫檔 const perFile = []; // { file, label, root, changes } const allChanges = []; const allUndetermined = []; const allConflicts = []; for (const slug of personaDirs) { for (const file of collectJsonFiles(path.join(home, slug))) { const label = path.relative(home, file); const raw = fs.readFileSync(file, "utf8"); let root; try { root = JSON.parse(raw); } catch (err) { console.error(`[migrate-tz] 略過(JSON 解析失敗,不會被計入任何統計):${label} — ${err.message}`); continue; } const { changes, undetermined, conflicts } = scanTree(root, label); if (changes.length) perFile.push({ file, label, root, changes }); allChanges.push(...changes); allUndetermined.push(...undetermined); allConflicts.push(...conflicts); } } const tsChanges = allChanges.filter((c) => c.kind === "timestamp"); const dateChanges = allChanges.filter((c) => c.kind === "datekey"); const filesChanged = new Set(perFile.map((f) => f.label)); console.log(""); console.log("========== 掃描報告 =========="); console.log(`將改動的檔案數:${filesChanged.size}`); console.log(`時間戳欄位(Z → +08:00):${tsChanges.length}`); console.log(`日期鍵欄位(重算台北曆日):${dateChanges.length}`); console.log(`無法確定(維持原值,需人工檢視):${allUndetermined.length}`); console.log(`合併衝突:${allConflicts.length}`); if (allUndetermined.length) { console.log(""); console.log("---- 無法確定,需人工檢視 ----"); for (const u of allUndetermined) { console.log(` ${u.file}${fmtPath(u.path)} = "${u.value}" — ${u.reason}`); } } if (allConflicts.length) { console.log(""); console.log("---- 合併衝突(未寫入任何檔案,需人工決定合併方式後重跑) ----"); for (const c of allConflicts) { console.log(` 檔案 ${c.file},陣列 ${fmtPath(c.path)},欄位 \`${c.field}\`,重算後都落在 ${c.newValue}:`); for (const e of c.entries) { console.log(` - [${e.index}] 原始值 ${e.original}:${JSON.stringify(e.record)}`); } } console.error(""); console.error(`[migrate-tz] 中止:偵測到 ${allConflicts.length} 組合併衝突,不寫入任何檔案。`); console.error("請先人工決定怎麼合併上面列出的紀錄,再重新執行這支腳本。"); return 3; } if (args.dryRun) { console.log(""); console.log("[migrate-tz] --dry-run:以上為預覽,沒有寫入任何檔案。"); return 0; } if (filesChanged.size === 0) { console.log(""); console.log("[migrate-tz] 沒有需要修改的欄位(可能已經是 +08:00 格式,或本來就沒有需要換算的資料)。"); return 0; } // 落筆:只有掃描完全結束、確定沒有合併衝突,才寫入 for (const { file, root, changes } of perFile) { const newRoot = applyChanges(root, changes); const tmp = `${file}.tmp${process.pid}`; fs.writeFileSync(tmp, JSON.stringify(newRoot, null, 2) + "\n", "utf8"); fs.renameSync(tmp, file); } console.log(""); console.log(`[migrate-tz] 已寫入 ${filesChanged.size} 個檔案:`); for (const label of filesChanged) console.log(` - ${label}`); // e. 執行後自動驗證 console.log(""); console.log("========== 執行後驗證 =========="); const leftover = verifyNoLeftoverZ(home, personaDirs); if (leftover.length) { console.error(`[migrate-tz] 嚴重錯誤:全樹掃描後仍找到 ${leftover.length} 個 Z 結尾的時間戳,遷移不完整:`); for (const o of leftover) console.error(` - ${o.file}: ${o.match}`); return 4; } console.log("(i) 全樹已無殘留的 Z 結尾時間戳:通過。"); const epochMismatches = tsChanges.filter((c) => c.epochOld !== c.epochNew); if (epochMismatches.length) { console.error(`[migrate-tz] 嚴重錯誤:${epochMismatches.length} 個時間戳欄位轉換前後 epoch 不相等:`); for (const m of epochMismatches) { console.error(` - ${m.file}${fmtPath(m.path)}:${m.oldValue}(${m.epochOld}) → ${m.newValue}(${m.epochNew})`); } return 4; } console.log(`(ii) ${tsChanges.length} 個時間戳欄位逐一驗證轉換前後 epoch 相等:通過。`); console.log(""); console.log(`[migrate-tz] 完成。備份保留於:${backupDir}`); return 0; } process.exit(main());