首页
/ OpenHuman UI 迁移 Wave 工作流解析:report.mjs 工作清单与并发安全分区实践

OpenHuman UI 迁移 Wave 工作流解析:report.mjs 工作清单与并发安全分区实践

2026-09-09 20:35:19作者:凤尚柏Louis

导读

本文深入剖析 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.tsxNativeSelect.tsxTextArea.tsxSwitch.tsxTable.tsxDialog.tsxSheet.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.tstailwind.config.jspnpm-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",并记录了 ButtonGroupHoverCard零消费者被删除而非强行保留的决策——一个无人消费的原语会让每次审计都误读为"已迁移",这种状态比不存在更糟。而 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.tstailwind.config.jspnpm-lock.yaml 为何"无人拥有":这些文件的改动会强制整条串行 CI 套件重跑,让快速波次失去意义。

七、反向审计:orphans.mjs 如何揪出"假迁移"

与 report.mjs 配套的 orphans.mjs 从另一个方向保障迁移质量:找出已存在但无人 import 的原语。脚本源码记录过真实案例:ui/Sidebar.tsx(625 行)与 app shell 自身的宽度常量逐字节相同却无人使用,ai-elements/ 的十个组件也全部闲置。

它的匹配规则有三个值得借鉴的精确性设计:

  1. 按模块路径匹配,而非符号匹配ui/AlertDialog 导出的是 AlertDialogRootAlertDialogContent 这类带后缀符号,用 \bAlertDialog\b 匹配会在 Root 前的词边界失败,把 23 个正常使用的原语误报为孤儿;按路径匹配则命名无关、无歧义。
  2. barrel import 单独匹配,正则要求原语名后必须跟大写后缀或结束${name}([A-Z][A-Za-z]*)?)。大写约束是承重的:如果允许任意后缀,Tool 会匹配到 Tooltip,导致 import { Button, Tooltip } 被计为 Tool 的三个假消费者。
  3. pages/dev/** 画廊刻意不计为消费者——它按设计渲染每个原语,计入就会掩盖所有孤儿。

八、实践要点总结

综合工作流文档与源码,落地一波 UI 迁移的标准动作是:

  1. 盘点:运行 node scripts/ui-codemod/report.mjs 查看剩余工作与 bucket 分布;需要机器输入时加 --json
  2. 定向查看:用 --bucket components/settings 之类参数单独预览某个 bucket,评估负载。
  3. 分波执行:以 { primitives: [...], buckets: [...] } 调用 ui-migration-wave,每个 bucket 交给一个 Agent,严格遵守共享文件禁区;未测试文件务必同变更附带冒烟测试,否则 diff-cover 0% 会拖垮整批。
  4. 波间验收:跑完整测试套件与 diff-cover dry run,弥补工作流窄门禁的盲区。
  5. 反向检查:定期运行 node scripts/ui-codemod/orphans.mjs,确保没有"写完即闲置"的原语残留。

这套"单一 checkout + 不相交 bucket + 共享文件禁区 + 双层覆盖率门禁"的编排,使得大规模 UI 重构可以在多 Agent 并发下安全推进,同时把 submodule 仓库特有的 gitlink 损坏风险降为零——对任何需要多智能体并发修改同一前端仓库的团队,都是一份可直接复用的工程模板。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.76 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.35 K
docsdocs
暂无描述
Markdown
899
5.83 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
925
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.84 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
601
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.03 K
525
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
395