Files
persona/scripts/migrate-tz.mjs
T
jiantw83andClaude Sonnet 5 ac2045e52f feat(persona): 時區改為+08:00等值轉換、補機密遮蔽,並接上shared共用規範
依 todo.md 執行的規範治理專案:
- persona-lib.mjs 的 iso()/nowIso() 改為輸出 +08:00 offset(等值瞬間轉換,
  非加8小時),新增 nowDisplay()(人讀顯示格式)與 todayTaipei()(台北曆日
  日期鍵),修正 11 處原本用 UTC 字串切片產生日期鍵的錯誤用法;新增
  redactSecrets() 並套用到 persona-gitea.mjs 的 Gitea 錯誤路徑,避免機密
  外洩。
- 新增一次性遷移腳本 scripts/migrate-tz.mjs(備份/dry-run/冪等/衝突偵測
  中止),供之後有真實人格資料需要轉換時使用。
- persona 13 個 skill 與 AGENTS.md 接上 /jsc-shared 共用規範(spec-output、
  spec-script-path、spec-subagent、spec-gitea、spec-git-safety、
  spec-skill-invocation 等),不再各自重抄,且完全未變動人格行為本身的規範
  (情緒模型、記憶衰減、劇場模式、鎖與隔離機制等逐字保留)。
- selftest 全數通過(788/788,含本次新增的時區邊界/冪等性測試)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 06:06:00 +00:00

605 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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>/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 <path>]
//
// 不帶 `--home` 時依序退回 `PERSONA_HOME` 環境變數、`~/.claude/personas`。
//
// 安全機制
// --------
// a. 執行前一定先檢查是否有人格處於載入狀態(exclusive lock 或存活的 guest
// 租約)——不管是不是 `--dry-run`。有就中止、不強行執行。
// b. 正式執行(非 `--dry-run`)一定先把整個 `PERSONA_HOME` 完整複製備份到
// `<上一層目錄>/personas.bak-<yyyyMMddHHmmss>/`(時間戳是台北時區)。
// `--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 <path>]\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());