mcpField
F
fix-cyclic-deps

fix-cyclic-deps

github.com/ui5开发44145 次安装1 周前更新
代码仓库

关于 fix-cyclic-deps

fix-cyclic-deps 是用于发现和处理 UI5 模块循环依赖的开发技能,也适用于现代化改造引入的依赖环。它分析依赖图,并在运行时可达性允许时,将选定的静态导入改为延迟 sap.ui.require 调用。

主要功能

  1. 01构建并检查 UI5 模块依赖图
  2. 02检测双节点及更长依赖环
  3. 03依据可达性和使用情况选择延迟边
  4. 04将静态导入改为延迟加载
  5. 05报告无法自动修复的依赖环

适用场景

  1. 01排查运行时返回 undefined 的模块
  2. 02检查 UI5 现代化改造后的循环依赖
  3. 03处理新增模块导入引起的依赖环
  4. 04记录需要手动重构的依赖环

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/ui5/plugins-coding-agents --skill fix-cyclic-deps

安装后在 Claude Code 中输入/fix-cyclic-deps确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
dependency-graph-analysis.md
scripts/
detect-cycles.js
detect-unsafe-lazy.js
detect-unsafe-lazy.test.js
scripts/lib/
graph-utils.js

详细介绍

修复循环模块依赖

此技能用于检测和解决 UI5 现代化过程中出现的循环模块依赖。当现代化将全局命名空间访问转换为 sap.ui.define 导入时,新的依赖边可能会形成循环导入(A 导入 B,B 导入 A),导致 UI5 AMD 加载器对反向边模块返回 undefined。

修复方法:将反向边上的 sap.ui.define 依赖替换为在每个调用点使用延迟加载的 sap.ui.require("path/to/Module")(同步形式),从加载器缓存中获取已加载的模块,而不创建依赖边。

Linter 规则

规则 ID消息模式此技能的操作
(无 — 结构性问题)运行时:尽管导入路径正确,模块仍为 undefined检测依赖图中的循环,并将反向边转换为延迟加载的 sap.ui.require()

此技能不会由 UI5 linter 规则触发。它用于解决模块依赖图中的结构性问题。在现代化工作流的最后修复阶段会触发此技能;当开发者在运行时遇到 undefined 模块时,也可以单独使用。

使用场景

  • 在现代化工作流中:作为第 3 阶段第 3.3 步(最后一步),在第 3 阶段的所有其他步骤完成后执行。多个步骤会添加 sap.ui.define 依赖边,从而可能形成循环 — 在最后运行才能基于最终依赖图进行处理。
  • 单独使用:当模块在运行时返回 undefined,而导入路径正确时。
  • 手动重构后:当开发者添加新的 sap.ui.define 依赖后,意外得到 undefined 时。

循环的来源

三种现代化操作会添加新的 sap.ui.define 依赖边,从而可能引入循环:

1. fix-js-globals 案例 1c — 全局命名空间读取

将 var Helper = com.example.app.utils.Helper; 转换为 sap.ui.define 依赖。如果 Helper 已导入当前模块,添加反向边就会形成双节点循环。

2. fix-js-globals 案例 10 — jQuery.sap.declare/require 现代化

将旧版 jQuery.sap.declare / jQuery.sap.require 代码包装在 sap.ui.define 中。jQuery.sap.require 调用会变为依赖项,可能形成旧版同步加载器采用不同方式处理的循环。

3. modernize-test-starter — 测试文件依赖

之前通过全局命名空间链访问模块的测试文件,现在会获得正确的 sap.ui.define 导入。相互引用的测试工具文件可能形成循环。

背景 — 为什么循环会破坏 UI5 加载器

UI5 AMD 加载器通过深度优先遍历来解析 sap.ui.define 依赖。遇到循环时:

  1. 加载器开始加载模块 A
  2. A 依赖 B → 加载器开始加载 B
  3. B 依赖 A → 但 A 尚未完成
  4. 加载器对 A 的工厂结果(反向边)返回 undefined
  5. B 的工厂收到 undefined,而它原本需要的是 A 的导出内容

