#!/usr/bin/env node /** * fix_checklist 提取工具 (TypeScript 版) —— 确定性解析 task_dir 下所有 UI_comparison.md, * 逐行抽取 Diff 非空(且非"一致")的差异,生成 fix_checklist.md 草稿。 * * 设计目标:把"凭模型肉眼枚举所有差异"降级为"机械逐行抽取 + 模型审阅去重", * 从源头消除 checklist 漏项。 * * 覆盖 UI_comparison.md 中的全部 5 类表: * - 主静态组件表 (UI Component ... | Diff) * - Dynamic / Transient UI Comparison * - State Machine Comparison * - Per-Transition Comparison * - Per-State Visual Diff * * 用法: * node extract_checklist.ts --task-dir ./tmp_xxx/task_2026xxxx * node extract_checklist.ts --task-dir -o /fix_checklist.md * * 等价于原 extract_checklist.py(functional equivalence,非逐字节一致)。 */ import * as fs from 'node:fs'; import * as path from 'node:path'; // Diff 单元格里这些值(小写后)视为"无差异",整行跳过 const NO_DIFF_VALUES = new Set([ '', '-', '—', '–', '一致', '相同', '无', '无差异', 'n/a', 'na', 'none', 'same', 'identical', 'ok', '✓', '√', '对齐', '已对齐', 'no diff', '无变化', ]); // 一行 Diff 里可能并列多个差异,按这些分隔符拆成独立条目(宁可多拆,不可漏) const SPLIT_PATTERN = /|;|;|\n/; // 占位符用于保护转义竖线 \| const PIPE_PLACEHOLDER = '\x00PIPE\x00'; interface Table { header: string[]; rows: string[][]; section: string; } interface PageStats { page: string; items: string[]; tableCount: number; diffRowCount: number; } /** 把一行 markdown 表格拆成单元格列表(处理转义竖线)。 */ function splitRow(line: string): string[] { let s = line.trim().replace(/\\\|/g, PIPE_PLACEHOLDER); if (s.startsWith('|')) s = s.slice(1); if (s.endsWith('|')) s = s.slice(0, -1); return s.split('|').map((c) => c.trim().split(PIPE_PLACEHOLDER).join('|')); } /** 判断是否为 |:---|:---| 这种分隔行。 */ function isSeparator(cells: string[]): boolean { if (cells.length === 0) return false; const nonEmpty = cells.filter((c) => c.trim() !== ''); if (nonEmpty.length === 0) return false; return cells.every((c) => c.trim() === '' || /^:?-{2,}:?$/.test(c.trim())); } /** 判断一个 Diff 单元格是否表示真实差异。 */ function cellHasDiff(val: string | undefined): boolean { if (val === undefined || val === null) return false; const v = val.trim().replace(/^[`*]+|[`*]+$/g, '').trim().toLowerCase(); return !NO_DIFF_VALUES.has(v) && v !== ''; } /** 在表头里找 Diff 列的索引;找不到返回 -1。 */ function findDiffCol(headerCells: string[]): number { const candidates = ['diff', '差异', '目标', 'target']; for (let i = 0; i < headerCells.length; i++) { if (headerCells[i].toLowerCase().includes('diff')) return i; } for (let i = 0; i < headerCells.length; i++) { const h = headerCells[i].toLowerCase(); if (candidates.some((c) => h.includes(c))) return i; } return -1; } /** 选一个最适合做"条目标识"的列(组件名/State/Transition/Effect/属性)。 */ function labelCol(headerCells: string[]): number { const keys = [ 'component', '组件', 'ui component', 'state', 'transition', 'effect', 'dimension', 'property', '属性', '维度', '状态', ]; for (let i = 0; i < headerCells.length; i++) { const h = headerCells[i].toLowerCase(); if (keys.some((k) => h.includes(k))) return i; } return 0; // 兜底用第一列 } /** * 解析 markdown 文本里所有表格。返回 [{header, rows, section}]。 * section = 表格前最近的 ## / ### 标题(用于区分 5 类表)。 */ function parseTables(text: string): Table[] { const tables: Table[] = []; const lines = text.split(/\r?\n/); let currentSection = ''; let i = 0; const n = lines.length; while (i < n) { const line = lines[i]; const h = line.trim().match(/^#{1,6}\s+(.+)$/); if (h) { currentSection = h[1].trim(); i += 1; continue; } // 检测表头:当前行像表格行,下一行是分隔行 if (line.includes('|') && i + 1 < n && lines[i + 1].includes('|')) { const header = splitRow(line); const sep = splitRow(lines[i + 1]); if (isSeparator(sep) && header.length >= 2) { const rows: string[][] = []; let j = i + 2; while (j < n && lines[j].includes('|') && lines[j].trim()) { const cells = splitRow(lines[j]); if (isSeparator(cells)) { j += 1; continue; } rows.push(cells); j += 1; } tables.push({ header, rows, section: currentSection }); i = j; continue; } } i += 1; } return tables; } /** 根据表所在 section 给条目打标签前缀(对齐 SKILL.md 的 [STATE] 等约定)。 */ function classifyPrefix(section: string): string { const s = section.toLowerCase(); if (s.includes('per-transition') || s.includes('transition')) return '[TRANSITION] '; if (s.includes('per-state') || (s.includes('state') && s.includes('visual'))) return '[STATE] '; if (s.includes('state machine') || section.includes('状态')) return '[STATE] '; if (s.includes('dynamic') || s.includes('transient') || section.includes('动态')) return '[DYNAMIC] '; return ''; } /** 从单张表抽取所有真实差异条目。 */ function extractItemsFromTable(table: Table): string[] { const { header, rows, section } = table; const diffIdx = findDiffCol(header); if (diffIdx === -1) return []; // 没有 Diff 列的表(如纯枚举表)不抽 const lblIdx = labelCol(header); const prefix = classifyPrefix(section); const items: string[] = []; for (const cells of rows) { if (diffIdx >= cells.length) continue; const diffVal = cells[diffIdx]; if (!cellHasDiff(diffVal)) continue; let label = lblIdx < cells.length ? cells[lblIdx].trim() : ''; label = label.replace(/^[`*]+|[`*]+$/g, '').trim(); // 把 Diff 单元格里并列的多个差异拆开,逐条成项(防止整行塌缩漏项) let parts = diffVal.split(SPLIT_PATTERN).map((p) => p.trim()).filter((p) => p); if (parts.length === 0) parts = [diffVal.trim()]; for (const part of parts) { if (!cellHasDiff(part)) continue; items.push(label ? `${prefix}${label}: ${part}` : `${prefix}${part}`); } } return items; } /** 返回 {page, items, tableCount, diffRowCount}。 */ function processComparison(filePath: string): PageStats { const text = fs.readFileSync(filePath, 'utf-8'); const tables = parseTables(text); const items: string[] = []; let diffRows = 0; for (const t of tables) { const diffIdx = findDiffCol(t.header); if (diffIdx === -1) continue; for (const cells of t.rows) { if (diffIdx < cells.length && cellHasDiff(cells[diffIdx])) diffRows += 1; } items.push(...extractItemsFromTable(t)); } // 页面名取自父目录名(hmos_page_{i}_{name}) const page = path.basename(path.dirname(filePath)); return { page, items, tableCount: tables.length, diffRowCount: diffRows }; } /** 递归查找所有 UI_comparison.md 文件,按路径排序。 */ function findComparisonFiles(taskDir: string): string[] { const results: string[] = []; const walk = (dir: string): void => { let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { walk(full); } else if (entry.isFile() && entry.name === 'UI_comparison.md') { results.push(full); } } }; walk(taskDir); return results.sort(); } /** 扫描 task_dir 下所有 UI_comparison.md,生成 checklist 文本 + 每页统计。 */ function buildChecklist(taskDir: string): { text: string; stats: PageStats[] } { const compFiles = findComparisonFiles(taskDir); const out: string[] = [ '# Fix Checklist', '', '> 本文件由 scripts/extract_checklist.ts 自动生成草稿。', '> 每条对应一个 UI_comparison.md 中 Diff 非空的差异行(已逐行拆分)。', '> 主 agent 需:去重、补全人类可读描述、回溯 Android 源码值,再开始修复。', '', ]; const stats: PageStats[] = []; if (compFiles.length === 0) { out.push('_(未找到任何 UI_comparison.md —— 请先完成 Step 2)_'); return { text: out.join('\n'), stats }; } for (const cf of compFiles) { const info = processComparison(cf); stats.push(info); out.push(`## ${info.page}`); out.push( ``, ); if (info.items.length === 0) { out.push('- _(无差异,或该页未生成有效 Diff 表 —— 请人工确认)_'); } else { for (const it of info.items) out.push(`- [ ] ${it}`); } out.push(''); } return { text: out.join('\n'), stats }; } interface CliArgs { taskDir: string; output: string | null; } function parseArgs(argv: string[]): CliArgs { const args: CliArgs = { taskDir: '', output: null }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; const next = (): string => { if (i + 1 >= argv.length) { console.error(`Missing value for ${a}`); process.exit(2); } return argv[++i]; }; if (a === '--task-dir') { args.taskDir = next(); } else if (a === '--output' || a === '-o') { args.output = next(); } else if (a === '-h' || a === '--help') { printHelp(); process.exit(0); } else { console.error(`Unknown argument: ${a}`); process.exit(2); } } if (!args.taskDir) { console.error('error: --task-dir is required'); process.exit(2); } return args; } function printHelp(): void { console.log( [ '确定性提取所有 UI_comparison.md 中的差异,生成 fix_checklist.md 草稿', '', 'Usage: node extract_checklist.ts --task-dir [-o ]', '', ' --task-dir task 目录(含 hmos_page_*/UI_comparison.md)(必填)', ' --output,-o 输出路径(默认 {task_dir}/fix_checklist.md)', ].join('\n'), ); } function main(): number { const args = parseArgs(process.argv.slice(2)); if (!fs.existsSync(args.taskDir) || !fs.statSync(args.taskDir).isDirectory()) { console.log(`[ERR] task-dir 不存在: ${args.taskDir}`); return 1; } const outPath = args.output ?? path.join(args.taskDir, 'fix_checklist.md'); const { text, stats } = buildChecklist(args.taskDir); fs.writeFileSync(outPath, text, 'utf-8'); // 对账报告:每页 diff 行数 vs 抽出条目数,便于主 agent 核对无漏 const totalRows = stats.reduce((sum, s) => sum + s.diffRowCount, 0); const totalItems = stats.reduce((sum, s) => sum + s.items.length, 0); console.log(`Wrote ${outPath}`); console.log( `Comparison files: ${stats.length} | total diff rows: ${totalRows} ` + `| total checklist items: ${totalItems}`, ); console.log('-'.repeat(60)); console.log(`${'PAGE'.padEnd(48)}${'rows'.padStart(5)}${'items'.padStart(6)}`); for (const s of stats) { const flag = s.items.length >= s.diffRowCount ? '' : ' <-- CHECK'; console.log( `${s.page.slice(0, 46).padEnd(48)}${String(s.diffRowCount).padStart(5)}${String( s.items.length, ).padStart(6)}${flag}`, ); } if (totalRows === 0) { console.log( '\n[WARN] 0 个 diff 行被识别。可能 comparison 表头无 Diff 列,' + '或文件只有状态说明没有表格 —— 请回到 Step 2 检查。', ); } return 0; } process.exit(main());