lezli01/lintforge
GitHub: lezli01/lintforge
一个轻量级的 Dart 与 Flutter 可插拔静态分析框架,让开发者以纯 Dart 类的形式编写自定义 lint 规则,无需构建完整的 analyzer 插件。
Stars: 0 | Forks: 0
LintForge
适用于 Dart & Flutter 的可插拔静态分析 —— 以纯 Dart 类的形式编写自定义 lint 规则。
规则契约、注册中心、多文件运行器、内置 unused_* 规则以及一个 CLI ——
让团队能够在不编写完整 analyzer 插件的情况下执行项目特定的检查。
为什么使用 •
功能 •
CLI •
规则 •
自定义规则 •
贡献
## 为什么使用 LintForge?
为 Dart 或 Flutter 项目添加自定义静态检查通常意味着要编写一个完整的 analyzer 插件:一个单独的 package、一个 plugin isolate、analyzer 插件协议,以及它自己的发布周期。对于“标记这一个项目特有的模式”来说,这未免有些小题大做。
LintForge 是一个更轻量级的替代方案 —— 它是一个提供契约、规则注册中心、单文件和多文件运行器以及一个 CLI 的框架,因此自定义规则只是一个简单的 Dart 类,它检查完全解析的 AST 并生成诊断信息。
它附带了一套不断增长的内置 `unused_*` 规则,并且由于它本身就是一个静态分析 package,它对自己也设定了相同的标准:它能顺畅地通过 lint,遵循 Effective Dart,并且绝不发布自己都无法通过的规则。
它采用 [MIT License](LICENSE) 发布,由 `lezli01` 创建于 [lezli01.is-a.dev](https://lezli01.is-a.dev)。欢迎积极贡献 —— 请参阅[贡献指南](#contributing)。
## 功能
LintForge 致力于让编写和运行自定义分析成为一流的、纯 Dart 的工作:
- **作为纯 Dart 类的规则。** 为文件级检查实现 `AnalyzerRule`,或为跨文件检查实现 `MultiFileAnalyzerRule`,注册后即可运行 —— 无需建立 analyzer-plugin package、协议或 isolate。
- **解析 AST,而非文本。** 规则在完全解析的 `package:analyzer` 编译单元上运行,因此它们可以跨文件跟踪类型、继承和引用,而无需 grep 源码。
- **内置的未使用代码规则。** 默认启用 `unused_function`、`unused_class` 和 `unused_source_file`,并可通过 `--rules` 标志缩小使用范围。
- **感知语言特性。** 内置规则考虑了 extensions、extension types、mixins、records、patterns、cascades、tear-offs、操作符重载、super-parameter 转发、`noSuchMethod`、`dart:mirrors`、条件导入和延迟导入,以及 `@pragma('vm:entry-point')`。
- **独立的 CLI。** 通过 `dart pub global activate` 一次性安装,`lintforge` 命令默认分析任何项目的 `lib/`、`bin/` 和 `test/` —— 在你要 lint 的项目中无需添加 `dev_dependency`,也无需修改 `pubspec.yaml` —— 并提供 `--rules`、`--exclude`、`--list-rules` 选项,以及对生成文件和构建缓存合理的默认排除规则。
- **感知引用的排除项。** 被排除的文件(例如 `*.g.dart`)仍会被解析和决议,因此从生成代码中发出的引用能保持手写声明依然有效 —— 它们只是自己从不接收诊断信息。
- **感知包含关系的报告。** 这三个 unused 规则构成了一个 文件 → 类型 → 成员 的层级结构,并抑制嵌套的发现项,因此一个废弃产物只会在最粗的层级被报告一次,而不是累积每个声明的噪音。
## 安装
LintForge 是一个**独立的命令行工具**。只需安装一次,即可针对任何 Dart 或 Flutter 项目运行 —— 你**不需要**将其添加到该项目的 `pubspec.yaml` 中:
```
dart pub global activate lintforge
```
这会将 `lintforge` 可执行文件安装到 `~/.pub-cache/bin` 中。请确保该目录在你的 `PATH` 中(请参阅 [pub 全局路径设置](https://dart.dev/tools/pub/cmd/pub-global#running-a-script-from-your-path))。
重新运行相同的命令即可更新;追加版本号可固定特定版本:
```
dart pub global activate lintforge
```
## CLI 用法
针对当前项目运行 analyzer:
```
lintforge [options] [paths...]
```
如果未提供任何路径,LintForge 将检查 `lib/`、`bin/` 和 `test/`。
选项:
- `--help`, `-h`:打印用法并退出。
- `--version`:打印 package 版本并退出。
- `--list-rules`:打印每个已注册规则的 id、严重级别和描述,然后退出。
- `--rules `:仅运行列出的规则 id。
- `--exclude `:排除匹配的路径。可重复使用以指定多个模式。自定义排除项会叠加在内置默认规则之上。
- `--no-default-excludes`:禁用内置的默认排除模式。使用 `--exclude` 列出你仍想排除的任何模式。
- `--color` / `--no-color`:强制开启或关闭彩色输出。如果省略,将自动检测颜色 —— 仅对交互式终端启用,而在输出被管道传输或重定向、设置了 `NO_COLOR` 或 `TERM=dumb` 时禁用。`FORCE_COLOR` 会强制开启。显式指定的标志始终优先于环境变量。
默认情况下,LintForge 会排除消费项目几乎永远不想进行 lint 的生成文件和工具/构建缓存:`*.g.dart`、`*.freezed.dart`、`**/.dart_tool/**` 和 `**/build/**`。这两个目录模式镜像了标准的 Dart/Flutter 忽略集,因此针对项目根目录运行 LintForge 不会标记 `.dart_tool/` 下 Flutter 生成的注册文件或 `build/` 下的构建产物。排除模式会与文件的 basename、其相对于当前工作目录的路径以及其绝对路径进行匹配;任何匹配都会排除该文件。要完全退出默认设置:
```
lintforge --no-default-excludes
```
列出 LintForge 附带的规则 —— 每个条目显示规则 id、严重级别和一行描述,每行一条规则:
```
lintforge --list-rules
```
退出代码:
- `0`:没有 `Severity.error` 的诊断信息。
- `1`:发出了至少一个错误诊断信息。
- `64`:命令行使用错误。
### 输出
LintForge 按文件对发现项进行分组,并将它们排列在格式对齐的 `severity line:col rule message` 列中,并在消息下方的续行显示任何修正提示。报告以一行摘要结束 —— 例如 `✖ 3 issues found (1 error, 2 warnings) in 2 files`,或者在运行干净时显示 `✓ No issues found`。
```
lib/unused_class_sample.dart
warning 13:7 unused_class The class "_Foo" is declared but never used.
↳ Remove "_Foo" or reference it.
warning 16:7 unused_class The mixin "_Bar" is declared but never used.
↳ Remove "_Bar" or reference it.
⚠ 2 issues found (2 warnings) in 1 file
```
当写入交互式终端时,报告是彩色的(严重级别带有颜色,次要细节变暗,文件标题加粗),而在通过管道传输或重定向时保留为纯文本,因此捕获的输出保持整洁且易于 diff。颜色遵循 `NO_COLOR` / `FORCE_COLOR` 约定,可以使用 `--color` / `--no-color` 强制指定。
在分析运行期间,只要 stderr 是交互式终端,实时进度指示器(spinner、进度条和正在解析的文件)就会绘制到 **stderr** 上,并在打印结果之前擦除 —— 因此它绝不会污染写入 stdout 的诊断信息。
## 将排除的文件作为引用源
`--exclude`(以及 `LintforgeOptions` 中的 `excludePaths`)会抑制匹配文件的**诊断报告**。它**不会**将它们从分析中移除:运行器仍会通过 `package:analyzer` 发现、解析并决议每个排除的文件,并将生成的编译单元输入到多文件引用和可达性图中。排除的文件作为普通源参与跨文件分析 —— 只是它们自己从不接收诊断信息,也不会被分发给单文件规则。
例如,在默认的 `*.g.dart` 排除规则生效时:
```
// lib/src/handlers.dart (reportable)
String formatGreeting(String name) => 'Hi $name';
```
```
// lib/src/handlers.g.dart (excluded — not reported on)
String greet(String name) => formatGreeting(name);
```
`handlers.g.dart` 被排除了,因此它永远不会被标记。但是运行器仍然会解析它,因此对 `formatGreeting` 的调用会被记录为真实的引用:`unused_function` 会将 `formatGreeting` 视为已使用,并且不会标记它,即使它唯一的调用者存在于被排除的文件中。
针对每个内置规则:
- **`unused_function`** —— 排除文件中的引用会计作对可报告集合中候选声明的使用。生成的伴生文件(`*.g.dart`、`*.freezed.dart` 等)如果调用了手写代码,会合理地使这些声明保持活跃,而不会产生误报。
- **`unused_source_file`** —— 排除的文件在可达性图中同时充当边缘源和边缘目标:排除文件的 `import` / `export` / `part` 指令可以将非排除文件标记为已到达,非排除文件也可以到达排除文件。排除文件自身永远不会被报告为未使用,无论分析集中的任何内容是否导入了它们。
- **`unused_class`** —— 文件本地规则。该规则仅检查同一编译单元内的引用,因此“将排除文件作为引用”除了标准的“排除文件永远不会被报告”保证之外,没有其他影响。
## 内置规则
LintForge 附带以下默认启用的规则。要关闭某个规则,请在传入 `--rules` 时使用不包含它的列表。
### 未使用的函数
- **Id:** `unused_function`
- **默认严重级别:** `warning`
- **分发:** 多文件(通过 `registerMultiFile` 注册);该规则在单次调用中能看到分析集中的每个已解析编译单元,并跨文件解析引用。
标记在已分析的文件集中任何地方都未被引用的函数形式声明:
- 顶级函数 —— 私有(名称以 `_` 开头)无条件标记,如果文件位于 package 的 `lib/src/` 目录下且从未被外部引用,则公共函数也会被标记;
- 顶级 getter 和 setter,适用与顶级函数相同的隐私 + `lib/src/` 规则;
- 在另一个函数或方法体内部的局部函数声明;
- 类和枚举上的构造函数(生成型、命名型、工厂型和重定向型);
- 类、mixins、枚举和 extension types 上的实例和 `static` 方法;
- 在类、mixins、枚举和 extension types 上声明的 getter、setter 和操作符;
- 在 `extension Foo on T {}` 块内声明的方法、`static` 方法、getter、setter 和操作符。
直接调用、tear-offs、命名类型引用、构造函数调用和重定向、操作符和索引表达式、显式成员访问以及 setter 赋值都算作使用。该规则具备特性感知能力:它还能跟踪 Dart 3 对象模式解构(`final Foo(:getter) = …`)、record 字面量和 record 模式(`(obj.getter,)` / `final (g,) = …`)、级联部分(`obj..foo()`)以及对可调用对象的隐式 `.call` 调用(`instance()` 被解析为 `instance.call()`),因此仅通过上述任何形式访问到的成员都会被正确计为已使用。
Super-parameter 转发(Dart 2.17+,`class B extends A { B({super.x}); }`)算作对父类型构造函数的使用,即使 AST 中没有 `super(...)` 调用节点 —— 该规则会从已解析的构造函数元素中读取隐式的父构造函数目标。这也适用于类未声明自身构造函数时插入的合成默认构造函数(`class B extends A {}` 算作对 `A.new` 的使用)。
带参数的枚举值声明(`enum Route { home('/'); const Route(this.path); final String path; }`)会在常量评估时隐式调用枚举的构造函数,但 AST 并未将其建模为 `InstanceCreationExpression` / `ConstructorName`。该规则从 `EnumConstantDeclaration.constructorElement` 读取构造函数目标,因此枚举的构造函数在每个声明的值处都被算作被引用一次。
在进行查找之前,全局引用集和候选集都会通过 `Element.baseElement` 进行投影,因此在泛型基类(`class Box { void put(T v) {} }`)上声明的成员会匹配通过同一声明的替换视图解析的调用点(`IntBox().put(0)`,其中 `IntBox extends Box`),这也适用于泛型 sealed 类上的工厂构造函数(`Holder.value(0)`)。
对可达的父类型成员的重写被视为使用:当 `MethodDeclaration` 重写了继承的父类型成员,且该成员要么在分析单元集之外声明(`dart:*`、`package:flutter`、运行之外的任何 package),要么其本身就在全局引用集中时,该重写将被豁免。不需要显 `@override` 注解 —— 无论是否带有注解,隐藏父类型成员的声明都属于重写,而且框架回调(`State.createState`、`Widget.createElement`、生命周期钩子)通常编写时也不带该注解。被重写的成员通过继承图进行解析,并在完整的 `extends` / `with` / `implements` / `on` 链中进行按名称的回退查找,因此这统一适用于方法、操作符、getter 和 setter,并涵盖框架回调重写(`State.build`、`Object.toString`、`operator ==` 等)以及仓库内的抽象基类/具体子类型分发。
有意不标记:
- 库的 `main` 函数;
- 在 `lib/src/` 之外声明的公共顶级函数、getter 和 setter(即 package 的公共接口);
- 在 `lib/src/` 之外声明的公共类型(类、mixin、枚举、extension type 或 `extension` 块)的公共实例/static 方法、getter、setter 和操作符 —— 它们构成了 package 可供消费的公共 API 表面,可被外部消费者访问并受测试检验,因此“在分析集中未找到引用”并不能证明它们未被使用。私有成员以及私有类型的成员仍会被标记;
- 条件导出或导入的非选择分支文件中的声明和成员(`export 'stub.dart' if (dart.library.html) 'web.dart';`)—— analyzer 会将每个指令解析为每个构建目标的单个分支,但每个 `if (…)` 配置分支都是真实的引用,因此只能通过非选择分支访问到的成员将被豁免,而不会被标记为死代码;
- 任何形式的 `external` 声明;
- 带有 `@pragma('vm:entry-point')` 注解的声明;
- 在具有 `part` 文件的库中声明的顶级函数、getter 和 setter(同级的 part 可能会合理地引用它们,而该规则目前不会遍历 part 库);
- 父类型链(`extends` / `with` / `implements`)声明了自身 `noSuchMethod` 的类的每个成员和构造函数 —— 该重写可以在运行时服务任何选择器,因此仅通过它访问到的成员没有静态引用。该遍历过程还会通过简单名称识别 `package:mocktail` 的 `Fake` 和 `Mock` 基类,因为分析的源码通常不会将 mocktail 库作为解析依赖拉取进来;
- 导入 `dart:mirrors` 的库中声明的每个成员和构造函数 —— 反射可以通过名称调用任意成员,因此该规则保守地跳过整个单元;
- 在文件顶部带有事实上的生成代码标记 `// ignore_for_file: type=lint` 的单元中的每个声明 —— Flutter 的 `flutter gen-l10n` 会将这一行写入合成的 `L` 基类以及它发出的每个按语言环境的 `output-localization-file` 子类中,而其他构建时的 Dart 代码生成工具也遵循相同的惯例,因此该规则将此标记视为“此文件是生成的,不要标记”的信号,并跳过该单元的每个候选收集器;
- 私有的、未引用的类、mixin、枚举、extension type 或 extension 的成员 —— `unused_class` 已经标记了封闭声明,因此重新标记每个成员只会重复报告;
- `unused_source_file` 报告为不可达的文件中的每个声明 —— 整个文件已经被标记了,因此列出其中的每个函数只会重复报告(请参阅[规则交互](#rule-interaction))。
### 未使用的类
- **Id:** `unused_class`
- **默认严重级别:** `warning`
标记名称以 `_` 开头且在同一编译单元中从未被引用的文件本地声明:
- `class` 声明(包括 `abstract`、`base`、`final`、`sealed` 和 `interface` 修饰符);
- `mixin` 声明;
- `enum` 声明;
- `extension type` 声明。
任何对声明的引用都算作使用,包括类型注解、构造函数调用、`extends`/`implements`/`with`/`on` 子句、`is`/`as` 检查、static 成员访问、枚举值访问以及构造函数或 static tear-offs。该规则具备 Dart 3 特性感知能力:它还能跟踪 Dart 3 对象模式(`switch` 中的 `case _Foo()`)、record 类型注解(`(_Foo, int)`)以及对 `sealed` 父类型的穷尽式 `switch` —— 因此仅在通过其子类型的模式分支中引用的私有类型会被正确视为已使用。
在此版本中特意不进行标记:
- 公共顶级类、mixins、枚举和 extension types;
- `class _Foo = A with B;` typedef 风格的类声明(`ClassTypeAlias`);
- `extension` 声明(非类型形式的 `extension _Ext on T {}`);
- 带有 `@pragma('vm:entry-point')` 注解的声明;
- 在导入 `dart:mirrors` 的库中声明的任何私有候选对象 —— 反射可以在运行时命名任意类型,因此该规则保守地跳过整个单元;
- 属于具有 `part` 文件的库的文件;
- `unused_source_file` 报告为不可达的文件中声明的类型 —— 整个文件已经被标记了,因此重新标记其中的每个类型只会重复报告(请参阅[规则交互](#rule-interaction))。
### 未使用的源文件
- **Id:** `unused_source_file`
- **默认严重级别:** `warning`
一条跨文件规则,用于标记分析集中从未从任何入口点到达的 Dart 源文件。可达性是遍历 `import`、`export` 和 `part` 指令(其解析后的 URI 位于分析集内)来计算的;解析为 `dart:` 库、package 依赖项或从运行中排除的文件的 URI 会被忽略。对于条件导入(`import 'x' if (dart.library.io) 'y'`),无论当前处于什么活动平台,每个 `if (...)` 配置都会贡献一个可达性边缘 —— 在单次分析运行中,两个备选方案都保持可达。延迟导入(`import 'x' deferred as p`)的遍历方式与普通导入完全相同。
入口点(始终被视为“已到达”)包括:
- `bin/` 下的文件;
- `test/` 下的文件;
- 声明了顶级 `main` 函数的文件;
- `lib/.dart` 以及直接位于 `lib/` 下的任何其他文件(即未嵌套在诸如 `lib/src/` 的子目录中),它们共同构成了 package 的公共表面。
如果一个非入口点文件可以通过 `import`、`export` 或 `part` 指令直接或间接地从入口点到达 —— 包括条件导入的每一种配置和延迟导入的目标 —— 则它被视为“已使用”。
在此版本中特意不进行标记:
- 入口点文件本身(`bin/` 或 `test/` 下的文件,带有顶级 `main` 的文件,以及 `lib/src/` 外部的 `lib/*.dart` 文件);
- 从入口点通过 `import`、`export` 或 `part` 指令直接或间接到达的任何文件 —— 包括条件导入的每一种配置和延迟导入的目标;
- 生成的文件的 basenames,例如 `*.g.dart` 和 `*.freezed.dart`,即使运行器的默认排除项被关闭,也会被防御性地跳过。
### 规则交互
这三个“unused”规则构成了一个包含层级结构 —— 源文件包含类型,类型包含成员:
```
unused_source_file (whole file)
└── unused_class (whole type)
└── unused_function (member / constructor)
```
当发现项在外层触发时,嵌套在其中的层级会被**抑制**而不是再次报告,因此一个废弃产物只会在最粗的层级被报告一次,而不会累积一堆针对每个声明的警告:
- `unused_source_file` 标记的文件会吞掉其中的任何 `unused_class` 和 `unused_function` 发现项 —— 文件被报告一次,而不是每个声明报告一次;
- `unused_class` 标记的私有、未引用类型会吞掉其成员的 `unused_function` 发现项。
这两个层级在不同的地方执行。**文件**层级以 `unused_source_file` 发出的发现项为基准,因此从 `--rules` 中去除 `unused_source_file` 会再次让这些文件内针对单个声明的发现项显示出来。**类型**层级是在 `unused_function` 内部计算的(基于封闭类型是否是一个私有、未引用的声明),因此无论是否启用了 `unused_class`,它都会生效。
## 自定义规则
独立的 `lintforge` CLI 运行内置规则。要运行你自己的规则,请改为将 LintForge 作为库使用:在你要进行 lint 的项目中将其添加为 `dev_dependency`,实现 `AnalyzerRule`,将其注册到 `RuleRegistry`,然后在你通过 `dart run` 运行的一个小型入口点中将注册表传递给 `AnalysisRunner`:
```
import 'package:lintforge/lintforge.dart';
class MyRule extends AnalyzerRule {
@override
String get id => 'my_rule';
@override
String get description => 'Flags a project-specific pattern.';
@override
Severity get defaultSeverity => Severity.warning;
@override
Iterable analyze(AnalysisContext context) sync* {
// Inspect context.unit and yield Diagnostic instances.
}
}
Future main() async {
final registry = RuleRegistry()..register(MyRule());
const options = LintforgeOptions.defaults();
final runner = AnalysisRunner(registry: registry, options: options);
final diagnostics = await runner.run();
for (final diagnostic in diagnostics) {
print(diagnostic);
}
}
```
`AnalyzerRule` 每个文件分发一次,仍然是文件本地检查的正确契约:实现一次只能看到一个解析后的编译单元,无法观察同级文件。
对于必须跨文件进行推理的检查,请改为实现 `MultiFileAnalyzerRule`。多文件规则在每次运行时与完整的已解析编译单元集一起调用一次,因此它可以构建跨文件结构,例如导入图或符号索引。使用与 `AnalyzerRule` 实现相同的 `RuleRegistry` 注册多文件规则;内置的 `unused_source_file` 规则就是以这种方式实现的。现有的 `AnalyzerRule` 实现可以继续正常工作,无需更改。
## 示例项目
[`samples/`](samples/) 目录包含每个内置规则的鲜活、可执行的文档:每个规则对应一个独立的 Dart/Flutter package,外加一个演练所有内置规则的 `all_rules` 示例。每个示例都依赖于该 package 的路径,并提供了一个 `README.md`,其中列出了它所涵盖规则的精确正面(必须被标记)和负面(绝对不能被标记)案例。
| 示例 | 演练内容 |
| ---------------------------------------------------------------------- | ------------------------------------------ |
| [`samples/unused_function/`](samples/unused_function/) | `unused_function` 规则 |
| [`samples/unused_class/`](samples/unused_class/) | `unused_class` 规则 |
| [`samples/unused_source_file/`](samples/unused_source_file/) | `unused_source_file` 多文件规则 |
| [`samples/all_rules/`](samples/all_rules/) | 一起演练所有内置规则 |
从仓库根目录运行任何示例,例如:
```
fvm dart pub get --directory samples/unused_function
fvm dart run lintforge samples/unused_function/lib
```
## 开发
LintForge 是一个纯 Dart package。此仓库使用 FVM 来固定 SDK 版本。在本地工作之前,请安装配置好的 SDK:
```
fvm install
fvm dart pub get
```
在提交 Pull Request 之前,请运行与 CI 相同的检查:
```
fvm dart format .
fvm dart analyze --fatal-infos --fatal-warnings
fvm dart test --coverage=coverage
fvm dart pub publish --dry-run
```
## 项目状态
LintForge 处于 1.0.0 之前的阶段。该框架 —— 规则契约、注册中心、单文件和多文件运行器、CLI 以及内置的 `unused_function`、`unused_class` 和 `unused_source_file` 规则 —— 目前已经可以正常工作,并由 [`samples/`](samples/) 下的示例项目进行了演练。随着框架的成熟,公共 API 在小版本之间可能仍会发生变化,因此如果你需要可重复的行为,请使用 `dart pub global activate lintforge ` 锁定特定版本。我们计划添加更多内置规则,包括更广泛的未使用声明检测和 `const` 建议。
## 安全性
LintForge 在本地作为开发依赖运行:它读取你的源码,使用 `package:analyzer` 进行解析,并打印诊断信息。它不执行任何网络访问,也从不执行它分析的代码,因此其攻击面非常小 —— 但我们非常重视安全报告。请通过 GitHub 针对此仓库的私密漏洞报告功能私密地报告可疑漏洞,而不是发布公开 issue。详情请参阅 [SECURITY.md](SECURITY.md)。
## 许可
LintForge 采用 [MIT License](LICENSE) 发布。© 2026 lezli01。标签:CLI, Dart, Flutter, Lint工具, WiFi技术, 云安全监控, 代码规范, 文档结构分析, 静态分析