HarmonyOS 7 ohpm list:依赖拓扑快照与版本漂移门禁
三方库升级的风险经常藏在“没有改过的地方”。业务只把一个直接依赖从小版本 A 升到小版本 B,锁文件却带进六个新的传递节点;本地增量构建没有异常,发布分支的完整安装才暴露包体、重复能力或兼容性问题。只看 oh-package.json5 的顶层声明,解释不了最终依赖树发生了什么。
这篇文章不讨论某个库好不好,而是构造一个可复用的依赖差异门禁:DependencyDeltaDesk。它调用官方 ohpm list 的 JSON 与递归选项生成快照,再把树规范化、与基线比较,最后只对可解释的变化给出门禁结论。演示任务是 DEP-1036-318,快照 ID 为 depgraph_318,目标环境 release-cn。基线 42 个节点,当前 47 个节点:新增 6、移除 1、其中 3 个包的版本或来源发生变化。团队给“新增传递依赖”设置的预算是 4,实际为 6,因此状态是 REVIEW_REQUIRED。

一、先问树发生了什么,再问谁该负责
依赖治理容易走向两个极端。一种做法是完全相信锁文件,只要安装可复现就不审差异;另一种是禁止任何传递依赖变化,把正常的安全修复也挡住。两者都缺少一个中间层:把变化转成稳定、可读、可追溯的事实。
HarmonyOS 的 OHPM 工具提供 ohpm list,官方文档说明它可以列出已安装依赖,并支持递归、JSON 输出等选项。本文使用的命令语义是 ohpm list -j -r:-j 输出 JSON,-r 展开递归依赖。命令支持情况应由项目锁定的 OHPM 版本确认;尤其在共享 CI 上,不要默认所有执行器都装着同一版工具。
快照不是复制一份终端文本。终端文本适合阅读,却常包含缩进、路径或顺序噪声。门禁需要的最小事实是:包名、版本、依赖关系、直接或传递身份、必要时的来源字段。任何当前命令未提供的字段都不能凭空补齐。例如,仅凭依赖树不能推断许可证合规,也不能把包名当作漏洞结论。
DependencyDeltaDesk 把流程分为“采集、适配、规范化、比较、裁决”五步。命令失败停在 CAPTURE_FAILED;JSON 结构不受适配器支持停在 ADAPTER_FAILED;比较完成但超过预算进入 REVIEW_REQUIRED;只有差异在预算内且人工规则无命中,才进入 READY。本次演示检查进度为 39/47,即 83%,说明仍有 8 个节点等待人工元数据核对,不能把它显示成 100%。
二、采集命令要避免 shell 拼接与无限输出
第一段代码只解决“可靠得到本次依赖树”的问题。它使用 execFile 直接传参数,不把工作目录或目标环境拼进 shell 字符串;设置超时与输出上限;同时记录工具版本。ohpm --version 与树快照一起保存,是为了后续解释格式差异,而不是把版本号硬编码成永远正确的条件。
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
interface CaptureResult {
toolVersion: string
capturedAt: string
rawTree: unknown
}
async function runOhpm(args: string[], cwd: string): Promise<string> {
const { stdout, stderr } = await execFileAsync('ohpm', args, {
cwd,
timeout: 30_000,
maxBuffer: 8 * 1024 * 1024,
windowsHide: true
})
if (stderr.trim()) {
// stderr 作为证据保留;是否失败以进程退出码为准
process.stderr.write(`[ohpm] ${stderr}`)
}
return stdout
}
export async function captureTree(projectRoot: string): Promise<CaptureResult> {
const version = (await runOhpm(['--version'], projectRoot)).trim()
const json = await runOhpm(['list', '-j', '-r'], projectRoot)
return {
toolVersion: version,
capturedAt: new Date().toISOString(),
rawTree: JSON.parse(json) as unknown
}
}
这里有两个故意保守的决定。第一,命令行参数写成数组;即使项目路径包含空格,也不会被 shell 重新解释。第二,JSON.parse 的结果仍是 unknown,不会因为 TypeScript 强制断言就自动可信。后续适配器必须逐字段检查。
采集动作应在干净的依赖安装之后执行。如果本地 oh_modules 残留旧节点,快照反映的是机器状态,不是提交状态。CI 中最好把安装日志、锁文件摘要、OHPM 版本和 depgraph_318 放在同一证据目录。若安装阶段使用了镜像或离线缓存,也要把源配置纳入审计范围,因为同名同版本并不总能证明内容来源一致。
三、不要把未知 JSON 结构写死在业务页面里
官方命令输出结构可能随工具版本演进。最脆弱的做法,是让 ArkUI 页面直接读取某个深层字段,例如 raw.dependencies[0].children。一旦输出字段改变,界面可能静默显示空树,门禁却误判为“没有变化”。
本工具因此设置一个窄适配层。它只接受经过样例测试的节点形状,并把所有版本差异收敛为 GraphNode。下面代码展示核心思想:从未知对象读取名称、版本与子节点候选字段;字段不满足契约时抛出错误。生产版本应按锁定的 OHPM 版本维护独立适配器,并用真实命令样本做回归,不能把“兼容多个猜测字段”当成长期方案。
interface GraphNode {
name: string
version: string
direct: boolean
parents: string[]
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v)
}
function asString(v: unknown, field: string): string {
if (typeof v !== 'string' || v.length === 0) {
throw new Error(`依赖快照字段无效:${field}`)
}
return v
}
export function normalizeKnownTree(raw: unknown): GraphNode[] {
if (!isRecord(raw) || !Array.isArray(raw.dependencies)) {
throw new Error('当前 OHPM JSON 不符合已验证的适配器契约')
}
const out = new Map<string, GraphNode>()
const visit = (value: unknown, parent: string | null, depth: number): void => {
if (!isRecord(value)) throw new Error('依赖节点不是对象')
const name = asString(value.name, 'name')
const version = asString(value.version, 'version')
const key = `${name}@${version}`
const node = out.get(key) ?? { name, version, direct: depth === 0, parents: [] }
node.direct ||= depth === 0
if (parent && !node.parents.includes(parent)) node.parents.push(parent)
out.set(key, node)
const children = value.dependencies
if (children === undefined) return
if (!Array.isArray(children)) throw new Error(`${key}.dependencies 不是数组`)
for (const child of children) visit(child, key, depth + 1)
}
for (const root of raw.dependencies) visit(root, null, 0)
return [...out.values()].sort((a, b) =>
`${a.name}@${a.version}`.localeCompare(`${b.name}@${b.version}`)
)
}
规范化键使用 name@version,但比较时还要单独按包名聚合。原因是版本升级会让旧键消失、新键出现;如果只做集合差,报告会写成“一删一增”,读者看不出它其实是同一包版本变化。parents 被排序并去重,用来回答“哪个直接依赖把它带进来”。循环引用或异常深度也应设置保护,示例为突出主线省略了深度上限,生产实现不能省略。
DevEco Studio 风格配图展示 tools/dep-delta、中间的适配代码、右侧 DependencyDeltaDesk 模拟器和底部 HiLog。它是围绕本次数据制作的演示图,不冒充真实 IDE 运行证据。

