Files
DevFlow/scripts/archive-todo.cjs
T
lxy e61fcdea79 新增: 看板归档器 + 已完成内容再分离
- scripts/archive-todo.cjs: 把看板已完成内容分离到归档(条目块 [x] 自动移出;历史/导航段走标题白名单整段移出,不靠启发式猜--本板存在大量无勾选的行内待办,启发式会误移)
- 安全闸: 未完成项零丢失断言(失败即中止不写);默认 dry-run;--list 从 UTF-8 文件读白名单(规避 Windows CLI 中文编码);容器标题(下挂更深子节)不当作空标题删
- 本次移出 6 段(统一规划 2026-06-26 全景/交接状态 2026-06-14/编排推进总览 2026-06-18)+ 909 条目;主板 847→736 行
- 核对: 未完成项 109 项零丢失,主板 [x] 清零,归档收 909 及其验收块
2026-10-10 00:47:10 +08:00

136 lines
6.1 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.
// 看板归档器:把 docs/todo.md 中「已完成」内容分离到 docs/07-项目管理/todo归档/<date>.md。
//
// 移动规则(纯行搬运,零措辞改动):
// ① 条目块为 [x] → 自动移出(其后续非条目行为同块延续,含缩进证据/引用块);
// ② **白名单整段移出**:命令行给出的标题(用引号原样传入) → 该段连同其子段(级别更深的后续段)
// 一并移出。历史/导航/已完成段走此路——**不靠启发式猜「已完成」**(本板存在大量无勾选的
// 行内待办,启发式会误移)。
// ③ 移出后残留的空标题(段内仅空行)自动删除,防悬空标题。
//
// 安全闸:未完成项([ ]/[~]/[→]/[!])零丢失断言失败即中止不写;默认 dry-run。
//
// 用法:
// node scripts/archive-todo.cjs # dry-run
// node scripts/archive-todo.cjs "## 🎯 统一规划(2026-06-26 全景)" # dry-run + 试移该段
// node scripts/archive-todo.cjs --apply "## 交接状态(2026-06-14)" # 实际写入
// node scripts/archive-todo.cjs --apply --date 2026-10-11 ...
const fs = require('fs');
const argv = process.argv.slice(2);
const APPLY = argv.includes('--apply');
const di = argv.indexOf('--date');
const DATE = di >= 0 ? argv[di + 1] : new Date().toISOString().slice(0, 10);
const li = argv.indexOf('--list');
// 白名单来源:① 命令行位置参数(注意 Windows Git Bash 下中文会被编码破坏,慎用)
// ② --list <file>:UTF-8 文件,每行一个精确标题(推荐,避免编码问题)
const WHITELIST =
li >= 0
? fs.readFileSync(argv[li + 1], 'utf8').split('\n').map((s) => s.trim()).filter(Boolean)
: argv.filter((a, i) => !a.startsWith('--') && i !== di + 1 && i !== li + 1);
const SRC = 'docs/todo.md';
const ARC = `docs/07-项目管理/todo归档/${DATE}.md`;
const UNDONE = /^\s*- \[[ ~→!]\]|^\d+\. \[[ ~→!]\]/;
const DONE = /^\s*- \[x\]|^\d+\. \[x\]/i;
const BLOCK = /^(\s*- \[|\d+\. \[)/;
const HEAD = /^#{1,3} /;
const level = (h) => (h.match(/^#+/) || [''])[0].length;
// 行尾归一:工作区可能是 CRLF,按 \r?\n 切(否则标题比对带 \r 恒不等)。写回用 LF,git 提交时归一。
const lines = fs.readFileSync(SRC, 'utf8').split(/\r?\n/);
// 打标:``` 围栏内一律 text(防 mermaid/示例里的 ## 误导分段)
const kinds = [];
let inFence = false;
for (const ln of lines) {
if (ln.trim().startsWith('```')) { kinds.push('text'); inFence = !inFence; continue; }
kinds.push(inFence ? 'text' : HEAD.test(ln) ? 'head' : BLOCK.test(ln) ? 'block' : 'text');
}
// 分段(首个 head 前为 preamble,永留主板)
const segs = [];
let cur = { head: null, headLevel: 0, items: [] };
for (let i = 0; i < lines.length; i++) {
if (kinds[i] === 'head') { segs.push(cur); cur = { head: lines[i], headLevel: level(lines[i]), items: [] }; }
else cur.items.push(lines[i]);
}
segs.push(cur);
const preamble = segs[0].head === null ? segs.shift() : null;
// 块归并(块 = 条目起始行 + 其后非条目行)
function blocksOf(items) {
const out = [];
let curB = null;
for (const ln of items) {
if (BLOCK.test(ln)) {
if (curB) out.push(curB);
curB = { status: DONE.test(ln) ? 'done' : UNDONE.test(ln) ? 'undone' : 'other', lines: [ln] };
} else if (curB) curB.lines.push(ln);
else out.push({ status: 'text', lines: [ln] });
}
if (curB) out.push(curB);
return out;
}
const moved = [];
const kept = [];
if (preamble) kept.push(...preamble.items);
let treeLevel = null; // 白名单段的级别:更深的后续段随其移出
let movedSegs = 0, movedBlocks = 0;
for (const seg of segs) {
const whitelisted = seg.head !== null && WHITELIST.includes(seg.head);
const inTree = treeLevel !== null && seg.head !== null && seg.headLevel > treeLevel;
if (!seg.head) { /* 不可能:非 preamble 段必有 head */ }
if (whitelisted || inTree) {
if (whitelisted) treeLevel = seg.headLevel;
movedSegs++;
if (seg.head) moved.push(seg.head);
for (const b of blocksOf(seg.items)) moved.push(...b.lines);
continue;
}
treeLevel = null;
if (seg.head) kept.push(seg.head);
for (const b of blocksOf(seg.items)) {
if (b.status === 'done') { movedBlocks++; moved.push(...b.lines); }
else kept.push(...b.lines);
}
}
// 删残留空标题:仅当「段内只剩空行」且「下一个标题级别 ≤ 当前」才删——
// 若下一个标题更深,说明它是**容器标题**(如 `## 待办` 下挂 `### 子节`),必须保留。
const cleaned = [];
for (let i = 0; i < kept.length; i++) {
if (HEAD.test(kept[i])) {
let j = i + 1;
while (j < kept.length && kept[j].trim() === '') j++;
const nextIsDeeper = j < kept.length && HEAD.test(kept[j]) && level(kept[j]) > level(kept[i]);
if (j >= kept.length || (HEAD.test(kept[j]) && !nextIsDeeper)) continue;
}
cleaned.push(kept[i]);
}
// 零丢失断言
const origUndone = lines.filter((l) => UNDONE.test(l));
const newUndone = new Set(cleaned.filter((l) => UNDONE.test(l)));
const lost = origUndone.filter((l) => !newUndone.has(l));
console.log(`[archive-todo] ${APPLY ? 'APPLY' : 'DRY-RUN'} date=${DATE} whitelist=${WHITELIST.length}`);
console.log(` 整段移出 ${movedSegs} 段,条目块移出 ${movedBlocks} 个,移出 ${moved.length} 行,主板 ${cleaned.length} 行(原 ${lines.length})`);
console.log(` 未完成项 原 ${origUndone.length} → 主板 ${newUndone.size},丢失 ${lost.length}`);
if (lost.length) {
console.error('✗ 零丢失断言失败,不写入:');
for (const l of lost.slice(0, 10)) console.error(' ' + l.slice(0, 100));
process.exit(1);
}
console.log(' 移出段标题:');
for (const l of moved.filter((x) => HEAD.test(x))) console.log(' - ' + l.slice(0, 90));
if (!APPLY) { console.log('(dry-run,加 --apply 写入)'); process.exit(0); }
if (moved.length === 0) { console.log('无内容可移,跳过写入'); process.exit(0); }
let arc = fs.existsSync(ARC) ? fs.readFileSync(ARC, 'utf8').trimEnd() : `# todo 归档(${DATE})`;
fs.writeFileSync(ARC, `${arc}\n\n---\n\n## ${DATE} 分拆(已完成内容再分离)\n\n${moved.join('\n')}\n`, 'utf8');
fs.writeFileSync(SRC, cleaned.join('\n'), 'utf8');
console.log(`[archive-todo] 已写入 ${SRC} 与 ${ARC}`);