#!/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