关于 FSharp.Refactor
FSharp.Refactor 为 F# 开发者提供离线重构提示和修复,也可处理 Agent 生成的代码。分析器提供机会提示而不阻止构建,.NET CLI 可预览或批量应用修复。
主要功能
- 01离线 F# 重构分析器
- 02编辑器提示和快速修复
- 03CLI 批量应用修复
- 04试运行和 API 变更选项
- 05用于 CI 的 SARIF 报告
适用场景
- 01检查 Agent 生成的 F# 代码
- 02在项目中应用选定修复
- 03在 CI 中报告分析结果
MCP 相关
面向 Agent 场景的 .NET 命令行工具支持 MCP;输入未说明其 MCP 工具。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
我们还没有获取到这个服务的安装包或远程地址,请按项目 README 中的说明安装。
工具
本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。
详细介绍
FSharp.Refactor
让我们从互联网中删除括号
为 F# 提供函数式重构建议。 该工具会明确(由 Agent 生成或旧版)代码的意图,减少非字母数字字符造成的冗余,并更高效地使用内置 .NET 库。
- 在编辑器中使用灯泡快速修复
- 使用命令行工具批量应用修复。
建议的严重级别为 Hint:它们提示改进机会,而非指出缺陷,
也绝不会阻止构建。
本项目是 CSharp.Refactor 的大哥
使用方法
该工具完全离线:开发时用到了 AI,但运行时不使用 AI。
快速开始
无需配置——该工具会读取你的项目,报告将要进行的更改,并且只有在你指示时才会编辑:
dotnet tool install --global fsharp-refactor
fsharp-refactor Your.fsproj --dry-run --api-changes
这会列出所有将要进行的修复,以及对应的文件和位置,不会写入任何内容。确认列表无误后,移除该标志以应用修复:
fsharp-refactor Your.fsproj --api-changes
如果项目当前无法编译,该工具会拒绝运行;如果应用修复引入了错误,也会明确报错。
如果你不想更改公共方法 / API,请移除 --api-changes:
fsharp-refactor Your.fsproj
如需在输入代码时使用灯泡提示,请参阅下文 VS Code / Ionide 和 Visual Studio IDE 插件说明。
对于 Agent 场景,dotnet 工具支持 MCP。
使用工具: 将它指向任意目标——工具会根据路径判断目标类型:
Your.fsproj | 一个项目 |
Thing.fs | 一个源文件——工具会找到并分析它所属的项目,但只编辑该文件 |
build.fsx | 一个脚本——完全不会执行 MSBuild 步骤,因此会立即启动。对于无法解析引用的脚本,工具不会拒绝处理:语法规则仍会应用于脚本(由 fsi 运行的脚本可能引用只有运行时才会提供的内容) |
Your.sln、Your.slnx | 解决方案列出的所有 F# 项目 |
src/ | 该目录下的解决方案,或其中的项目 |
"src/**/*.fsproj" | glob 匹配的所有内容 |
它会更改什么
示例:
| 更改前 | 更改后 | |
|---|---|---|
| 正确性 | let s = new FileStream(p, m) | use s = new FileStream(p, m) |
| 正确性 | raise (Exception "boom") | failwith "boom" |
| 性能 | s.Contains "x" | s.Contains 'x' |
| 性能 | xs |> Seq.toList |> List.map f | xs |> Seq.map f |> Seq.toList |
| 惯用写法 | match b with | true -> 1 | false -> 0 | if b then 1 else 0 |
| 冗余 | new StringBuilder() | StringBuilder() |
| 冗余 | [<SerializableAttribute>] | [<Serializable>] |
| 诊断信息 | failwith "Error" | failwith $"Error, calling f with x: {x}" |
以下是约 150 条规则的部分示例——完整列表见 重构项:
编辑器和 CI 设置
分析器以 FSharp.Refactor.Analyzers 的形式发布。
该包是开发依赖项:它只会提供提示和快速修复,不会有任何内容进入编译输出。
注意:即使添加了 NUGET 引用,该分析器也不会进入输出路径(BIN),也不会成为项目的一部分。
VS Code / Ionide
最简单的方法:安装 FSharp.Refactor VS Code 扩展
- 它会打包分析器,并在你同意后将其全局接入 Ionide 的设置,因此每个 F# 项目无需单独配置即可获得提示。
或者手动为单个项目接入:在你想要分析的项目中引用该包:
<PackageReference Include="FSharp.Refactor.Analyzers" Version="*" PrivateAssets="all" />
然后在 .vscode/settings.json 中将 Ionide 指向还原后的分析器:
{
"FSharp.enableAnalyzers": true,
"FSharp.analyzersPath": [
"~/.nuget/packages/fsharp.refactor.analyzers/<version>/analyzers/dotnet/fs"
]
}
将 <version> 替换为还原时实际选用的版本(NuGet 缓存始终按版本为文件夹命名,因此此路径不能使用浮动版本;通过 ls ~/.nuget/packages/fsharp.refactor.analyzers/ 检查)。在 Windows 上,缓存位于 %USERPROFILE%\.nuget\packages 下。建议会以 Hint 严重性诊断的形式显示,并提供一键灯泡修复。
FsAutoComplete 0.85+ 会根据每项修复写入的内容为其命名(Fix FR0136 → Guid.NewGuid());旧版本只会显示 Fix FR0136。Ionide 自带 FsAutoComplete;如需运行较新版本,请执行 dotnet tool install -g fsautocomplete,并将 FSharp.fsac.netCoreDllPath 设置为其
fsautocomplete.dll(上述扩展可提供这两项操作)。
该包并排附带基于两个 FSharp.Analyzers.SDK 版本构建的分析器:FSharp.Refactor.Analyzers.Ionide.dll 对应 SDK 0.35.0(标准 Ionide 的 FsAutoComplete 所打包的版本,适用于 7.31.x 及更早版本),而 FSharp.Refactor.Analyzers.dll 对应 SDK 0.37.2(适用于 CLI 和 FsAutoComplete 0.84+)。SDK 只会加载与自身版本匹配的程序集,并会为另一个版本记录跳过日志,因此每个宿主都会选用自己能用的版本,无需进行配置选择。
如果没有显示建议,请打开 Output -> “F# Language Service”:其中会列出已扫描的分析器 dll 及加载的分析器数量;版本配对不匹配时的跳过信息也会在此明确显示,而不会出现在编辑器中。
Visual Studio 2022-2026
安装
FSharp.Refactor Visual Studio 扩展
(面向用户的概览):
在完整的 Visual Studio 中显示波浪线和 Ctrl+. 快速修复,并通过 FsAutoComplete 辅助进程进行分析。一个前置条件:
dotnet tool install -g fsautocomplete。
CLI / CI
dotnet tool install --global fsharp-analyzers
fsharp-analyzers --project src/YourProject.fsproj --analyzers-path ~/.nuget/packages/fsharp.refactor.analyzers/<version>/analyzers/dotnet/fs --code-root . --report analysis.sarif
fsharp-analyzers 是分析器宿主,也是你需要安装的 dotnet 工具。此包本身不是工具,而是供宿主加载的分析器程序集库,因此传入的是目录,而不是安装该包。该目录位于 NuGet 缓存中,因为分析器包有意不包含 lib/ 文件夹,并且被标记为开发依赖项——因此 PackageReference 不会将任何内容放入 bin,而还原后,程序集会存放在缓存中。--analyzers-path 接受任何包含这些程序集的文件夹,并会递归搜索。
CLI 只会报告结果,不会编辑文件;这正适合用于 CI(SARIF 输出可用于 GitHub 代码扫描)。若要应用修复,请使用下方的自有工具。可通过 fsharprefactor.json 在每个代码库中单独禁用规则——请参阅下方的配置。
从命令行应用修复
fsharp-refactor
dotnet 工具会直接将快速修复应用到文件中:
dotnet tool install --global fsharp-refactor
fsharp-refactor Your.fsproj [--dry-run] [--codes FR0002,FR0031] [--api-changes] [--jobs 4] [--max-passes 5]
(或在此代码库中运行:
dotnet run --project src/FSharp.Refactor.Tool -c Release -- Your.fsproj ...)
对于项目,它会从 MSBuild 获取准确的编译器参数;对于脚本,FCS 会自行解析引用,不会运行 MSBuild。无论哪种情况,工具都会运行所有分析器,从下往上应用不重叠的修复,并重新分析,直到某次遍历不再应用任何修复为止——一项修复可能会启用更多修复。如果编译已经存在错误,它会拒绝继续;若应用修复引入了错误,则会明确报错。
将警告视为错误:由项目自身的设置决定,工具绝不会覆盖这些设置——它的验证过程就是 CI 运行的构建。内存中的类型检查会采用编译器自身的参数,因此 --warnaserror 和 <WarningsAsErrors> 会将修复引发的 F# 警告变成错误,导致该检查回滚。引用了该 F# 项目的 C# 项目若将分析器警告设为错误(error CA1859、error IDE0005),验证构建会因代码问题而失败,而非工具噪声;运行结果还会说明,如果你仍想应用此类修复,应更改哪个设置(<WarningsNotAsErrors>CA1859</WarningsNotAsErrors> 或 dotnet_diagnostic.CA1859.severity = suggestion)。<NoWarn> 也有效,但会让所有人都看不到该分析器的警告。
| 标志 | |
|---|---|
--dry-run | 仅报告:列出将进行的每项修复及其文件和位置,不写入任何内容。不会默认重写——去掉此标志即可编辑。 |
--codes FR0002,FR0031 | 将运行范围限制为所选规则;其他分析器不会运行。 |
--categories <list> | 将运行范围限制为规则类别:correctness、performance、idiom、cosmetic。与 --codes 组合时,无论顺序如何,都会进一步缩小范围。被排除规则的分析器完全不会运行。筛选后的运行可能会停在完整运行会继续处理的位置:FR0157(idiom)会留下一个 ` |
--jobs <n> | 同时对这么多个文件进行类型检查(默认值为 4,并根据核心数限制在 2-4 之间)。以 CPU 换取运行时间;由于 FCS 会在每次增量构建中重用每个文件的前缀,收益在约 4 个任务时达到峰值,继续增加反而会降低性能。--jobs 1 表示顺序扫描。 |
--framework <tfm> | 使用此目标框架进行分析,而非最窄的框架——见下文。 |
--max-passes <n> | 修复后重新分析的迭代次数(默认值为 5)。第一轮之后的每一轮仅扫描上一轮修改过的文件,以及在这些文件中第一个文件之后编译的文件。 |
--help | 由工具本身打印相同的列表(-h 和 /? 也可用)。 |
--api-changes | 同时应用下文所述的跨文件修复。 |
--no-if-defs | 对多目标项目中的功能修复,绝不生成 #if/#else/#endif 组合(见下文)。修复会保持原样,最终构建检查会还原任何不被旧版框架接受的修复。 |
--report <file> | 将运行中发现的所有问题写入文件;文件扩展名决定格式。.sarif(或 .json)为 SARIF 2.1.0 格式,可供 GitHub 代码扫描渲染为 PR 内联注释;.html 是一个独立页面,包含按规则分组、高亮源码、修复前后对比和类别筛选;.csv 则为每个问题生成一行,便于在电子表格中查看。与 --dry-run 搭配,可自然用于 CI lint 门禁。见下文的 CI 设置。多目标运行会在每个目标完成后重写报告,因此即使运行一小时后发生崩溃或按下 Ctrl-C,也仍会保留已发现的内容。若将工具指向由多个检出目录组成的工作区——即自身没有解决方案、但其子目录各自带有解决方案的目录,例如 C:git——工具会分别分析每个检出目录(遵循各自的解决方案并应用各自的 fsharprefactor.json),并写入同一份报告,路径相对于工作区。 |
--baseline <sarif> | 设定基线:指纹出现在此先前 --report 输出中的问题既不会报告,也不会修复——只显示新增问题。指纹由规则代码、文件名和规范化的周边源码计算得出,因此不受行号变化、文件中的其他编辑或不同检出目录影响。只需分诊一次,即可永久设定基线。 |
--fail-on-findings | 只要筛选后仍有任何问题,就以代码 3 退出——严格的 CI 门禁。完整的退出代码约定:0 表示无问题,1 表示分析或应用失败,2 表示用法错误,3 表示有问题(仅在使用此标志时)。 |
| `--notes [on | off |
--notes only | 审查模式:运行所有规则,仅内联列出没有修复方案的问题,不写入任何内容。这包括 33 条建议规则(其 Rules.md 的修复列中标有 em dash)以及修复规则只能提供建议的情况。运行 fsharp-refactor src/Your.fsproj --notes only --report notes.html 可将它们写入页面。 |
--format json | 面向机器的标准输出:进度说明会转到 stderr,运行发现的问题则会以一个 JSON 文档输出(代码、严重性、是否可修复、位置、消息、指纹、源码片段)。默认输出仍为人类可读格式。 |
--rules | 打印规则目录——规则代码、类别、默认是否启用(遵循 --format json)。 |
--create-config | 将此构建的默认设置写入当前目录下的 fsharprefactor.json——包含每条规则和每个运行级别的键,每项附一条注释;若 <what> 是目录,则写入该目录。除非你编辑配置,否则不会产生任何更改;也绝不会覆盖现有配置。 |
--mcp | 通过 stdio 将工具作为 MCP 服务提供(以换行符分隔的 JSON-RPC,无额外依赖):包含 analyze 工具(target、codes/categories、parseOnly、apply)和 list_rules 工具。多次调用之间会复用一个已预热的类型检查器,因此第一次 analyze 会解析引用,后续调用则从热缓存中响应——这正是 Agent 循环所需的运行效率。 |
--parse-only | 用于无法在此机器上编译的代码库——例如需要其数据库的类型提供程序,或无法还原的引用。不会运行 MSBuild,也不会解析引用:源文件直接来自 fsproj 的 <Compile> 项目,并且只运行 119 个分析器中从不查询类型检查器的 58 个(有类型信息的规则会直接排除,而不是假定它们会自行静默)。这不能替代真正的运行,而且留下的问题分布会朝错误的方向倾斜:根据语料库测量结果,约四分之一的正确性规则和四分之一的性能规则仍会触发,而外观规则中有四分之三会触发——因此,--parse-only 无问题的结果说明不了多少,对值得了解的内容更是如此。在类型信息依赖较多的代码库中,遗漏的问题可达 38%;在以外观规则为主的代码库中则低于 10%。因此,安全保证也会相应变化:门禁不再是成功构建,而是确保本轮运行不会使编译错误数高于基线;常见的语法级保护(注释保护、重叠修复保留)仍会生效。限制:不会解析位于条件式或计算所得 DefineConstants 分支后的 #if,拒绝通配符 <Compile> glob,并且多框架扫描会合并为一次。请审查差异——此模式恰好缺少了负责仲裁所有框架的构建。 |
CI 设置(SARIF)
试运行加上 --report,即可让 CI 获取完整的发现列表,而不触碰任何文件;上传 SARIF 后,每项发现都会成为拉取请求中的内联注释。报告中的路径相对于包含目标的仓库(即目标上方最近的 .git),因此工具可以从任意位置运行;每项结果都包含规则说明和帮助链接、带有三行上下文的发现文本、作为 SARIF fixes 条目的修复方案(代码扫描会将其呈现为建议的更改)、稳定指纹,以及本次运行的调用记录:
refactor-lint:
runs-on: ubuntu-latest
permissions:
security-events: write # required by upload-sarif
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
- run: dotnet tool install --global fsharp-refactor
- run: fsharp-refactor src/Your.fsproj --dry-run --report findings.sarif
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: findings.sarif
category: fsharp-refactor
有两点需要如实说明。首先,无论是否发现问题,试运行都会以 0 退出——发现只是提示,并非错误;只有构建失败或运行崩溃才会使此步骤失败。因此,注释只提供信息,不会阻止合并;若要让发现导致作业硬失败,请添加显式检查:
jq -e '.runs[0].results | length == 0' findings.sarif
其次,启用门禁前应先限定范围:--categories correctness,performance 可让共享仓库中的检查结果保持合理可信(参见他人的代码库),而 fsharprefactor.json 则可关闭团队已决定不采用的检查项。配置中介绍的抑制注释可在同一处按行隐藏单项发现,同时适用于门禁和编辑器。
与其他分析器包一同运行
fsharp-analyzers 宿主会加载所有指定的分析器程序集,因此一次调用——只需进行一次 FCS 类型检查(这部分开销最大)——即可将此包与其他基于相同 SDK 构建的分析器一同运行,并将所有发现汇总到一份报告中。以 G-Research 的分析器为例,其当前版本固定使用与此包相同的 FSharp.Analyzers.SDK(0.37.2):
fsharp-analyzers --project src/Your.fsproj \
--analyzers-path ~/.nuget/packages/fsharp.refactor.analyzers/<version>/analyzers/dotnet/fs \
~/.nuget/packages/g-research.fsharp.analyzers/<version>/analyzers/dotnet/fs \
--code-root . --report findings.sarif
规则代码互不重叠(此处为 FR*,彼处为 GRA*),fsharpanalyzer: ignore-line 抑制注释也适用于它们的代码——相关机制位于共享 SDK 中,而非任何单个包中。
SDK 严格要求宿主与其加载的每个分析器程序集版本一致;若版本不匹配,分析器会静默加载失败,而不会明确报错。若两个包固定使用的 SDK 次版本不同,请改用独立的 CI 作业——每个作业分别生成自己的 SARIF,并通过各自的 category: 上传(代码扫描会将不同流分开)——代价是每个作业都要对项目进行一次类型检查。无论采用哪种方式,应用修复仍由此包自己的工具负责:fsharp-refactor 只应用自己的规则,而仅生成报告的分析器不会产生冲突。
别人的代码库
每条规则都属于以下四种类型之一,见 重构中的最后一列:
| 类型 | 数量 | |
|---|---|---|
correctness | 代码的实际行为与表面看起来不一样:竞争条件、被吞掉的异常、泄漏的可释放对象、永远不成立的比较 | 67 |
performance | 代码正确,但做了不必要的工作:不必要的内存分配、重复工作、本可查找却进行了扫描 | 41 |
idiom | 用 F# 的惯用方式实现相同行为。值得改进,也值得先达成共识——这和其他方面一样,关乎团队风格 | 56 |
cosmetic | 代码的标点和拼写。确实是清理工作,但没人会认为陌生人提交的拉取请求里包含这类改动是受欢迎的 | 17 |
每个版本的说明都在 CHANGELOG.md 中;NuGet 包附带最近六个版本的说明。
有十三条规则在类别之外还带有 优先级 标记——这些是可能造成缺陷和安全漏洞、代价高到不宜延后的问题:FR0020、
FR0028、FR0032、FR0046、FR0047、FR0048、FR0061、FR0063、FR0065、FR0066、
FR0122、FR0126 和 FR0127。被吞掉的异常(FR0055)和公共可变对象(FR0062)
仍然只是普通的正确性问题:更多是坏习惯,而非实际缺陷。它们的说明会在不使用
--notes 时输出,编辑器会将其显示为警告,SARIF 也会将其标记为警告级别。
Rules.md 会在“优先级”列中标记这些规则。
当代码库不属于你时,这一点很重要。对一个你不维护的项目运行所有规则,再根据结果提交拉取请求,是浪费别人半天时间的好办法:没有维护者想看到“删除了空属性参数列表”这种改动散落在两百个文件中,而这么大的差异会掩盖真正重要的内容。一个从未释放的可释放对象,则完全是另一回事。
因此,对于你只是临时参与的代码库:
fsharp-refactor Their.fsproj --categories correctness,performance --dry-run
这些规则才值得花时间审查。对于你自己的代码,就全部运行一遍。
类别声明有数据验证,并非凭空假设:
dotnet run -c Release --project benchmarks/PerfClaims
会在你的机器上重新检查这些声明,同时衡量两个维度——实际耗时和内存分配(GC 压力也属于性能问题)。约定是:性能规则的改写至少要在一个维度上有所提升,而惯用方式规则的改写则必须保持性能持平。FR0050 曾为列表生成 Seq.sum——比它取代的可变循环慢约 50%%,还会分配一个枚举器;这正是基准测试文件、由规则模块解析的输出,以及将其重新归类为惯用方式规则的原因。
多目标项目
无需额外操作:多目标项目会按框架逐个处理,先处理目标范围最窄的框架。
功能修复能兼顾两种情况——使用项目自身的术语。当项目还面向比某个重载更旧的框架(net4x、netstandard2.0)时,工具会读取 fsproj 中的 DefineConstants,并查找形如框架名称的常量——例如 NETSTANDARD21、NET8,且必须包含数字——其 '$(TargetFramework)' == '...' 条件仅覆盖现代框架。表示旧版框架的名称(NET48、NET451、NETSTANDARD2_0)无论条件如何,都会被直接拒绝:SDK 会在旧版编译期间定义这些常量,而 fsproj 解析无法识别这些编译过程。与框架常量共用同一条件的风味名称(SQLProvider 在 NETSTANDARD21 旁定义了 MICROSOFTSQL)会被跳过:它们表示的是风味,而且编译同一共享文件的同级项目也可能在旧版框架上定义它们。如果某个常量符合条件,且文件已使用条件编译,FR0038 和 FR0106 会生成一对修复,而不是生成旧版部分无法编译的修复:
#if NETSTANDARD21
let orderNumber (s: string) = Int32.Parse(s.AsSpan(6, 5))
#else
let orderNumber (s: string) = Int32.Parse(s.Substring(6, 5))
#endif
绝不臆造常量:项目未定义此类常量时,就采用普通修复;最终的全框架构建仍是判定依据(如果旧版部分不接受某项修复,就会撤销该修复)。如果工具无法完整读取 DefineConstants 元素上的条件(也就是除 '$(TargetFramework)' == 'X' 与 Or 链式组合之外的任何条件),则不会猜测,而是排除相应常量。如果某行已位于所选常量的正向区域中,或位于手写的 NET*_OR_GREATER 区域中,就采用普通修复(旧版框架不会编译该行);完全没有 #if 的文件不会添加这些条件,编辑器也始终建议使用普通形式。--no-if-defs 会在整个运行期间彻底关闭成对修复,无论项目定义了什么常量,都不会添加条件编译。
大型解决方案可通过三种方式控制成本:整个运行共用一个 FCS 检查器(二十个项目几乎共用全部引用程序集,且这些程序集只解析一次);按一组条件编译定义扫描过的共享源文件,不会被下一个以相同方式编译它的项目再次扫描;如果多目标项目的源文件完全不含 #if,就只扫描一个框架,但仍会通过最终的全框架构建验证其余部分。
这并非无谓的工作。根据目标框架可解析内容来启用的规则,因框架而异:s.Contains 'x' 会在存在字符重载的 net8.0 下提供,但在缺少该重载的 netstandard2.0 目标下无法编译。每个框架还会激活各自的 #if 分支,因此其他框架分支中的代码根本不会出现在语法树中。一次扫描注定只能看到部分代码。
先处理目标范围最窄的框架,意味着先应用在所有框架上都有效的修复,再应用仅适用于更广泛目标范围的修复;每轮处理结束后都会构建所有框架,因此无法通用的修复会明确失败,而不会被误认为成功。
因此,直接运行一次 fsharp-refactor Your.fsproj 就会生成:
let has (s: string) =
#if NETSTANDARD2_0
s.Contains "x" // still a string: no char overload here
#else
s.Contains 'x' // rewritten under the net8.0 pass
#endif
如果需要,可以使用 --framework <tfm> 将运行限制在一个框架上。
条件编译符号(--define)
除非定义了 SOME_SYMBOL,否则 #if SOME_SYMBOL 下的代码不会出现在语法树中,因此运行时无法分析或修复它,也无法确保它继续通过编译。按 dotnet fsi --define: 的方式传递符号:
fsharp-refactor Your.sln --api-changes --define LOCAL_BUILD
--define 可以重复指定,并接受以 ; 分隔的列表(--define:A 和 -d:A 也可用);在 fsharprefactor.json 中设置 "defines": ["LOCAL_BUILD"],则会将这些符号添加到每次运行中。运行所执行的每次构建以及所进行类型检查的每个脚本都会定义这些符号,并且运行开始时会对此说明一次。
项目保留自己的符号:工具通过 DefineConstants 环境变量将额外符号传递给 MSBuild,SDK 和项目的 <DefineConstants>$(DefineConstants);...</DefineConstants> 会将其添加进去。(-p:DefineConstants=... 则会替换它们:DEBUG、TRACE 和项目自身的常量都会消失,运行构建的代码也会与 dotnet build 构建的不同。)
常见情况是,一组示例脚本同时为两种场景编写:
#if LOCAL_BUILD
#r "../../src/MyLib/bin/Debug/net10.0/MyLib.dll"
#else
#r "nuget: MyLib"
#endif
如果没有该符号,这类脚本就是已发布包的使用者,因此无法根据仓库源码检查其中的调用,而它可能使用的声明也会保持原样。这种情况发生时,运行会指出该符号:
(Example.fsx #r's this project's built assembly and could not be checked against its sources, ...)
its #r of MyLib.dll did not resolve to a reference this pass can redirect; it sits under
#if LOCAL_BUILD, which this run does not define - pass --define LOCAL_BUILD ...
允许更改公共 API,例如类型
默认情况下不会更改公共类型和函数签名。有时,这类更改可以提高程序效率:
将 type Item = { X: Option System.Guid } 改为 type Item = { X: VOption System.Guid } 往往是合理的,因为 Guid 本身就是结构体,因此在这里使用 ValueOption 更好。但这可能会影响外部用户和序列化。
--api-changes 会启用对内部或公共签名进行更改的重写——将元组参数函数改为柯里化函数(FR0090),以及按 data-last 方式重新排序参数(FR0091)——并重写项目中的每个调用点、#load 该项目或 #r 其已构建程序集的脚本,以及同一解决方案中引用该项目的兄弟项目(通常是测试项目)或直接编译其某个源文件的项目(链接文件;此类修复行会标注 note: linked file)。未启用此选项时,这些更改会被暂缓,只计入数量。该选项还会将包含类型提示(FR0022、FR0069、FR0070、FR0093)扩展到公共类型。由于运行之外的使用者的调用点无法重写,因此必须显式启用此选项:只有当运行中引用公共函数的每个项目都是通过类型检查的 F# 项目时,才会更改公共函数的形式;引用它的 C# 项目、存在错误的兄弟项目、无法针对源码通过类型检查的 #r 脚本,或上级目录中没有解决方案的独立项目,都会使公共接口保持原样,运行也会对此说明。并且,每条规则只有在它仍无法看到的调用点会因此编译失败,而非悄然改变行为时,才会触发。指定单个源文件会完全跳过这些更改——指定一个文件却在其调用者中产生编辑,会让人意外。
这个标志包含两项彼此独立的功能:修改其他文件的修复,以及将原地形状更改扩展到公共声明。只有第一项需要显式启用:没有任何项目链接的程序集——可执行文件或脚本——会自行获得第二项;库则可在 fsharprefactor.json 中设置 "publicApi": false 来启用。无论哪种方式,这些更改都会传递给编辑器,而编辑器中从未使用过该标志。
请参阅公共接口就是 API 吗?。
重构
每条规则都列在 Rules.md 中:一行表格便于浏览,说明每条规则的触发条件、提供的修复、默认是否启用,以及其修复是否需要 --api-changes;之后每条规则都有一个章节,详细说明原因。测试会确保该文件与规则目录保持同步。
路线图基于 “F# refactoring possibilities”。
配置
可以通过可选的 fsharprefactor.json 按仓库禁用规则。程序会从每个待分析文件所在目录向上查找,直到仓库根目录(离文件最近的配置文件优先)。键可以是规则代码或分析器名称,且不区分大小写;配置文件格式错误时会默认放行,因此绝不会导致编辑器出错。注释和尾随逗号均可使用。
fsharp-refactor --create-config 会为你生成一个配置文件:包含当前构建已知的每条规则及其当前默认值、每个运行级别的键及其默认值,并为每项添加一条注释。在你编辑某一行之前,文件中的内容不会产生任何影响——只需切换你不同意的选项,删除其余内容,即可在默认值变化时继续采用新默认值。若配置文件已存在,该命令会拒绝覆盖。
格式化工具的设置也会影响规则执行,以免规则在每次运行时撤销这些设置:在 .editorconfig 中设置 fsharp_space_before_lowercase_invocation = false(让 Fantomas 写成 f(x),而不是其默认格式 f (x)),除非此文件或 --codes 按名称启用 FR0013,否则 FR0013 会保持静默。
Fable 项目(即引用了 Fable.Core 的项目)会有几处不同的处理。其公共值会导出到 JavaScript、Python、Dart 或 Rust,因此 FR0130 不会更改这些值。FR0038 会跳过文化相关的提示,因为所有 Fable 目标都按序比较字符串。绑定到 JavaScript 的项目(引用了 Fable.Browser.*、Fable.Node、Fable.Deno 或 Fable.Promise)是单线程的,因此 FR0162 在此类项目中会保持静默;绑定到浏览器的项目使用用户自己的时钟,因此 FR0121 不会更改 DateTime.Today 和 DateTime.Now。表示形式相关的规则仍会启用:Rust 目标会按值存储 [<Struct>] 记录。
{
"rules": {
"FR0003": false,
"conversionMove": { "enabled": false }
}
}
可调整阈值的规则会从同一对象类型的配置项中读取数值属性。FR0114 接受 thenAtLeast(默认值为 20),表示建议翻转之前 then 分支至少应有多少行;还接受 elseAtMost(默认值为 3),表示 else 分支最多应有多少行:
{
"rules": {
"FR0114": { "enabled": true, "thenAtLeast": 30, "elseAtMost": 2 }
}
}
FR0060 接受 maxAttributes(默认值为 4),表示一个 [<A; B>] 方括号中最多可以包含多少个特性;还接受 wrapColumn(默认值为 110),表示合并后的行最多可以有多宽。这两项都属于代码风格,而非正确性要求;超过任一限制时,该规则就不会合并:
{
"rules": {
"FR0060": { "enabled": true, "maxAttributes": 6, "wrapColumn": 120 }
}
}
有些配置项是开关而非阈值,读取 JSON 布尔值时也同样接受 1。FR0029 接受 tailLines(默认值为 40),表示对于编译器未发出警告的任务,最后一个 await 之后有多少行非 await 代码才会触发尾部提取——若 FS3511 指明了该任务,apply 工具会从构建结果中读取这一信息,并始终提供提取建议——还接受 hoistReturnOnAsync(默认值为 false),它只会将返回值提升扩展到 async { },不会扩展 FS3511 的建议:
{
"rules": {
"FR0029": { "tailLines": 25, "hoistReturnOnAsync": true }
}
}
FR0174 接受 pipelines(默认值为 false)。该规则会将 C# 风格 LINQ 链中 IQueryable 上的副本移出链式调用(q.ToList().Where(f) → q.Where(f).ToList());像 q |> Seq.toList |> List.filter f 这样的管道会像 query { } 一样清楚地显示行数据何时载入内存,因此除非启用此选项,否则不会处理:
{
"rules": {
"FR0174": { "pipelines": true }
}
}
FR0065 接受 dropLegacyProtocols(默认值为 false)。停用 Ssl3/Tls/Tls11 会改变进程与远程端点协商的内容,因此默认只在编辑器中提供建议;启用此选项后,无人值守运行可以将已弃用的协议从标志中注释掉。--api-changes 刻意不启用此规则——该标志针对的是需要重新编译的调用方,这是另一种风险:
{
"rules": {
"FR0065": { "dropLegacyProtocols": true }
}
}
FR0015 接受 perCall(默认值为 true)。循环内的 Regex 调用会为每个元素重新解析一次模式,因此始终会被提升;普通函数体内的调用每次执行都会查找运行时正则表达式缓存,而只有在缓存(十五种模式)轮换时才会重新解析。默认情况下,这类调用同样会被提升——函数被多次调用的可能性远高于模块被初始化后其绑定从未被读取的可能性;而且 F# 会在模块首次访问时构建其绑定,因此不会构建从未用到的已提升 Regex。将其设为 false,即可只保留循环中的提升:
{
"rules": {
"FR0015": { "perCall": false }
}
}
FR0035 接受 minElements(默认值为 8),它表示经过探测的模块级列表或数组字面量中,至少需要多少个显式写出的元素,使用 HashSet 才划算;少于此数量时,线性探测更快,即使每次探测都未命中也是如此。它还接受 setMinElements(默认值为 16),表示字面量至少需要多少个元素,才会原地转换为 F# Set,一种需要更多元素才划算的比较树;更短的列表则会改用 HashSet:
{
"rules": {
"FR0035": { "minElements": 8, "setMinElements": 24 }
}
}
也可以排除路径——在内置默认项(paket-files、.paket、node_modules)的基础上添加排除项。这些默认项覆盖了编译仍会包含的生成代码和第三方代码:
{
"ignorePaths": [ "generated", "external/imported" ]
}
仅有名称的条目会匹配完整路径段;包含斜杠的条目会在规范化路径中的任意位置匹配;包含 * 的条目是 glob——* 不会跨越路径段,** 则会(*.g.fs、src/generated/**)。被忽略的文件既不会分析,也不会由 apply 工具的扫描进行类型检查——在大量使用 paket 的解决方案中,会有许多没人希望被“修复”的第三方源代码。以惯用的 // <auto-generated> 标记开头的文件会自动跳过,无论它们位于何处;obj/ 下的所有内容也会跳过。
可以使用 F# analyzer SDK 自带的抑制注释,在发现项所在位置将其屏蔽——编辑器也会遵循这些注释,因此一条注释就能同时屏蔽灯泡提示和 apply 工具(被抑制的发现项既不会报告,也不会修复):
// fsharpanalyzer: ignore-line-next FR0106
let orderNumber (s: string) = Int32.Parse(s.Substring(6, 5))
let inline dodgy (s: string) = s.Substring(0, 3) // fsharpanalyzer: ignore-line FR0106
// fsharpanalyzer: ignore-file FR0031, FR0038
// fsharpanalyzer: ignore-region-start FR0002
// fsharpanalyzer: ignore-region-end
抑制注释也很容易使用,而团队可能不希望像命名问题那样,仅凭一行标点就能屏蔽正确性问题。“suppressions”策略就是用来划定这条界限的:
{ "suppressions": "no-correctness" }
"all"(默认值)——每条抑制注释都会屏蔽对应的发现项。"no-correctness"——正确性类别规则上的注释仍会被报告;惯用法、外观和性能类别的抑制仍然有效。被覆盖的发现项永远不会自动修复——工具不会因为某人显式添加的注释而改写代码——它会继续报告(并导致--fail-on-findings失败),直到问题得到处理,或团队认定该注释值得遵循。"none"——每条抑制注释都会被报告。
命令行上的 --honor-suppressions 会将本次运行的策略覆盖为 "all"。如果仓库希望开发者的机器上不启用抑制、但在流水线中启用,就在配置中提交 "no-correctness"(或 "none"),并且只在 CI 中传入该标志。无论采用哪种策略,运行摘要都会统计被注释屏蔽的发现项——抑制绝不会悄无声息。请注意,该策略只适用于此工具:编辑器会原生遵循 SDK 的注释,因此灯泡提示仍然可以被屏蔽。
你的公共接口是 API 吗?
配置中的两个键决定了不带标志时 --api-changes 会应用到什么程度。最好将它们区分开来,因为它们分别把关两种不同的风险。
{ "publicApi": false }
F# 中的声明默认是公开的,因此语法树中的 public 通常表示没有作出明确决定。受作用域限制的规则——那些修复会就地改变声明编译后形状的规则,例如 [<Struct>]、[<Literal>]、命名联合字段,以及字段的 option 变为 voption——会避开公共声明,因为其他程序集中的使用者会看到这种变化,而此处无法对此进行检查。"publicApi": false 表示不存在这样的使用者:这是一个应用程序、内部工具或叶项目。此时这些规则会将公共声明视为内部声明,在 apply 工具和编辑器中均如此;编辑器从未能使用 --api-changes。这并不授权修改被分析文件以外的内容。
未设置时,由编译结果决定。 OutputType 为 Exe 或 WinExe 时,没有外部链接器——没有任何东西可以引用其公共声明——因此会将其视为叶项目,并自动打开限制。库则不是叶项目,限制仍然关闭。设置 "publicApi": true 可以覆盖此行为:会序列化自身公共类型或通过反射加载插件的可执行文件,需要恢复保守行为。
脚本是逐个文件应答的,而不是作为完整的编译来应答。.fsx 是最终叶节点——它不链接任何内容,也没有内容链接到它——因此其自身的声明处于作用域内。它 #load 的内容则不在:这些源代码归属于拥有它们的项目,很可能是某个库,而某个脚本读取它们,并不能说明还有谁会编译它们。
参与判定的脚本,是正在运行的解决方案或项目文件夹及其子文件夹下的 .fsx,而不是整个驱动器,也不包括 ignorePaths 排除的路径:本仓库告知工具忽略的路径属于外部代码,外部代码不决定本仓库声明的形态。因此,这项保证是:本次运行负责的树形目录内,没有脚本会继续调用已移动的名称。
{ "apiChanges": true }
另一个风险是:修复必须编辑其他文件——比如为函数添加柯里化(FR0090),或重新排列其参数(FR0091),并重写项目中的每个调用点。对于始终认为这样做正确的仓库,可以将 --api-changes 设为固定决策;它涵盖该标志的全部作用,因此也意味着 publicApi: false。以该标志启动的运行不受影响,而且配置只会扩大适用范围,绝不会撤销该标志。运行在采用此设置时会说明原因。
配套的 .fsi 始终优先于两者:签名文件是作者对导出内容的声明——包括作者可以自由编写的 val private——因此,对于签名中声明的任何名称,旁边的形态更改都会让步。而 FR0092(常量 failwith 消息)根本不是可见性规则——它的风险在于测试或调用方会读取文本——所以它仍只由该标志控制。
关卡暂缓的内容会报告出来,而不是隐藏起来。 一次运行结束时会显示类似内容
7 finding(s) held back by scope: 4 FR0070, 3 FR0022 - public declarations
this run may not reshape. Set "publicApi": false in fsharprefactor.json if
nothing outside this assembly links to them or serializes them.
这样,这是一项明确的决策,而不是需要猜测的事情。只需回答一次,工具就会全部应用;没人应该手动重新输入工具本可以写出的内容。
编辑器仍会提供这些操作,但会附上警示。 灯泡是由唯一能实际回答这个问题的人针对每个位置作出的同意,而且只需点击一下,无须手动编辑,因此在编辑器中,这些发现也会出现在公共声明上:
联合类型
Shape仅包含小型值类型;[<Struct>]可避免每个值都在堆上分配内存。会更改公共形态:仅当此程序集之外没有任何内容链接到它或对其进行序列化时才安全(JSON、XML、protobuf——工具无法判断)。
最后这句并非谦辞。序列化无法检测:System.Text.Json、Newtonsoft、XmlSerializer、DataContract、protobuf、MessagePack,以及消费者通过反射接入的任何其他机制,都会读取编译后的形态;而只枚举其中一部分机制的保护措施,会悄无声息地破坏其余机制。因此,工具绝不会推断某种形态更改在序列化方面是安全的——它会说明更改内容,并将判断留给读者。由于无人可询问,应用工具只会统计这些更改。
禁用某条规则会完全跳过其分析,因此在大型代码库中,这个文件也可以用作性能调节手段。在内部,所有分析器都会对每个文件版本共享一次带记忆的 AST 遍历,因此无论启用多少规则,编辑器每次按键都只需遍历一次。
除以下两条规则外,其他规则默认均已启用:
- FR0099(行尾分号)会对每个含有分号的文件进行词法分析,却很少发现问题——对于一个外观修饰类的默认规则而言,成本过高。
- FR0002(将 match option 改为 Option 组合子)是唯一一条经过测量、会让你的代码变慢的重写规则——在其基准测试中慢了 53%,且每次调用都会分配一个闭包。可读性不错,运行成本却更高;当这种权衡适合你的代码库时,再选择启用。
通过
"FR0099": true/"FR0002": true开启任一规则,或明确指定:apply 工具会将--codes FR0002视为优先级高于默认关闭状态和配置中的禁用设置——指定规则就是提出请求。--categories筛选器则有意不采用这种逻辑:--categories idiom会运行已开启的习语规则,不会悄悄启用默认关闭的规则。
同一文件还可以使用 FSharpLint 的提示语法添加自定义 FR0012 术语重写规则(单字母标识符是元变量):
{
"hints": {
"add": [
"Option.isSome x |> not ===> Option.isNone x"
]
}
}
自定义规则与内置规则一样会受到安全处理:绑定会在需要时加上括号;如果规则的右侧省略或重复了某个元变量,则只有在纯原子上才会触发(绝不会丢弃副作用)。
改进
欢迎贡献。本节介绍如何开发分析器;以上内容介绍的是如何使用它们。
尝试你的更改
构建分析器,然后让任一宿主指向构建输出,而不是 NuGet 缓存。
在编辑器中,通过目标仓库的 .vscode/settings.json:
{
"FSharp.enableAnalyzers": true,
"FSharp.analyzersPath": ["<path-to>/FSharp.Refactor.Analyzers/bin/Debug/net8.0"]
}
打开一个 F# 文件,其中包含例如 match x with | true -> 1 | false -> 2
- 此时会出现一条提示,提供
if x then 1 else 2。
或从命令行运行:
fsharp-analyzers --project YourProject.fsproj --analyzers-path src/FSharp.Refactor.Analyzers/bin/Debug/net8.0 --code-root .
注意:分析器必须针对与宿主 FsAutoComplete 兼容的 FSharp.Compiler.Service 进行构建。本项目当前固定使用 FSharp.Analyzers.SDK 0.37.2(FCS 43.12.201)。更新时请参阅 SDK 的版本配对表。
构建和测试
dotnet build
dotnet test
本项目用自己的工具检验自己。提交前:
dotnet tool restore
dotnet fantomas src tests
dotnet dotnet-fsharplint lint src/FSharp.Refactor.Analyzers/FSharp.Refactor.Analyzers.fsproj
dotnet dotnet-fsharplint lint src/FSharp.Refactor.Tool/FSharp.Refactor.Tool.fsproj
dotnet dotnet-fsharplint lint tests/FSharp.Refactor.Tests/FSharp.Refactor.Tests.fsproj
dotnet dotnet-fsharplint lint tests/FSharp.Refactor.PropertyTests/FSharp.Refactor.PropertyTests.fsproj
还会用分析器检查分析器自身的源代码,并预期结果为零。两个项目都要检查,不只是分析器——我们发布的 apply 工具也是用 F# 编写的,而且它曾长期未经检查:
dotnet tool run fsharp-analyzers --project src/FSharp.Refactor.Analyzers/FSharp.Refactor.Analyzers.fsproj --analyzers-path src/FSharp.Refactor.Analyzers/bin/Debug/net8.0 --code-root .
dotnet tool run fsharp-analyzers --project src/FSharp.Refactor.Tool/FSharp.Refactor.Tool.fsproj --analyzers-path src/FSharp.Refactor.Analyzers/bin/Debug/net8.0 --code-root .
测试输入都是字符串字面量,因此格式化工具不会触碰测试所检验的、刻意构造的源代码片段。多行输入通过 fsharp 辅助函数写成缩进代码块(tests/FSharp.Refactor.Tests/TestSource.fs):每一行开头都会去掉与结束引号相同的缩进,因此规则看到的代码片段从第 0 列开始,Fantomas 也可以移动代码块而不改变其内容。
let source =
fsharp
"""
module Test
let f x = match x with | true -> 1 | false -> 2
"""
parse 会让输入无法解析的测试失败,而 parseAndCheck 会让输入无法通过类型检查的测试失败:有类型错误的文件无法触发类型规则,因此在有问题的输入上测试“无匹配项”会因错误的原因通过。对于问题输入本身就是测试重点的情形(FR0077 和 FR0145 会修复编译错误),应调用 parseAndCheckAllowingErrors。修补后的源代码会通过 assertTypechecks "Patched source" patched 检查(仅解析规则则使用 assertParses):如果失败,它会在源代码上方列出编译器错误;而布尔值 typechecksCleanly 只会返回 false。
一行或两行的输入可以保留为转义后的 "...\n..." 字面量。如果字面量必须是常量(属性参数、[<Literal>]、printf 格式),或者其中包含测试所依赖的制表符、\r 或尾随空格,也应保留转义形式,因为编辑器会删掉这些内容。
除了基于示例的测试套件,tests/FSharp.Refactor.PropertyTests 还是一个针对生成程序的 FsCheck 测试套件:其中包括一个由声明组成的模块,每个声明都按仅解析规则的要求构造;以及由三个整数构成、并以解释器作为判定基准的布尔项。其属性体现了规则所保证的不变量——仅解析修复会让文件保持可解析;对于解析器从损坏文本中恢复的语法树,不会有规则抛出异常;逐个应用编辑最终会达到固定点;布尔重写(FR0108/FR0109、否定和德摩根提示、FR0010、FR0013)在每一步都能保持函数的真值表。覆盖率测试会确保生成器符合对应规则:如果某个形状不再触发其目标规则,测试会指出具体是哪一个。
类型规则也采用相同的处理方式,位于 Families/:每个规则族各有一个文件(async、循环、字符串、对象等),其中包含该族规则所针对的代码形状,名称、字面量和变体均随机生成;每个生成的模块都会作为脚本进行类型检查,前面还会加上一个存根文件,用于声明某些规则会检查的命名空间(例如测试框架的属性、日志记录器)。每个规则族都要满足四项属性:生成的程序通过类型检查;单独应用每项修复后程序仍可解析;一起应用所有互不重叠的修复后程序仍可通过类型检查;对于在击键过程中损坏的程序,没有规则会抛出异常。每种形状都会单独针对其命名的规则进行检查;如果形状写作 !FR0162,则会针对它必须保持安静的规则进行检查——这是从真实代码库中吸取教训后加入的误报防护。最后还有一项测试检查整个目录:每条规则都有对应形状,或者有说明无法触发它的理由(例如修复编译错误的规则、编译器会拒绝的制表符,以及 api 阶段跨项目的迁移)。
设计原则
- 绝不破坏用户代码。 只有在能够证明安全的情况下才会提供修复建议;对于边界情况,则不会给出建议。修复是由编辑器应用的最小范围文本编辑,因此始终只需原生撤销一步即可还原。
- 最小化编辑。 编辑范围之外的原始格式保持不变——不会重新格式化整个文件。
- 核心纯净,适配器精简。 每项重构都是纯函数
ParsedInput -> ISourceText -> Suggestion list,直接针对源代码字符串进行单元测试。Analyzers.fs中的 SDK 分析器入口都是单行代码。 - 提示只引导用户采用惯用的 F# 写法。 每个分析器都会将
a -> b重写为更惯用的形式b;我们绝不会发布让代码远离惯用 F# 写法的提示。因此,建议的严重性级别是Hint,而不是警告:它们指出的是改进机会,而不是缺陷,也不会阻止 CI。对于确实可逆、且两个方向都谈不上更惯用的重写(if ↔ match、元组形式 ↔ 柯里化形式),应放在 FsAutoComplete 的代码修复基础设施中,作为由用户调用的refactor.rewrite操作,并应提交到那里,而不是这里。
我们的愿景,以及同领域的其他项目
AI Agent 兼容性:本项目可区分 F# 代码与生成的 Python 代码气味。与此同时,一些过去的规则(例如函数长度和圈复杂度)预计未来会受到较少关注。 本项目专注于惯用的 F# 写法、代码性能和最佳实践,而较少关注代码结构、命名和可维护性。
本项目旨在兼容其他产品,因此不会出现建议的更改来回反复、互相冲突的情况。
| 工具 | 相同规则 | 状态 |
|---|---|---|
| FxCop 和 MS Code Analysis | 许多 | 我们实现了 MinimumRecommendedRules,以及一些与 F# 相关的性能规则等 |
| FSharpLint | 许多 | 我们不只是列出规则,还提供快速修复和自动修复。规则与本项目兼容。 |
| Resharper F# | 许多 | 有许多相同的功能,但使用了完全不同的 AST。 |
| Resharper C# | 部分 | Resharper 侧重于 OO,而我们专注于 FP。许多 C# 问题在 F# 中根本不存在(例如闭包捕获等)。 |
| Linq.Expression.Optimizer | 一些 | 我们优化编译时表现,而此工具优化运行时代码 |
| SonarQube | 少数 | SonarQube 的大多数规则都是从 Java 移植而来的、带有主观倾向的企业开发规则。但我们也有一些相同且适用于 .NET 的规则。 |
| G-Research FSharp Analyzers | 不太算 | 有助于关注可维护性的好规则。关注点不同,应该可以很好地配合使用。 |
| Fantomas | 无 | 关注点不同:Fantomas 是代码布局工具。我们与它兼容,因此可以同时使用两者。 |
| FSharp.Analyzers.SDK | 无 | 我们的工具 fsharp-refactor 基于 FSharp.Analyzers.SDK 构建 |
| CSharp.Refactor | 子集 | 这是本工具的小妹妹,可在你的 C# 项目中运行。 |
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。