关于 detect-static-dependencies
用于审计 C#/.NET 代码库中难以测试的静态及环境依赖,并按调用点和受影响文件排序,提供可用于改善可测试性的现有抽象建议。
主要功能
- 01扫描 C# 文件中的依赖模式
- 02汇总并排序调用点
- 03报告受影响文件及位置
- 04建议 .NET 抽象方案
适用场景
- 01编写单元测试前评估可测试性
- 02查找遗留代码中的静态耦合
- 03确定封装迁移的优先级
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dotnet/ skills --skill detect-static-dependencies安装后在 Claude Code 中输入/detect-static-dependencies确认已加载。
详细介绍
检测静态依赖
扫描 C# 代码库中对难以测试的静态 API 的调用,并生成一份排名报告,展示出现频率最高的静态 API、受影响最严重的文件,以及 .NET 生态中现有的可替代抽象。
适用场景
- 在添加单元测试前,审查项目的可测试性
- 了解旧代码库中静态耦合的范围
- 确定优先封装哪些静态 API(优先处理出现频率最高的)
- 制定渐进式提升可测试性的迁移计划
响应指南
- 根据用户的请求调整响应范围。若用户询问某个特定类别(例如“查找时间相关的静态 API”),应重点提供该类别的文件位置和数量,而不是生成涵盖所有类别的完整报告。
- 用户提供特定文件或目录路径时,只扫描该范围;除非用户提出要求,否则不要扩展到整个解决方案。
- 第 4 步的完整结构化报告格式适用于全面审查请求。对于聚焦的问题,只返回相关部分(例如所请求类别的摘要和受影响文件)。
执行约定
- 提示词中提到相对路径即可开始。使用可用的文件列表工具查找路径并立即扫描;在文件发现和内容搜索均失败前,不要要求用户提供或重新上传文件。
- 从符合条件的
.cs文件开始,递归搜索内容并显示行号。不要只搜索static关键字:LINQ 表达式、lambda、回调和插值字符串中的环境调用通常没有static修饰符。 - 如果文件读取工具无法读取已通过列表或搜索确认存在的路径,应先判断失败原因再重试。只有在确认属于工具可用性、传输或路径规范化问题后,才能改用其他可用机制(例如
rg -n、grep 或 shell 文件读取器),并且必须先确认规范路径仍在工作区内。若失败原因是内容排除、权限或策略、工作区边界,或原因不明,则停止。搜索输出可用于建立调用记录;只需打开必要的周边代码来核实接收者来源。 - 加载此技能或宣布扫描计划后,绝不能停止。必须在同一响应中返回已完成的审查结果。如果所有备用方式确实都失败,应报告已核实的部分发现和具体限制;不得捏造发现,也不得用要求用户重新运行来替代审查。
不适用场景
- 用户希望生成封装器(转交给
generate-testability-wrappers) - 用户希望执行机械式迁移(转交给
migrate-static-to-wrapper) - 静态 API 已通过接口或
TimeProvider封装 - 代码不是 C# / .NET
输入
| 输入 | 必填 | 说明 |
|---|---|---|
| 目标路径 | 否 | 要扫描的文件、目录、项目(.csproj)或解决方案(.sln)。默认为当前工作区。 |
| 排除模式 | 否 | 要跳过的 glob 模式(例如 **/obj/**、**/Migrations/**) |
| 类别筛选 | 否 | 限定特定类别:time、filesystem、environment、network、console、process |
工作流
步骤 1:确定扫描范围
将目标解析为一组 .cs 文件:
- 将提示词中指定的工作区相对路径视为目标;应查找该路径,而不是要求用户提供绝对路径。
- 如果未指定,则扫描当前工作区下所有符合条件的
.cs文件;不要擅自只选择一个项目而遗漏其他项目。 - 如果目标是
.cs文件,则只扫描该文件。 - 如果目标是目录,则递归扫描其中所有
.cs文件(排除obj/、bin/)。 - 如果目标是
.csproj,则找到其所在目录并扫描其中的.cs文件。 - 如果目标是
.sln,则解析该文件,找到所有项目目录,并扫描所有项目中的.cs文件。
始终排除 obj/、bin/ 以及用户指定的排除模式。
步骤 2:搜索静态依赖模式
扫描每个文件,查找符合以下类别的调用:
将匹配的模式视为候选项,而非问题。在统计实例调用之前,请追踪其接收方如何进入类。通过构造函数、参数、属性或依赖注入(DI)提供的协作者已经是一个测试接缝。尤其是,注入的 HttpClient 可以通过受控的 HttpMessageHandler 进行测试;不要仅仅因为注入的类型是具体类型就统计其调用或建议替换它。
| 类别 | 要搜索的模式 | 建议的替代方案 |
|---|---|---|
| 时间 | DateTime.Now、DateTime.UtcNow、DateTime.Today、DateTimeOffset.Now、DateTimeOffset.UtcNow、Task.Delay(、new CancellationTokenSource(TimeSpan | TimeProvider(.NET 8+) |
| 文件系统 | File.ReadAllText(、File.WriteAllText(、File.Exists(、File.Delete(、File.Copy(、File.Move(、Directory.Exists(、Directory.CreateDirectory(、Directory.GetFiles(、Directory.Delete(、Path.GetTempPath(,以及会访问磁盘的实例成员(new FileInfo(...)、new DirectoryInfo(...)、.LastWriteTimeUtc、new StreamReader(path)) | IFileSystem(System.IO.Abstractions NuGet) |
| 随机性 / 标识 | new Random(、Random.Shared、Guid.NewGuid( | 类似 TimeProvider 的接缝:注入 Random / 一个 IGuidProvider |
| 区域性 / 序列化 | CultureInfo.CurrentCulture、CultureInfo.CurrentUICulture、JsonSerializer.Serialize(、JsonSerializer.Deserialize( | 显式传递区域性 / 选项,或注入序列化器抽象 |
| 环境 | Environment.GetEnvironmentVariable(、Environment.SetEnvironmentVariable(、Environment.MachineName、Environment.UserName、Environment.CurrentDirectory、Environment.Exit( | 自定义 IEnvironmentProvider |
| 网络 | new HttpClient(、.GetAsync(、.PostAsync(、.SendAsync((确认接收方是 HttpClient;排除接收方为注入对象或由注入工厂创建的调用) | 注入 HttpClient(通常由 IHttpClientFactory 提供) |
| 控制台 | Console.WriteLine(、Console.ReadLine(、Console.Write(、Console.ReadKey( | IConsole 包装器或 ILogger |
| 进程 | Process.Start(、Process.GetCurrentProcess(、Process.GetProcessesByName( | 自定义 IProcessRunner |
对于时间调用,除计数外,还要检查其用法。在一个逻辑操作中读取两次环境时钟,意味着有两个调用点,并会导致一致性缺陷:例如,分别读取 DateTime.UtcNow 来设置 CreatedAt 和 ExpiresAt = DateTime.UtcNow.AddDays(30),两者可能产生偏差。建议捕获一个统一的时间点。使用 TimeProvider 时,尽可能保留 DateTimeOffset;当现有成员要求 UTC DateTime 时,使用 GetUtcNow().UtcDateTime,绝不要使用 .DateTime,因为它会丢失 UTC 类型。将捕获一个时间点视为可选的行为层面后续改进:机械式包装迁移必须逐一保留原有读取,除非用户另行批准这项语义更改。
步骤 3:汇总并排序结果
统计整个扫描范围内的每个调用点,包括以下规则涵盖的实例成员调用点,而不只是 static 调用点。
计数规则——报告与临时扫描结果不一致的主要原因是计数不准确:
- 撰写说明前,先建立一份调用记录表。 为每个纳入统计的调用点恰好建立一行,包含类别、精确模式、
file:line和建议的抽象边界。所有类别、模式和每个文件的计数,都要通过对同一份记录表进行分组得出;撰写表格时,绝不要独立重新计数。 - 将三个计数范围分开。
Files scanned包含所有符合条件的源文件;affected files仅包含记录表中有条目的文件;call sites是记录表的行数。绝不要用其中一个替代另一个。 - 只保留一个权威总计。 你发现的每个调用点都必须计入类别汇总和总计。绝不要把实际发现项放进总计中未涵盖的“其他观察”部分。
- 按成员访问的对象分类,而不是按它是否为
static分类。 访问相同不可测试资源的实例成员也要计数,并归入相应类别(new FileInfo(path).LastWriteTimeUtc→ 文件系统;new HttpClient().GetAsync(...)→ 网络)。如果成员是实例调用,请使用“隐藏依赖”而不是“static”。 - 统计实例调用前,先检查接收方的来源。 只有当被测代码自行获取或构造依赖时,才统计资源访问。构造函数注入、参数注入、属性注入和 DI 注入的协作者(包括具体的
HttpClient实例)不计入“需要包装”的总数。 - 从“需要包装”的总数中排除确定性的纯辅助方法。
Path.Combine、Path.GetExtension、Path.GetFileName和Math.*/string.*静态方法不会读取任何环境输入,因此很容易测试。如果要列出它们,请放在单独的“无需处理”备注中——绝不要将其作为可测试性障碍。 - 报告前涵盖所有类别——时间、文件系统、环境、网络、控制台、进程、随机数(
new Random()、Guid.NewGuid())、区域性(CultureInfo.CurrentCulture)以及序列化/静态方法,例如JsonSerializer。如果某个存在的类别被遗漏,就会导致计数偏低。 - 为每个调用点提供
file:line,方便用户直接跳转到相应位置。 - 发布前核对。 类别总数、模式排名表和每个文件的表格,合计必须与总数一致。
- 将排除项视为范围决定,而不是一个类别。 建立记录表前,先移除
obj/、bin/、生成文件和用户指定排除的文件。任何报告计数都不得包含这些文件或调用点。集中说明一次排除项,不要将排除的候选项混入计算。 - 注明截断的排名。 在全面审计中,如需核对总数,应列出所有不同模式。如果用户只要求 Top-N 子集,请将其标注为子集,不要暗示表中各行之和等于总数。
生成一份包含以下内容的摘要:
- 类别汇总——每个类别的调用点总数(时间、文件系统、环境等)
- 排名靠前的模式——按计数排序的 10 个最常见单独模式
- 受影响最多的文件——静态依赖数量最多的文件
- 现有可用抽象——针对每个类别,注明建议使用的 .NET 抽象:
- 时间 →
TimeProvider(.NET 8 起内置) - 文件系统 →
System.IO.Abstractions(NuGet 包) - HTTP →
IHttpClientFactory(内置) - 环境 → 自定义
IEnvironmentProvider - 控制台 → 自定义
IConsole或ILogger - 进程 → 自定义
IProcessRunner
- 时间 →
步骤 4:呈现报告
将输出格式化为结构化报告:
## Static Dependency Report
**Scope**: <project/solution name>
**Files scanned**: <count>
**Total static call sites**: <count>
### Category Summary
| Category | Call Sites | Recommended Abstraction |
|-------------|-----------|------------------------|
| Time | 42 | TimeProvider (.NET 8+) |
| File System | 31 | System.IO.Abstractions |
| Environment | 12 | IEnvironmentProvider |
| ... | ... | ... |
### Top 10 Patterns
| # | Pattern | Count | Files |
|---|---------------------|-------|-------|
| 1 | DateTime.UtcNow | 28 | 14 |
| 2 | File.ReadAllText | 18 | 9 |
| ... |
### Most Affected Files
| File | Static Calls | Categories |
|-------------------------------|-------------|---------------------|
| Services/OrderProcessor.cs | 12 | Time, FileSystem |
| ... |
### Migration Priority
1. **Time** (42 sites) — Use `TimeProvider`, zero NuGet dependencies on .NET 8+
2. **File System** (31 sites) — Use `System.IO.Abstractions` NuGet package
3. ...
第 5 步:建议后续步骤
根据报告,建议优先处理数量最多且内置支持最好的类别。简要说明即可。
只有在用户的下一步明确需要时,才提及 generate-testability-wrappers 或 migrate-static-to-wrapper——将其作为交接说明,而非推销。审计结尾不要附上会削弱调查结果的宣传性后续建议。
验证
- 已扫描范围内的所有
.cs文件(核对数量) - 报告包含各类别总数、主要模式和受影响文件
- 各类别总数、主要模式和每个文件的计数,与同一总数一致
- 分别报告已扫描文件、受影响文件和调用点的数量
- 所有汇总数据均基于同一份出现记录清单得出,而非分别重新计数
- 每个出现项都带有
file:line位置 - 没有在“其他”部分列出未计入总数的发现
- 调用注入的协作者时,不计入“需要封装”的总数
- 不将确定性的纯辅助函数(
Path.Combine、Math.*)计为可测试性障碍 - 为每种检测到的模式列出了建议的替代方案
- 已排除
obj/和bin/目录 - 迁移优先级按影响程度(数量 × 替换难度)排序
常见问题
| 问题 | 解决方案 |
|---|---|
扫描 obj/ 或生成的代码 | 始终排除 obj/、bin/ 和 *.Designer.cs |
| 将对注入协作者的调用计入 | 追踪接收者:注入的 HttpClient、TimeProvider、接口或其他由调用方提供的依赖已经具备接缝,无需替换 |
| 漏掉 lambda/LINQ 中的静态调用 | 搜索会覆盖 .cs 文件中的所有代码,包括 lambda |
在 < .NET 8 的项目中建议使用 TimeProvider | 检查 .csproj 中的 TargetFramework——如果低于 net8.0,则建议使用 NodaTime.IClock 或自定义的 ISystemClock |
| 忽略测试项目 | 只扫描生产代码——从扫描中排除 *.Tests.csproj 项目 |
| 将发现项放到其他位置而导致计数不足 | 实际调用点应计入类别总数,而不是放在被总数忽略的末尾“另有发现”段落中 |
| 将实例成员误称为静态成员 | new FileInfo(p).LastWriteTimeUtc 是实例调用,但仍依赖隐藏的文件系统——将其计入“文件系统”,并准确描述 |
建议封装 Path.Combine | 纯粹且确定性的辅助函数无需接缝;将其列为障碍会导致建议有误 |