**双节点循环(A↔B)**必定会导致问题。**更长的依赖链(A→B→C→A)**在运行时是否会出问题取决于加载顺序 — 它们属于潜在缺陷。

为什么同步的 sap.ui.require 能打破循环

同步形式 sap.ui.require("path/to/Module") 不会创建加载器依赖边。它只会从模块缓存中读取,不会触发加载。

关键注意事项:如果目标模块的工厂尚未执行,sap.ui.require(path) 会返回 undefined。因此,只有当目标模块可通过从当前入口点出发的纯静态路径访问,且该路径在惰性调用位置执行之前触发时,才是安全的。

当你移除静态边 A → B,并将其替换为 A 中某个函数内的 var B = sap.ui.require("B") 时,必须确认加载 A 的每个入口控制器都能访问 B。

sap.ui.require 的三种形式——了解它们的区别:

  • sap.ui.require("path/to/B") — 同步读取缓存。不触发加载。如果模块不在缓存中,则返回 undefined。
  • sap.ui.require(["path/to/B"], function(B) {...}) — 异步加载并执行回调。会触发加载。
  • sap.ui.requireSync("path/to/B") — 同步加载并返回结果。会触发加载。已弃用。

关键区别:

  • sap.ui.define(["path/to/B"], ...) — 创建加载器边 A→B(有循环风险)
  • 函数体内的 var B = sap.ui.require("path/to/B") — 不创建加载器边(如果 B 在缓存中,则安全)

检测算法

自动检测脚本

一个捆绑脚本可自动执行检测(构建图、验证、检测循环、统计使用次数、识别枢纽):

node <skill-dir>/scripts/detect-cycles.js <project-root>

该脚本会:

  1. 从 manifest.json 中发现项目命名空间
  2. 扫描所有 .js 文件(应用 + 测试),移除注释并解析 sap.ui.define 数组
  3. 构建依赖图(仅包含项目内部模块)
  4. 通过回退分析验证图的完整性
  5. 检测 2 节点循环和更长的链(Tarjan 的 SCC)
  6. 统计使用次数,并识别惰性侧(2 节点)或枢纽(3 个及以上节点)

输出为写入 stdout 的 JSON。使用此输出来指导修复阶段。如果无法使用该脚本,请参阅下方的手动流程以及 references/dependency-graph-analysis.md。

手动流程
步骤 1 — 发现项目命名空间

