工具工程:ModuleFence
结果页:ModuleGraphPage
任务 ID:graph_20261001_21

这次问题不是编译失败,恰恰是编译一直能过。一个业务模块为了复用弹窗,反向 import 了壳工程的实现;另一个已经删除的页面还留在 router_map.json;三个组件库通过各自的 index.ets 绕成了循环。开发机全量构建没报错,真机从冷启动进入促销页时,动态路由才提示目标不存在。

ModuleFence 就从这个事故开始。它不检查权限、隐私或签名,而是在 Hvigor 正式编译前,把 ArkTS import、模块边界和路由导出拼成一张图。固定样本包含 8 个模块、214 个 ArkTS 文件和 486 条依赖边;第一次扫描发现 12 条反向依赖、7 个失效路由导出和 3 个环。修复后全部归零,扫描耗时 1.7 s,状态为 GRAPH_CLEAN。

一、编译器能解析,不等于架构允许

工程最初按 entry → feature → foundation 设计:壳工程组合页面,业务模块依赖基础模块,基础模块不认识具体业务。几个月后,开发者为了少写一个接口,把 feature_order 里的代码直接指向 entry/src/main/ets/components/GlobalDialog.ets。路径存在、类型正确,编译器自然放行;但 feature 现在无法独立测试,也不能再打成可复用 HSP/HAR。

类似的问题还发生在 barrel export。index.ets 统一导出看起来整洁,却把依赖来源藏在了一层别名后面。A 从 B 的 index 引入类型,B 又从 C 引入常量,C 为了复用 A 的工具再指回 A。单个文件都没有明显异常,只有把边连成图才能看到循环。

我没有把规则硬编码成“entry 永远最高层”。ModuleFence 使用策略文件描述允许方向、公共出口和路由表位置。这样工具可以服务不同工程,而不是把当前项目结构写死。扫描状态分成 IDLE → PARSING → RESOLVING → GRAPH_CLEAN;解析完成不代表通过,只有路径解析、路由对账和环检测都结束才进入终态。

二、规则文件先把“反向”说清楚

当前要解决的问题,是让模块边界成为可评审的配置,而不是靠开发者记忆。每个模块声明层级和允许依赖对象;公共包只能从配置中的 exports 进入,禁止跨到内部目录。

export interface ModuleRule {
  name: string
  layer: number
  roots: string[]
  allow: string[]
  publicExports: string[]
}

export const architecturePolicy: ModuleRule[] = [
  { name: 'entry', layer: 3, roots: ['entry/src/main/ets'],
    allow: ['feature_order', 'feature_search', 'foundation'], publicExports: [] },
  { name: 'feature_order', layer: 2, roots: ['features/order/src/main/ets'],
    allow: ['foundation'], publicExports: ['Index.ets'] },
  { name: 'feature_search', layer: 2, roots: ['features/search/src/main/ets'],
    allow: ['foundation'], publicExports: ['Index.ets'] },
  { name: 'foundation', layer: 1, roots: ['commons/foundation/src/main/ets'],
    allow: [], publicExports: ['Index.ets'] }
]

这里的 layer 只用于生成易读报告,真正的放行依据是 allow,避免“数字小就一定能依赖”的隐式规则。扫描到 feature_order → entry 时会直接标记 REVERSE_EDGE;扫描到某模块内部文件却未经过 publicExports 时,标记 INTERNAL_IMPORT。数据从一条 import 语句变成带来源文件、行号、模块和错误码的边,后续才能稳定去重。

策略在构建进程启动时读取一次,扫描期间视为不可变。正式项目修改规则必须走代码评审,否则开发者可能为了让流水线变绿,直接把违规模块加入 allow。Demo 没有自动改写 import,因为跨模块依赖往往需要接口下沉或依赖倒置,脚本无法替团队做架构选择。

三、用 AST 找 import,也找字符串形式的动态路由

当前要解决的问题,是准确提取 import/export 与路由调用,避免正则把注释、测试字符串和模板文本当成依赖。工具使用 TypeScript Compiler API 建立 SourceFile,访问语法树节点;ArkTS 文件按 UTF-8 读取,无法解析时单独报告,不悄悄跳过。

import ts from 'typescript'

export interface SourceFacts {
  imports: string[]
  routeNames: string[]
  dynamicRouteLines: number[]
}