四、差异要区分新增、移除和版本漂移
depgraph_318 的基线节点数为 42,当前节点数为 47。集合关系可以复算:新增 6、移除 1,所以当前数量是 42 + 6 - 1 = 47。另有 3 个同名包的版本或来源发生变化;这些变化包含在当前与基线节点中,不应再加到节点总数上。
门禁最关注的是新增 6 个节点里有多少是传递依赖。演示中 6 个新增节点都不是顶层声明,因此 newTransitive=6;预算为 4,超出 2。状态由此进入 REVIEW_REQUIRED,而不是 FAILED。原因是新增传递依赖可能合理,但需要责任人解释父链、包体影响和版本选择。硬失败适用于解析失败、快照缺失或明确禁止的来源,这些属于无法审计,而不是需要判断。
下面代码解决“把两份稳定快照转成可解释结论”的问题。它按包名识别版本漂移,按完整键识别增删,并把门禁结果与数据分开返回,便于 UI、CI 注释和归档复用。
interface DeltaReport {
added: GraphNode[]
removed: GraphNode[]
changed: Array<{ name: string; from: string[]; to: string[] }>
newTransitive: number
budget: number
status: 'READY' | 'REVIEW_REQUIRED'
}
function byName(nodes: GraphNode[]): Map<string, Set<string>> {
const map = new Map<string, Set<string>>()
for (const n of nodes) {
const versions = map.get(n.name) ?? new Set<string>()
versions.add(n.version)
map.set(n.name, versions)
}
return map
}
export function diffGraph(
baseline: GraphNode[], current: GraphNode[], budget = 4
): DeltaReport {
const oldKeys = new Set(baseline.map(n => `${n.name}@${n.version}`))
const newKeys = new Set(current.map(n => `${n.name}@${n.version}`))
const added = current.filter(n => !oldKeys.has(`${n.name}@${n.version}`))
const removed = baseline.filter(n => !newKeys.has(`${n.name}@${n.version}`))
const oldNames = byName(baseline)
const newNames = byName(current)
const changed: DeltaReport['changed'] = []
for (const [name, fromSet] of oldNames) {
const toSet = newNames.get(name)
if (!toSet) continue
const from = [...fromSet].sort()
const to = [...toSet].sort()
if (from.join('|') !== to.join('|')) changed.push({ name, from, to })
}
const newTransitive = added.filter(n => !n.direct).length
return {
added, removed, changed, newTransitive, budget,
status: newTransitive > budget ? 'REVIEW_REQUIRED' : 'READY'
}
}
代码没有自动选择“更高版本就是更好”。依赖树可能并存多版本,也可能因约束收敛减少节点;只有结合变更说明、上游发布记录和项目验证才能判断。门禁的职责是把注意力集中到 6 个新节点和 3 个漂移包,而不是为开发者做不可解释的升级决定。
五、83% 代表审查覆盖,不代表命令执行进度
手机主页面显示任务 DEP-1036-318、目标 release-cn、快照 depgraph_318。圆环进度为 83%,旁边写明“已核对 39 / 47 节点”;摘要卡显示基线 42、当前 47、新增 6、移除 1、漂移 3。状态条是 REVIEW_REQUIRED,红色箭头只指向“新增传递依赖 6 > 预算 4”。