读取 manifest.json → sap.app.id → 例如 "com.example.myapp" → 转换为斜杠形式:"com/example/myapp"。这用于区分项目内部模块与 sap/* 框架依赖项。

步骤 2 — 构建依赖图

解析所有 .js 文件(应用源代码和测试)。对每个文件:

  1. 移除注释(// 和 /* */),同时保留字符串内容。这样可以避免匹配注释掉的代码中的 sap.ui.define。

  2. 查找 sap.ui.define,使用允许各个标记之间有任意空白的正则表达式:

    /sap\s*\.\s*ui\s*\.\s*define\s*\(/
    

    关键:更简单的 sap\.ui\.define\s*\( 会漏掉 sap.ui.define 被拆分到多行的文件。真实代码库中确实存在这种写法。

  3. 提取依赖数组:从 define 后面的 ( 开始,查找 [ 到 ],并正确处理括号深度。提取字符串字面量。

  4. 仅筛选项目内部模块(与命名空间匹配)。包含以 test-resources/ 为前缀的路径。

  5. 构建有向图:节点 = 模块路径(不含 .js),边 = 依赖关系。

排除项:框架依赖项(sap/*)、函数体中的 sap.ui.require 调用、字符串字面量(扩展名称、片段路径)。

步骤 2b — 验证图的完整性

构建图后,验证每个被引用的依赖也已解析。缺失的模块可能会掩盖完整的循环链。

对于每个被引用为依赖项但没有对应图节点的模块:

  1. 检查源文件是否存在。如果不存在,则跳过并附带信息提示。
  2. 如果文件存在,则运行回退分析:扫描 [...] 数组中的项目命名空间字符串。如果找到,但主解析器未提取到任何内容,则将其合并到图中。
  3. 还要检查已找到 sap.ui.define 但第一个参数不是数组的文件。运行相同的回退分析。
步骤 3 — 检测 2 节点循环

对于每条边 A→B,检查是否也存在 B→A。收集唯一的配对(去重)。

第 4 步 — 检测更长的链(3 个以上节点)

运行 Tarjan 的 SCC 算法。任何包含 3 个以上节点的 SCC 都是较长的循环链。完整算法伪代码请参见 references/dependency-graph-analysis.md。

2 节点循环的修复策略

决策:哪一侧采用延迟处理
阶段 1 — 运行时可达性检查
  1. 确定入口点:Component.js + 每个静态导入 A 或 B 的控制器。
  2. 对每个入口点遍历静态依赖图,检查 {A, B} 中哪些模块可达。
  3. 根据覆盖范围选择延迟侧:
    • 如果从每个入口点都能通过 B 到达 A → 在 B 中将 A 设为延迟加载是安全的。
    • 如果从每个入口点都能通过 A 到达 B → 在 A 中将 B 设为延迟加载是安全的。
    • 如果两侧的覆盖范围都完整 → 进入阶段 2。
    • 如果两侧的覆盖范围都不完整 → 还需要向控制器添加静态依赖(参见“追加到控制器的补救方案”)。
阶段 2 — 使用次数作为决胜条件

当两侧同样安全时:

  1. 统计使用次数:A 的代码主体中对 B 的引用数量,与 B 的代码主体中对 A 的引用数量(排除字符串、注释、sap.ui.define 数组)。
  2. 使用次数较少的一侧胜出:代码改动更少。
  3. 决胜条件 1:总依赖项更多的模块保留常规导入(编排器模式)。
  4. 决胜条件 2:按字母顺序靠前的一侧保留常规导入。
转换步骤

要在 A 中将 B 设为延迟加载(A 不再静态导入 B):

步骤 1 — 从 A 的依赖数组及相应参数中移除 B:

// Before:
sap.ui.define(["path/to/B", "path/to/C"], function(B, C) {
// After:
sap.ui.define(["path/to/C"], function(C) {

步骤 2 — 在每个使用位置添加延迟 require:

// Before:
someMethod: function() {
    B.doSomething();
    B.doSomethingElse();
}
// After:
someMethod: function() {
    var B = sap.ui.require("path/to/B");
    B.doSomething();
    B.doSomethingElse();
}

每个函数使用一个 sap.ui.require — 每个使用 B 的函数都需要单独声明。

声明关键字:与周围代码风格一致(现代代码使用 const,旧版代码使用 var)。

步骤 3 — 清理:移除 var X = X; 形式的自赋值。如果 B 没有任何代码引用,只需从依赖项中移除,无需添加延迟 require。

特殊情况:模块已部分采用延迟加载

如果 A 已有 sap.ui.require("path/to/B") 调用,且 sap.ui.define 数组中仍有 B,请从 sap.ui.define 中移除 B,并确保所有剩余的使用位置都有延迟 require。不要重复添加现有调用。

更长的链(3 个以上节点)— 基于中心模块的自动修复

较长的链通过基于中心模块的方法自动修复。识别每个 SCC 中的中心模块,并将其创建循环的依赖项设为延迟加载。修复一个中心模块即可同时消除多条链。

为什么采用基于中心模块的方法?

中心模块位于多条循环路径的中心。与其在多个文件中修复依赖边,不如将中心模块中的少数依赖项改为延迟加载,一次性打破所有循环。

中心模块识别算法

对于每个包含 3 个以上节点的 SCC:

  1. 循环参与度得分:统计每个节点指向 SCC 中其他成员的出边数量。得分越高,将这些依赖项设为延迟加载所能打破的循环就越多。
  2. 选择中心模块:选择得分最高的节点。决胜条件:总依赖项更多者优先(编排器)。最终决胜条件:按字母顺序靠前者优先。
  3. 识别创建循环的依赖项:中心模块的 sap.ui.define 依赖项中属于该 SCC 的部分。
  4. 应用与 2 节点循环相同的转换。
  5. 重新检测 SCC:如果仍有循环,则重复此过程。
决策:将中心模块的哪些依赖项设为延迟加载

将中心模块的所有 SCC 内部依赖项设为延迟加载。这样可确保打破所有经过中心模块的循环。如果中心模块的某个 SCC 内部依赖项没有任何使用位置,只需将其移除。

后备方案:报告至 MODERNIZATION-ISSUES.md

如果模块在函数体之外的顶层引用了创建循环的依赖项(此处的延迟 sap.ui.require 会返回 undefined),请报告:

### Cyclic Dependency Chain (unfixable automatically)

- **SCC nodes**: A, B, C, D
- **Hub identified**: A (3 outgoing SCC edges, 4 incoming)
- **Blocking reason**: A references B at module top level (line 15), outside any function body.
- **Suggested manual fix**: Restructure A to defer the B reference into a function body, or extract the top-level initialization into a separate non-cyclic module.

修复前后示例

示例 1 — 仅有 1 个使用位置的 2 节点循环(ModuleA ↔ ModuleB)

ModuleA 导入 ModuleB,ModuleB 导入 ModuleA。ModuleB 只在 1 个调用位置使用 ModuleA。

修复前(有问题):

// ModuleB.js
sap.ui.define([
    "com/example/myapp/utils/ModuleA",
    "sap/ui/thirdparty/jquery"
], function(ModuleA, jQuery) {
    var ModuleB = {
        handleStatus: function(aSelectedItems, sId) {
            ModuleA.processStatus(aSelectedItems, sId);  // undefined!
        }
    };
    return ModuleB;
});

修复后:

// ModuleB.js
sap.ui.define([
    "sap/ui/thirdparty/jquery"
], function(jQuery) {
    var ModuleB = {
        handleStatus: function(aSelectedItems, sId) {
            var ModuleA = sap.ui.require("com/example/myapp/utils/ModuleA");
            ModuleA.processStatus(aSelectedItems, sId);
        }
    };
    return ModuleB;
});
示例 2 — 有多个使用位置的 2 节点循环(Orchestrator ↔ Helper)

Orchestrator 导入 Helper(使用 5 次),Helper 导入 Orchestrator(使用 25 次)。决策:在 Orchestrator 中将 Helper 设为延迟加载(需要修改的使用位置更少)。

修复前(有问题):

// Orchestrator.js
sap.ui.define([
    "com/example/myapp/utils/Helper",
    "com/example/myapp/utils/Validator"
], function(Helper, Validator) {
    var Orchestrator = {
        openDialog: function() { Helper.openDialog(); },
        refreshAll: function() { Helper.refreshAll(); }
        // ... 3 more Helper usage sites
    };
    return Orchestrator;
});

修复后:

// Orchestrator.js
sap.ui.define([
    "com/example/myapp/utils/Validator"
], function(Validator) {
    var Orchestrator = {
        openDialog: function() {
            var Helper = sap.ui.require("com/example/myapp/utils/Helper");
            Helper.openDialog();
        },
        refreshAll: function() {
            var Helper = sap.ui.require("com/example/myapp/utils/Helper");
            Helper.refreshAll();
        }
        // ... each function gets its own lazy require
    };
    return Orchestrator;
});
示例 3 — 移除未使用的依赖

ModuleX 导入了 ModuleY,却从未在代码中引用它——只需移除这个无用的导入。

之前:

sap.ui.define([
    "com/example/myapp/utils/ModuleY",
    "com/example/myapp/utils/ModuleZ"
], function(ModuleY, ModuleZ) {
    // ModuleY never used

之后:

sap.ui.define([
    "com/example/myapp/utils/ModuleZ"
], function(ModuleZ) {
示例 4 — 通过 Hub 自动修复较长链(ModelManager Hub)

ModelManager 通过依赖 FilterHelper 和 ChartHelper 参与了 7 条循环链。Hub 分析结果:ModelManager 有 2 条指向 SCC 的出边,因此它是 hub。

之前(有问题):

// ModelManager.js
sap.ui.define(["sap/ui/thirdparty/jquery",
    "com/example/myapp/utils/FilterHelper",
    "com/example/myapp/utils/Payload",
    "com/example/myapp/utils/ChartHelper",
    "sap/base/Log"
], function(jQuery, FilterHelper, oPayload, ChartHelper, Log) {
    var ModelManager = {
        getFilterConfig: function() {
            var oFilterConfig = FilterHelper;  // undefined due to cycle!
        },
        getChartType: function() {
            var oChartType = ChartHelper.getAnnotationType();  // undefined!
        }
    };
    return ModelManager;
});

之后(已修复——两个 SCC 内部依赖都已改为延迟加载):

// ModelManager.js
sap.ui.define(["sap/ui/thirdparty/jquery",
    "com/example/myapp/utils/Payload",
    "sap/base/Log"
], function(jQuery, oPayload, Log) {
    var ModelManager = {
        getFilterConfig: function() {
            var FilterHelper = sap.ui.require("com/example/myapp/utils/FilterHelper");
            var oFilterConfig = FilterHelper;
        },
        getChartType: function() {
            var ChartHelper = sap.ui.require("com/example/myapp/utils/ChartHelper");
            var oChartType = ChartHelper.getAnnotationType();
        }
    };
    return ModelManager;
});

从 1 个文件中移除 2 个依赖,便打破了全部 7 条循环链。注意:var oFilterConfig = FilterHelper; 是一种旧式别名模式——该技能会保留现有代码,只转换导入机制。

实施步骤

  1. 运行检测脚本,构建依赖图并检测所有循环:

    node <skill-dir>/scripts/detect-cycles.js <project-root>
    

    JSON 输出包含 twoNodeCycles(含使用次数和延迟加载侧的判断)以及 longerChains(含 hub 识别结果)。如果脚本不可用,请遵循上述手动流程。

  2. 检查输出:查看 warnings 和 errors 数组,确认依赖图是否完整。继续之前先处理错误。

  3. 修复每个 2 节点循环(来自 twoNodeCycles[]):

    • lazySide 字段标识需要修改的模块
    • 从 sap.ui.define 数组和函数参数中移除循环依赖
    • 在每个使用位置添加 var Module = sap.ui.require("path/to/Module")
    • 清理残留内容
  4. 通过 hub 修复较长链(来自 longerChains[]):

    • hub 和 hubInternalDeps 字段标识需要延迟加载的模块和依赖
    • 如果所有使用位置都位于函数体内 → 应用延迟加载转换
    • 如果有任何使用位置位于模块顶层 → 报告至 MODERNIZATION-ISSUES.md
    • 重新运行 SCC 检测——如果仍有循环,则重复处理
  5. 验证——重新运行循环检测:

    node <skill-dir>/scripts/detect-cycles.js <project-root>
    

    输出应显示 twoNodeCycles: [] 和 longerChains: []。然后运行 npx @ui5/linter --details 进行回归检查。

  6. 验证静态覆盖情况:

    node <skill-dir>/scripts/detect-unsafe-lazy.js <project-root>
    

    检查每个 sap.ui.require("M") 中的 M,确保每个入口控制器都能通过静态链访问到它。如果存在发现项,请应用下方的“追加到控制器的补救方法”。重复运行,直到 unsafeCount: 0。只有两个脚本都报告 0 个问题,技能才算完成。

追加到控制器的补救方法

当 detect-unsafe-lazy.js 报告有未覆盖的入口时,将延迟加载目标追加到控制器的 sap.ui.define 数组中,作为仅用于加载的副作用导入:

// Before — controller does NOT statically import DialogHelper
sap.ui.define([
    "com/example/myapp/utils/ActionHandler",
    "sap/ui/core/mvc/Controller"
], function(ActionHandler, Controller) {
    // ActionHandler has lazy require to DialogHelper
    // → sap.ui.require("DialogHelper") returns undefined

// After — append as load-only dep (no factory param)
sap.ui.define([
    "com/example/myapp/utils/ActionHandler",
    "sap/ui/core/mvc/Controller",
    "com/example/myapp/utils/DialogHelper"
], function(ActionHandler, Controller /* no DialogHelper param */) {
    // DialogHelper now in cache → lazy require works

规则:

  • 追加到依赖数组末尾,不添加对应的工厂参数
  • 如果 staticUncovered 列出 3 个或更多控制器,则改为将依赖添加到 BaseController.js 或 Component.js
  • 每次修复后重新运行 detect-unsafe-lazy.js,确认问题已解决

注意事项 — 关键规则

  1. 在函数体内部延迟 require:将 var B = sap.ui.require("path/to/B") 放在使用 B 的函数内部,而不是模块顶层。在定义时,B 可能尚未缓存。

  2. 每个函数只 require 一次:每个使用该模块的函数体都需要自己的声明。变量的作用域为函数级。

  3. 仅限项目内部模块:框架依赖项(sap/*)不会导致项目级循环依赖。只处理与项目命名空间匹配的依赖项。

  4. 字符串字面量不算使用:日志消息、.extend() 类名、片段路径都是字符串,不是代码引用。不要统计或修改它们。

  5. 注释掉的代码不算使用:// 或 /* */ 内的行不算使用。

  6. 参数对齐:移除索引 N 处的依赖项 → 移除索引 N 处的函数参数。仅当被移除的依赖项有对应参数时才进行调整。

  7. 现有的延迟 require 不算依赖边:函数体中的 sap.ui.require("path/to/X") 不会产生加载器依赖边。只有 sap.ui.define 数组条目才算依赖边。

  8. 幂等:重复运行是安全的。第二次运行时,循环依赖已被打破,不会进行任何更改。

  9. 包含测试文件:依赖图必须包含测试文件。modernize-test-starter 添加 sap.ui.define 依赖项后,测试文件也可能参与循环依赖。

  10. 不要改动异步 sap.ui.require:异步形式 sap.ui.require(["path/to/B"], function(B) {...}) 会产生加载器依赖边。此技能仅使用同步的单字符串形式。

  11. 多行 sap.ui.define 模式:正则表达式必须允许 sap、.、ui、.、define、( 之间出现任意空白或换行。较简单的正则表达式会漏掉拆分的模式,导致循环依赖链被隐藏。

  12. 解析前移除注释:搜索 sap.ui.define 前,始终先移除注释(同时正确处理字符串字面量)。文件可能会在注释中将其用作章节标记。

  13. 验证图的完整性:检查每个引用的依赖项是否都已解析。对主要解析器未找到任何内容的文件运行备用分析。在检测循环依赖前,合并发现的依赖项。

相关技能

  • fix-js-globals:案例 1c 和案例 10 是产生循环依赖的新依赖边的主要来源。请在 fix-js-globals 完成后运行 fix-cyclic-deps。
  • modernize-test-starter:测试文件现代化会添加 sap.ui.define 依赖项,这些依赖项可能导致测试工具之间,或测试模块与应用模块之间出现循环依赖。
  • modernize-ui5-app:负责协调阶段顺序的上级工作流。此技能作为第 3 阶段第 3 步(最终步骤)运行。

评论

支持 Markdown