export function collectFacts(fileName: string, source: string): SourceFacts {
  const file = ts.createSourceFile(fileName, source,
    ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
  const facts: SourceFacts = { imports: [], routeNames: [], dynamicRouteLines: [] }

  const visit = (node: ts.Node): void => {
    if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
      facts.imports.push(node.moduleSpecifier.text)
    }
    if (ts.isCallExpression(node) && node.expression.getText(file).endsWith('pushPath')) {
      const arg = node.arguments[0]
      if (arg && ts.isObjectLiteralExpression(arg)) {
        const name = arg.properties.find((p: ts.ObjectLiteralElementLike) =>
          p.name?.getText(file) === 'name')
        if (name && ts.isPropertyAssignment(name) && ts.isStringLiteral(name.initializer)) {
          facts.routeNames.push(name.initializer.text)
        } else {
          facts.dynamicRouteLines.push(file.getLineAndCharacterOfPosition(node.pos).line + 1)
        }
      }
    }
    ts.forEachChild(node, visit)
  }
  visit(file)
  return facts
}

代码先解决“事实提取”,不在 visitor 里判断架构对错。静态字符串路由进入 routeNames,后续与各模块 router_map.json 的注册名对账;变量、函数返回值或字符串拼接进入 dynamicRouteLines,要求开发者通过受控的 RouteCatalog 映射,不能因为工具算不出来就当成通过。

这也是 7 个失效导出的来源:路由表里保留了旧名称,源码已经没有对应页面工厂;另有两处调用仍使用删除前的别名。工具双向对账,既找“调用不存在”,也找“注册无人使用”。后者不一定是错误,所以报告为 warning;本文固定样本把确认废弃的 7 项删除后,stale route 数变为 0。

需要说明的是,TypeScript AST 对当前 Demo 使用的 ArkTS 子集足够,但新语法或编译器扩展可能需要升级解析器。解析诊断不为空时,ModuleFence 直接失败并列出文件,不能输出 GRAPH_CLEAN。正式团队也可以替换为 ArkTS 专用分析能力,策略层和图算法不需要跟着重写。

四、环检测必须给出可修复路径

当前要解决的问题,是把“发现循环”变成开发者能处理的路径。只输出 A、B、C 在同一强连通分量里还不够,报告应显示一条闭环和每条边来自哪个文件。

export class CycleDetector {
  static find(graph: Map<string, string[]>): string[][] {
    const cycles: string[][] = []
    const visiting = new Set<string>()
    const visited = new Set<string>()

    const walk = (node: string, path: string[]): void => {
      if (visiting.has(node)) {
        const start = path.indexOf(node)
        cycles.push([...path.slice(start), node])
        return
      }
      if (visited.has(node)) return
      visiting.add(node)
      for (const next of graph.get(node) ?? []) walk(next, [...path, node])
      visiting.delete(node)
      visited.add(node)
    }

    for (const node of graph.keys()) walk(node, [])
    return cycles
  }
}

这段简化实现适合展示闭环路径,生产版还会对结果规范化和去重,避免同一个环从不同起点输出多次。扫描到的 3 个环分别通过“下沉纯类型到 foundation”“把回调接口反转到 feature”“取消多余 barrel export”解决,而不是加延迟 import 掩盖。图数据在一次扫描中保持只读,检测结束再生成报告,避免遍历过程中修改边导致结果不稳定。

要注意,文件级环与模块级环是两套口径。ModuleFence 先按文件解析,再聚合成模块边;同一模块内部的小环可设为 warning,跨模块环则阻断构建。Demo 两种都统计,但手机结果页展示的是跨模块 3→0。测试文件、生成目录和产物目录由策略明确排除,不能用目录名模糊匹配,否则可能漏掉真实源码。

五、接到 Hvigor 前,先保证脚本单独可运行

ModuleFence 提供 node tools/module-fence.mjs --report,本地可以快速跑;随后才注册为 Hvigor 自定义任务,并让组装任务依赖它。这样排查工具自身问题时,不需要每次完整打包。Hvigor 负责任务编排,扫描器负责读取源码与输出退出码,两者边界清楚。

插件执行时先生成临时报告,再原子替换 build/reports/module-fence.json。扫描失败不覆盖上一次成功报告,避免 IDE 面板显示半份数据。进程退出前关闭文件句柄;watch 模式下复用文件哈希缓存,但策略文件或路由表变化会让缓存整批失效。并发触发同一任务时使用项目级锁,第二次调用等待已有 Promise,不创建两个扫描器争抢报告文件。

固定回放的 HiLog/控制台摘要为:

