OpenHuman UI 迁移 Wave 工作流解析:report.mjs 工作清单与并发安全分区实践
导读
本文深入剖析 OpenHuman 前端(app/)UI 迁移工程的核心协作协议——scripts/ui-codemod/WORKFLOW.md 所定义的 ui-migration-wave 工作流。该工作流解决一个真实且棘手的问题:多个 AI Agent 在同一个 git checkout 内并发执行大规模组件迁移,如何在不破坏仓库结构(仓库本身是 superproject 的 submodule)的前提下,通过文件级不相交分区避免写冲突。读完本文,你将掌握工作清单生成器 report.mjs 的分区算法、共享文件禁区清单、wave 的启动与验收流程,以及它如何与 diff-cover 覆盖率门禁协同工作。
一、迁移工作流的总体架构
UI 迁移的日常运转由两个互补脚本支撑,位于 scripts/ui-codemod/ 目录:
- report.mjs:只读工作清单生成器。它扫描
app/src,找出仍然使用手写 HTML 标记、但已有官方components/ui/原语(primitive)可以替代的位置,输出按 bucket 分组的待办清单。脚本头部明确声明"Read-only: it never writes to a source file",写入由独立的rewrite.mjs(仓库中的 writer)负责。 - orphans.mjs:反向审计工具,报告"已经存在但零消费者"的原语,防止"看似已迁移、实则未迁移"的假象。
整个执行模式是:report.mjs 产出工作列表 → ui-migration-wave 工作流一波(wave)一波地消费它。每一波只处理一批原语(primitives)和若干 bucket 的文件,波次之间穿插完整的测试与覆盖率校验。
二、为什么用 wave 分批、为什么坚持单一 checkout
工作流文档开宗明义地解释了两个关键设计决策,这决定了后续所有分区逻辑。
第一,Agent 必须在单一 checkout 中并发运行。因为本仓库是上层 superproject 的 submodule,如果给 Agent 创建嵌套的 git worktree,嵌套 worktree 会共享 .git/modules 的 HEAD,从而静默损坏 gitlink(详见仓库根目录的 AGENTS.md 中关于 submodule 的说明,以及 git submodule update --init --recursive vendor/ 的初始化方式)。因此,嵌套 worktree 在此仓库中是被禁止的。
第二,既然无法靠 worktree 隔离,不相交的文件所有权(disjoint file ownership)就成为阻止两个 Agent 同时编辑同一文件的唯一手段。这正是 report.mjs 保证 bucket 之间严格不相交、并且对超大 bucket 采用"分块"而非"整包交给一个 Agent"的原因——文档中提到的 41 个文件场景,就是防止单个 Agent 一次拿到过重的负载。
三、report.mjs:工作清单生成器源码级解读
report.mjs 的核心是把"哪些手写标记需要被替换"映射为"哪些原语接管",再按目录聚合成不相交的 bucket。
3.1 模式表(PATTERNS):替换依据与权重
const PATTERNS = [
{ id: 'button', rg: '<button', primitive: 'Button', weight: 1 },
{ id: 'input', rg: '<input', primitive: 'TextField / Checkbox', weight: 1 },
{ id: 'select', rg: '<select', primitive: 'NativeSelect', weight: 1 },
{ id: 'textarea', rg: '<textarea', primitive: 'TextArea', weight: 1 },
{ id: 'switch', rg: 'role="switch"', primitive: 'Switch', weight: 2 },
{ id: 'table', rg: '<table', primitive: 'Table', weight: 3 },
{ id: 'overlay', rg: 'fixed inset-0', primitive: 'Dialog / Sheet', weight: 4 },
];
这里值得注意三点:
weight是单处替换的粗略判断成本,仅用于让各 bucket 体量大致均衡——把一个 overlay 改成Dialog/Sheet是远比把<select>改名成NativeSelect更大的决策,所以 overlay 权重为 4,button/input 等为 1。- 每种模式都对应
app/src/components/ui/下真实存在的原语,例如 Button.tsx、NativeSelect.tsx、TextArea.tsx、Switch.tsx、Table.tsx、Dialog.tsx 与 Sheet.tsx。 - 扫描使用 ripgrep(
rg)的--count --no-heading模式,统计每个文件里每种模式的命中次数,命中次数乘权重累加为该文件的 weight。
3.2 排除与白名单规则
const EXCLUDES = [
'!*.test.tsx', '!*.test.ts', '!*.spec.ts',
'!src/components/ui/**', // the primitives themselves
'!src/pages/dev/**', // the gallery demonstrates raw markup on purpose
];
扫描默认跳过测试文件、components/ui/ 原语本身,以及 pages/dev/**——后者是刻意用原始标记展示原语效果的原语画廊(gallery),不算作待迁移对象,也不算作消费者。
3.3 误报白名单(DENYLIST)与 overlay 迁移判定
const DENYLIST = new Map([
['src/services/analyticsInteractions.ts',
'read-only contract: the `[role="switch"]` here is a querySelector string in the delegated analytics listener, not markup'],
]);
这一点非常关键,也解释了共享文件表中 analyticsInteractions.ts 为何是只读契约:该文件中 role="switch" 并不是 JSX 标记,而是委托事件监听器里的 querySelector 选择器字符串。查看 analyticsInteractions.ts 可以看到 CONTROL_CHANGE_SELECTOR 确实包含 [role="switch"],且 INTERACTIVE_CLICK_SELECTOR 包含 [data-analytics-id]——data-analytics-id 是埋点契约的一部分,迁移时绝不能改动这些属性。
const MIGRATED_OVERLAY = /from 'radix-ui'|from '.*\/ui\/(Dialog|Sheet|ModalShell)'|<(Dialog|Sheet|ModalShell)\b/;
fixed inset-0 既是手写 overlay 的样式写法,也是 Radix overlay 的标准样式。因此一个文件如果已经通过 Radix 或项目自己的 Dialog/Sheet/ModalShell 驱动 overlay,就应视为已迁移而不是待处理。
3.4 hasTest:决定批处理策略
function hasTest(file) {
const dir = dirname(file);
const base = basename(file, '.tsx');
return (
existsSync(join(APP, dir, `${base}.test.tsx`)) ||
existsSync(join(APP, dir, '__tests__', `${base}.test.tsx`))
);
}
hasTest 判断文件是否有同目录或 __tests__/ 下的 Vitest 测试。它不是为了统计而统计,而是直接关系到 CI 门禁能否通过:项目的 CI 通过 diff-cover 对变更行强制 80% 覆盖率,如果一个未测试文件只做了 import 级改动,该文件的覆盖率就是 0%,会拖垮它所在批次。因此,未测试文件在迁移时必须在同一个变更里附带冒烟测试。
3.5 bucket 划分:前两个路径段
function bucketOf(file) {
return file.replace(/^src\//, '').split('/').slice(0, 2).join('/');
}
bucket 名取文件路径的前两段,例如 components/settings。聚合后每个 bucket 记录文件数、总 weight、未测试文件数,按 weight 降序排列,bucket 内部文件也按 weight 降序排序,保证每个 bucket 被派给单个 Agent 时负载相对均衡。
四、共享文件禁区:任何迁移 Agent 都不得触碰
工作流文档用一张表明确列出了跨所有 bucket 共享、并发编辑等于丢失写入的文件,这是并发安全的核心防线:
| 文件 | 归属 |
|---|---|
src/components/ui/index.ts |
归 barrel agent,且在 primitives 阶段之后 |
src/lib/i18n/*.ts |
wave 中无人拥有——Agent 只上报缺失的 key,而非直接改文件 |
src/test/setup.ts、tailwind.config.js、pnpm-lock.yaml |
无人拥有:任何改动都会触发整条串行 CI 套件 |
src/services/analyticsInteractions.ts |
无人拥有:只读的 data-analytics-id 契约 |
其中 app/src/components/ui/index.ts 的头部注释进一步印证了 barrel 的战略地位:它自称"the only sanctioned import path for shared controls",并记录了 ButtonGroup 和 HoverCard 因零消费者被删除而非强行保留的决策——一个无人消费的原语会让每次审计都误读为"已迁移",这种状态比不存在更糟。而 src/lib/i18n/(如 I18nContext.tsx)的国际化 key 由 Agent 上报后统一处理,避免并发写冲突。
五、运行一个 wave:命令与工作流入参
工作流文档给出了三条核心命令,全部指向 scripts/ui-codemod/report.mjs:
node scripts/ui-codemod/report.mjs # 查看还剩多少工作(人类可读摘要)
node scripts/ui-codemod/report.mjs --json # 机器可读 JSON,作为工作流输入
node scripts/ui-codemod/report.mjs --bucket components/settings # 只看指定 bucket
三种输出的对应实现位于脚本末尾:
- 默认模式输出总览:
${files.size} files, ${totalSites} sites, ${ordered.length} buckets,随后是表格,列出每个 bucket 的名称、文件数、未测试文件数、总 weight 和涉及的模式集合。 --json模式输出{ buckets: [...] }的 JSON,供ui-migration-wave工作流消费。--bucket <name>只过滤出指定 bucket(精确匹配 bucket 名,如components/settings)。
得到工作清单后,以如下参数调用 ui-migration-wave 工作流:
{ "primitives": [...], "buckets": [...] }
primitives 数组列出本波要迁移的原语(对应 PATTERNS.primitive),buckets 数组列出本波要处理的 bucket。每个 bucket 派给一个 Agent,Agent 只允许改动自己 bucket 内文件,共享文件一律不碰。
六、波次之间的验收:完整套件 + diff-cover dry run
文档明确提示:"Between waves, run the full suite and the diff-cover dry run yourself——the workflow's gate is scoped to the files it touched, which is faster but narrower."
这句话揭示了门禁的精确语义:ui-migration-wave 工作流内置的 gate 只针对它实际改动过的文件做范围校验(更快),因此覆盖面更窄;而仓库 CI 的 diff-cover 门禁才是完整的 80% 变更行覆盖要求。两者必须互补——波次之间,人工(或调度方)应主动跑完整测试套件和 diff-cover 的 dry run,防止"工作流绿了、CI 红了"。
这一机制在 scripts/ci/vitest-changed-coverage.sh 中有完整实现:PR CI 通过 vitest related 只跑变更文件相关的测试(利用 app/src 禁止动态 import 的约束,静态 import 图可靠),同时覆盖率仍写入 app/coverage/lcov.info,diff-cover 步骤随后在变更行上强制 >= 80%。未测试的变更文件会以 0% 出现在 lcov 报告中——门禁无法通过"没有相关测试"来规避。当变更属于配置文件级(lockfile、vitest/vite/ts config、test setup)或变更文件超过 MAX_RELATED_FILES=200 时,脚本自动回退到完整套件。
这正解释了共享文件表中 src/test/setup.ts、tailwind.config.js、pnpm-lock.yaml 为何"无人拥有":这些文件的改动会强制整条串行 CI 套件重跑,让快速波次失去意义。
七、反向审计:orphans.mjs 如何揪出"假迁移"
与 report.mjs 配套的 orphans.mjs 从另一个方向保障迁移质量:找出已存在但无人 import 的原语。脚本源码记录过真实案例:ui/Sidebar.tsx(625 行)与 app shell 自身的宽度常量逐字节相同却无人使用,ai-elements/ 的十个组件也全部闲置。
它的匹配规则有三个值得借鉴的精确性设计:
- 按模块路径匹配,而非符号匹配。
ui/AlertDialog导出的是AlertDialogRoot、AlertDialogContent这类带后缀符号,用\bAlertDialog\b匹配会在Root前的词边界失败,把 23 个正常使用的原语误报为孤儿;按路径匹配则命名无关、无歧义。 - barrel import 单独匹配,正则要求原语名后必须跟大写后缀或结束(
${name}([A-Z][A-Za-z]*)?)。大写约束是承重的:如果允许任意后缀,Tool会匹配到Tooltip,导致import { Button, Tooltip }被计为Tool的三个假消费者。 pages/dev/**画廊刻意不计为消费者——它按设计渲染每个原语,计入就会掩盖所有孤儿。
八、实践要点总结
综合工作流文档与源码,落地一波 UI 迁移的标准动作是:
- 盘点:运行
node scripts/ui-codemod/report.mjs查看剩余工作与 bucket 分布;需要机器输入时加--json。 - 定向查看:用
--bucket components/settings之类参数单独预览某个 bucket,评估负载。 - 分波执行:以
{ primitives: [...], buckets: [...] }调用ui-migration-wave,每个 bucket 交给一个 Agent,严格遵守共享文件禁区;未测试文件务必同变更附带冒烟测试,否则 diff-cover 0% 会拖垮整批。 - 波间验收:跑完整测试套件与 diff-cover dry run,弥补工作流窄门禁的盲区。
- 反向检查:定期运行
node scripts/ui-codemod/orphans.mjs,确保没有"写完即闲置"的原语残留。
这套"单一 checkout + 不相交 bucket + 共享文件禁区 + 双层覆盖率门禁"的编排,使得大规模 UI 重构可以在多 Agent 并发下安全推进,同时把 submodule 仓库特有的 gitlink 损坏风险降为零——对任何需要多智能体并发修改同一前端仓库的团队,都是一份可直接复用的工程模板。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00