这张竖版运行图不是实际设备截图,而是数据契约的视觉演示。状态栏固定为 10:36、Wi‑Fi、5G、信号与 76% 电量。与文章第一篇使用不同的色彩、卡片密度和信息组织,避免两篇套同一界面。
为什么不是 100%?ohpm list 的采集与自动比较已经完成,但 8 个节点仍等待人工核对父链和变更理由。把机器执行完成误写成审查完成,会让发布者误以为所有新依赖已经被批准。页面因此将“采集完成”和“审查覆盖”拆成两个状态:前者是绿色小标签,后者保留 83%。
六、详情页从数字回到责任链
详情页不再展示总览环形图,而是分成三块。第一块列出预算:允许新增传递依赖 4,实际 6,超出 2。第二块列出三条漂移记录,每条都带旧版本集合、新版本集合与父包。第三块给出待办:确认 6 个新增节点的引入理由、核对 1 个移除节点是否仍被运行期动态使用、完成剩余 8 个节点的元数据检查。

底部日志保持精简:capture=PASS、normalize=47、delta=+6/-1/~3、review=39/47、gate=REVIEW_REQUIRED。任务 ID、时间、快照 ID和电量与 03 一致。03 回答“这次升级变化有多大”,04 回答“为什么需要人工审查以及谁把节点带进来”,两张图承担不同解释任务。
责任链不应该只显示最短路径。有些节点会被多个直接依赖共同引用,如果只留一条父链,移除某个顶层依赖后仍可能保留该节点。规范化模型中的 parents 因此是数组。UI 默认显示最多两条路径,其余折叠;导出的 JSON 报告保留完整列表,避免视觉简化破坏证据。
七、基线什么时候更新,是治理的核心问题
基线不是每次构建后自动覆盖。若门禁失败仍写入新基线,下一次比较就会把未经批准的变化当作正常。正确顺序是:生成候选快照、审查差异、完成验证、批准后再由受控流程更新基线。基线提交应包含任务 ID DEP-1036-318、快照 depgraph_318、OHPM 版本、锁文件哈希和批准记录引用。
分支策略也要明确。功能分支可以与主分支基线比较,发布分支则应与上一个已发布基线比较;二者回答的问题不同。前者控制单次合入的增量,后者解释用户将接收到的完整变化。release-cn 还可能有区域化依赖或构建参数,因此不能拿默认 debug 快照替代。
当多个开发者同时升级不同库时,合并后的依赖树可能发生新的收敛或分叉。单个分支都在预算内,合并结果仍可能超预算。所以门禁至少在拉取请求和发布候选构建两个阶段运行。报告 ID必须重新生成,不复用旧分支的结论。
八、失败处理与资源边界
采集进程要成对管理。超时后必须终止子进程并清理临时输出;解析失败要保留原始 stdout 的受限副本和 stderr 摘要;页面离开时要取消前端轮询,但不能粗暴终止仍由 CI 管理的后台任务。若工具运行在开发机,临时文件写入完成后再原子重命名,避免页面读到半份 JSON。
8 MiB 输出上限不是通用真理,只是示例保护值。大型仓库应根据历史快照分布调整,并在接近上限时告警。无限提高上限会把异常循环树转成内存风险;过小则截断合法输出。更稳妥的长期方案是工具支持流式输出或分层采集,但在官方命令没有这种保证时,不应虚构参数。
节点数也不能直接等同包体积。一个节点可能被构建优化移除,另一个节点可能包含较大原生库;依赖树门禁只能指示“需要看哪里”。包体差异、启动耗时与运行期行为需要各自的测量工具。把所有发布风险塞进一个分数,会得到漂亮但不可解释的仪表盘。
九、结论:把“锁文件变了”升级为可审计事实
DependencyDeltaDesk 最终没有替团队决定能否发布。它给出了足够具体的事实:基线 42、当前 47、新增 6、移除 1、漂移 3;新增传递依赖预算 4、实际 6;39/47 节点已核对,审查覆盖 83%;因此状态为 REVIEW_REQUIRED。任何人都能从同一快照重新计算这些数字。
这种工具的价值在于缩短讨论路径。开发者不再用“我只升级了一个库”描述风险,审查者也不必浏览整份锁文件寻找变化。双方围绕父链、版本集合、预算和未核对节点说话。命令采集、适配器、差异算法与批准流程各自有边界,升级工具版本时也能知道应该更新哪一层。
三方依赖治理最忌讳的不是变化,而是无法解释的变化。ohpm list -j -r 提供了观察树的入口;稳定快照与差异门禁把入口变成证据;人工审查再把证据变成发布判断。三者缺一不可。
十、参考资料
- 华为开发者联盟,OHPM
ohpm list命令说明(含递归、JSON 输出等选项):https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/ide-command-line-ohpm-list - 华为开发者联盟,OHPM 依赖分类与配置说明:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/ide-oh-package-json5
- 华为开发者联盟,DevEco Studio 导出与查看依赖树相关说明:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/ide-view-project-dependencies
更多推荐


所有评论(0)