task=graph_20261001_21 modules=8 files=214 edges=486
reverseEdges=12->0 staleRoutes=7->0
cycles=3->0 scanTime=1.7s
state=GRAPH_CLEAN

DevEco Studio 图中,左侧目录是 tools/scanner、tools/rules、tools/report 与业务模块;中间打开 ArkTsFactCollector.ts;右侧模拟器显示 Module Fence 结果页;底部日志与正文一致。红色标注只圈出动态路由分支,提醒读者“无法静态确定”不是“没有问题”。

六、结果页不是为了把构建工具做成产品

我保留 ModuleGraphPage,是为了让开发者在真机联调包里快速核对当前产物使用的扫描摘要。页面只读取构建时写入 resources 的只读结果,不在手机上重新扫描源码。状态栏时间为 21:52,5G、Wi-Fi、信号和 84% 电量齐全;页面显示 8 个模块、214 个文件、486 条边,以及三类问题全部归零。

红色细箭头指向 12 → 0 的反向依赖,旁边写“边界已收口”。状态流为 IDLE → PARSING → RESOLVING → GRAPH_CLEAN。如果报告版本与应用构建号不一致,页面显示 REPORT_STALE,绝不能拿旧的绿色结果为新产物背书。正式发布包可关闭入口,但流水线仍保存 JSON 与可读文本报告。

验收脚本还做了三次破坏测试:新增一条 feature → entry import,任务必须失败并给出文件行号;把 product_detail 从路由表删除,调用侧必须报告 missing;制造 order → search → order,报告必须输出闭环。随后撤销改动再次扫描,状态才恢复 GRAPH_CLEAN。这比只跑一次绿色样本更能证明门禁有效。

增量扫描是这套工具能否长期留在工程里的关键。首次运行读取 214 个文件,后续根据内容哈希只重建变化文件的事实;但单个文件变化后,依赖图不能只重算它自己。ModuleFence 会删除该文件贡献的旧边,写入新边,再对受影响模块重新做可达性与环检测。路由表、策略文件或解析器版本变化属于全局输入,必须让缓存整体失效,不能为了追求速度复用旧结论。

报告去重也有明确主键:错误码 + 来源模块 + 目标模块 + 规范化路径 + 行号。如果只是文件前面多了一行注释,行号变化会生成新记录,但旧记录同时消失,不会在面板里累积历史幽灵。流水线另存趋势时才使用不含行号的稳定指纹,用来判断某类问题是否反复出现。运行报告与趋势统计分开,开发者看到的始终是当前代码事实。

对于动态路由,我没有提供随手写注释就能跳过的 ignore。确实无法静态确定的名称必须经过 RouteCatalog.resolve(),目录里维护允许值与所属模块;扫描器识别这个唯一出口,并验证目录项存在于路由表。这样运行时仍然可以根据业务条件选择页面,但“动态”不再等于任意字符串。若新增插件式模块,则由插件在构建阶段合并自己的目录片段,冲突名称直接失败。

门禁上线的第一周没有把 22 个存量问题一次性全部设为阻断。我先生成基线,只阻止新问题增加;随后按模块逐批清零,模块归零后切换为严格模式。否则开发者面对一整页旧债,很可能选择永久关闭工具。基线文件记录错误指纹和到期时间,超过期限仍未处理会自动变成失败,避免“临时豁免”变成长期配置。

最后还要防工具越权。扫描器只读源码、配置与路由表,报告写入限定的 build 目录;不执行被扫描文件,也不解析远程依赖中的脚本。符号链接会规范化并检查是否仍在项目根目录内,越界路径直接拒绝。一个构建前门禁如果为了分析依赖而获得无边界文件访问,本身就会成为新的工程风险。

七、门禁的价值是让坏依赖尽早变贵

跨模块反向依赖往往不会当天出故障。它先让单元测试变慢、模块无法独立构建,最后在动态加载、裁剪或多人并行时爆出来。等到运行期才修,开发者面对的是页面、路由、打包和生命周期混在一起的症状;放到构建前,问题只是一条带行号的边。

ModuleFence 没有试图替代编译器,也没有把所有 warning 都升级成错误。它只把团队已经同意的架构方向、路由出口和环规则变成可重复执行的检查。214 个文件增加 1.7 s,换来的是每次提交都能回答:依赖有没有倒流、路由有没有失效、模块有没有成环。这种工具不显眼,却能让工程边界不再依赖某个资深开发者“刚好记得”。

参考资料:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