From bd92b6e419712866df97ed25fc505adc8a1603a4 Mon Sep 17 00:00:00 2001 From: Owain Brown Date: Fri, 9 Oct 2026 14:47:56 +0100 Subject: [PATCH 01/11] Add hedgehoglab_lints analyzer plugin with STATE rules and metric limits Co-Authored-By: Claude Opus 5.5 --- .github/workflows/plugin.yml | 45 ++++ README.md | 4 + plugin/hedgehoglab_lints/README.md | 89 +++++++ .../hedgehoglab_lints/analysis_options.yaml | 6 + .../example/analysis_options.yaml | 14 ++ .../hedgehoglab_lints/example/lib/clean.dart | 21 ++ .../hedgehoglab_lints/example/lib/stubs.dart | 6 + .../example/lib/violations.dart | 41 ++++ .../example/lib/violations_metrics.dart | 79 ++++++ plugin/hedgehoglab_lints/example/pubspec.yaml | 6 + plugin/hedgehoglab_lints/lib/main.dart | 33 +++ plugin/hedgehoglab_lints/lib/src/limits.dart | 17 ++ .../src/rules/max_cyclomatic_complexity.dart | 118 +++++++++ .../lib/src/rules/max_function_length.dart | 63 +++++ .../lib/src/rules/max_nesting.dart | 100 ++++++++ .../lib/src/rules/max_parameters.dart | 63 +++++ .../lib/src/rules/no_static_singleton.dart | 105 ++++++++ .../lib/src/rules/notifier_suffix.dart | 55 +++++ .../lib/src/rules/notifier_void_methods.dart | 88 +++++++ plugin/hedgehoglab_lints/lib/src/support.dart | 89 +++++++ plugin/hedgehoglab_lints/pubspec.yaml | 16 ++ .../test/metric_rules_test.dart | 202 ++++++++++++++++ .../test/standards_rules_test.dart | 227 ++++++++++++++++++ .../hedgehoglab_lints/test/test_support.dart | 18 ++ 24 files changed, 1505 insertions(+) create mode 100644 .github/workflows/plugin.yml create mode 100644 plugin/hedgehoglab_lints/README.md create mode 100644 plugin/hedgehoglab_lints/analysis_options.yaml create mode 100644 plugin/hedgehoglab_lints/example/analysis_options.yaml create mode 100644 plugin/hedgehoglab_lints/example/lib/clean.dart create mode 100644 plugin/hedgehoglab_lints/example/lib/stubs.dart create mode 100644 plugin/hedgehoglab_lints/example/lib/violations.dart create mode 100644 plugin/hedgehoglab_lints/example/lib/violations_metrics.dart create mode 100644 plugin/hedgehoglab_lints/example/pubspec.yaml create mode 100644 plugin/hedgehoglab_lints/lib/main.dart create mode 100644 plugin/hedgehoglab_lints/lib/src/limits.dart create mode 100644 plugin/hedgehoglab_lints/lib/src/rules/max_cyclomatic_complexity.dart create mode 100644 plugin/hedgehoglab_lints/lib/src/rules/max_function_length.dart create mode 100644 plugin/hedgehoglab_lints/lib/src/rules/max_nesting.dart create mode 100644 plugin/hedgehoglab_lints/lib/src/rules/max_parameters.dart create mode 100644 plugin/hedgehoglab_lints/lib/src/rules/no_static_singleton.dart create mode 100644 plugin/hedgehoglab_lints/lib/src/rules/notifier_suffix.dart create mode 100644 plugin/hedgehoglab_lints/lib/src/rules/notifier_void_methods.dart create mode 100644 plugin/hedgehoglab_lints/lib/src/support.dart create mode 100644 plugin/hedgehoglab_lints/pubspec.yaml create mode 100644 plugin/hedgehoglab_lints/test/metric_rules_test.dart create mode 100644 plugin/hedgehoglab_lints/test/standards_rules_test.dart create mode 100644 plugin/hedgehoglab_lints/test/test_support.dart diff --git a/.github/workflows/plugin.yml b/.github/workflows/plugin.yml new file mode 100644 index 0000000..a2b62a3 --- /dev/null +++ b/.github/workflows/plugin.yml @@ -0,0 +1,45 @@ +name: plugin + +on: + pull_request: + paths: + - "plugin/**" + - ".github/workflows/plugin.yml" + +jobs: + hedgehoglab_lints: + runs-on: ubuntu-latest + defaults: + run: + working-directory: plugin/hedgehoglab_lints + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + + - name: Install dependencies + run: dart pub get + + - name: Format + run: dart format --output=none --set-exit-if-changed . + + - name: Analyze + run: dart analyze + + - name: Test + run: dart test + + # End to end: the plugin, enabled from the example's analysis_options.yaml, + # must report every rule on violating code and nothing on clean code. + - name: Example project + working-directory: plugin/hedgehoglab_lints/example + run: | + dart pub get + dart analyze lib/clean.dart lib/stubs.dart + out="$(dart analyze lib/violations.dart lib/violations_metrics.dart || true)" + echo "$out" + for rule in hl_notifier_suffix hl_notifier_void_methods hl_no_static_singleton \ + hl_max_function_length hl_max_cyclomatic_complexity hl_max_nesting hl_max_parameters; do + echo "$out" | grep -q " $rule\$" || { echo "missing $rule"; exit 1; } + done diff --git a/README.md b/README.md index 817f092..214dd76 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,10 @@ This will ensure you always use the latest version of the lints. If you wish to include: package:netsells_flutter_analysis/analysis_options.4.0.0.yaml ``` +## Analyzer plugin + +`plugin/hedgehoglab_lints/` is a separate package: an analyzer plugin that turns code-metric limits and the mechanically checkable Flutter standards into warnings from `dart analyze`. See its [README](plugin/hedgehoglab_lints/README.md). It is independent of the lint set above. + ## Suppressing Lints There may be cases where specific lint rules are undesirable. Lint rules can be surpressed at the line, file, or project level. diff --git a/plugin/hedgehoglab_lints/README.md b/plugin/hedgehoglab_lints/README.md new file mode 100644 index 0000000..d1f9f78 --- /dev/null +++ b/plugin/hedgehoglab_lints/README.md @@ -0,0 +1,89 @@ +# hedgehoglab_lints + +An analyzer plugin that enforces hedgehog lab Flutter standards and code-metric limits from `dart analyze`. It costs nothing until a rule is violated, which makes it the cheapest layer to hold a standard at: a developer or coding agent sees the warning in the editor or in CI and fixes it, with no prose in context and no review round trip. + +It is built on the `analysis_server_plugin` API (Dart 3.10 / Flutter 3.38 and later; developed against Dart 3.13 / Flutter 3.47), the same system `riverpod_lint` 3 uses. It is separate from the root `netsells_flutter_analysis` lint set and needs nothing from it. + +## Rules + +All rules are warnings, are enabled as soon as the plugin is, and apply to hand-written files under `lib/` (generated `*.g.dart` and `*.freezed.dart` files are skipped). Standards IDs refer to [standards/04-state-management.md](https://github.com/hedgehoglab-engineering/flutter-standards/blob/main/standards/04-state-management.md); the source does not print numeric IDs, so `STATE-nnn` is the rule's position in that file (001 generate with `@riverpod`, 002 no singletons, 003 `keepAlive`, 004 file grouping, 005 suffix, 006 method naming, 007 void returns). + +| Rule | Standard | Reports | +|---|---|---| +| `hl_notifier_suffix` | STATE-005 | A `@riverpod` class extending a generated `_$Name` base whose name does not end in `Notifier`. | +| `hl_notifier_void_methods` | STATE-007 | A public method on such a class declared to return anything other than `void`, `Future` or `FutureOr`. Exempt: `build`, `@override`, static, private, getters, setters, operators, and methods with no declared return type. | +| `hl_no_static_singleton` | STATE-002 | A non-`const` static field that holds an instance of its own class, when it is not `final`, is named `instance`/`shared`/`singleton` (leading underscore allowed), or the class has mutable instance fields. | +| `hl_max_function_length` | metric | More than 50 source lines of code in a function or method (blank and comment-only lines excluded). | +| `hl_max_cyclomatic_complexity` | metric | Cyclomatic complexity above 20: 1 plus each `if`, loop, `case`, `catch`, `?:`, `&&`, `||`, `??`. | +| `hl_max_nesting` | metric | Control flow (`if`, loops, `switch`, `try`) nested more than 5 deep. `else if` does not add depth. | +| `hl_max_parameters` | metric | More than 4 parameters, positional or named. Constructors and `@override` members are not measured. | + +STATE-005 and STATE-007 match syntactically (`@riverpod` or `@Riverpod(...)` plus `extends _$Name`), so they work before `build_runner` has run. STATE-002 is deliberately conservative: it only looks at a field whose own type is the enclosing class, so a static `Map` cache, a `const` value, or `static final zero = Money(0)` on an immutable class is not reported. A static field holding some other mutable object would need data-flow analysis and is out of scope. Metric limits are constants in `lib/src/limits.dart` and match the `dart_code_linter` defaults; they are not per-project options (the plugin API has no structured rule configuration, only on/off). Use `// ignore: hl_max_parameters` for a justified exception. + +### Why the metrics are our own + +`dart_code_linter` 4.4.2 (MIT) does run as a new-style plugin on Dart 3.13, and its metric thresholds are configurable. But the plugin only exposes its lint rules: cyclomatic complexity, nesting, parameters and source lines are reachable only through its own CLI (`dart run dart_code_linter:metrics analyze lib`), not through `dart analyze`. Under `dart analyze` it reported none of them on a function that breaks every limit. That puts them outside the "enforced by the analyzer, zero tokens" path, so the four equivalents here are minimal reimplementations. `dart_code_linter`'s rules can still be enabled alongside this plugin (it is a second `plugins:` entry with its own `version:`). + +## Enabling it + +In the consuming project's `pubspec.yaml` nothing is needed; the analysis server resolves the plugin itself. Add to `analysis_options.yaml`: + +```yaml +plugins: + hedgehoglab_lints: + git: + url: https://github.com/hedgehoglab-engineering/flutter-analysis + path: plugin/hedgehoglab_lints + ref: hedgehoglab_lints-v0.1.0 +``` + +For local development use `path: /absolute/or/relative/path/to/hedgehoglab_lints` instead of `git:` (see `example/analysis_options.yaml`). Restart the Dart analysis server after changing the `plugins:` section. The first run resolves the plugin's dependencies and takes about 20 seconds; later runs are fast. + +Rules are warning rules, so they are on by default. To switch one off in a project: + +```yaml +plugins: + hedgehoglab_lints: + git: {...} + diagnostics: + hl_max_parameters: false +``` + +### Observed behaviour on Dart 3.13 / Flutter 3.47 + +* **Plugins in an `include:`d file are honoured by `dart analyze`.** The upstream docs say plugins cannot be configured in a nested options file, but a `plugins:` section in a file pulled in with `include: package:shared/analysis_options.yaml` was applied (five of five expected warnings, against none with the include removed). Plugin lists from the include and from the project's own file merge, and a project can switch a rule off with `diagnostics:` in its own file. Because the docs say otherwise this is undocumented behaviour that could change, so the safe adoption pattern is to put the `plugins:` block in each project's own `analysis_options.yaml`; sharing it through an include is a convenience to re-verify on each Flutter upgrade. +* **`flutter analyze` is unreliable with plugins on 3.47.0.** It runs the language server and returns before plugin diagnostics arrive. Over six runs on the same project it showed the plugin warnings in three and none in three (the same happens with `dart_code_linter`), while `dart analyze` showed all of them in four of four. Gate CI and agent checks on `dart analyze`; treat `flutter analyze` output as incomplete. + +### With riverpod_lint + +A project like bnf-flutter that has `riverpod_lint` in `dev_dependencies` but does not enable it needs it listed as a plugin too (the dependency alone does nothing in the new system, and `custom_lint` is no longer involved): + +```yaml +plugins: + riverpod_lint: ^3.1.9 + hedgehoglab_lints: + git: + url: https://github.com/hedgehoglab-engineering/flutter-analysis + path: plugin/hedgehoglab_lints + ref: hedgehoglab_lints-v0.1.0 +``` + +Both loaded together in a test project. The `riverpod_lint` entry in `dev_dependencies` can be dropped once it is listed here. + +## Versioning + +The plugin has its own version in `pubspec.yaml`, independent of the root package. Release by tagging `hedgehoglab_lints-vX.Y.Z`; projects pin that tag in `ref:`. New rules are warnings, so adding one can fail a project that gates on warnings: bump the minor version for a new rule or a tighter limit, the patch version for a false-positive fix, and say which projects need work in the release notes. + +## Adding a rule + +Create `lib/src/rules/.dart` with an `AnalysisRule` subclass (a `static const LintCode` with `severity: DiagnosticSeverity.WARNING`, a `hl_` snake_case name, and the standards ID at the start of the message) and a `SimpleAstVisitor`; register it in `lib/main.dart` with `registerWarningRule`. Add a test class to `test/` extending `AnalysisRuleTest` with a positive case, a negative case and an edge case, add the rule to the table above, and add a violating example to `example/lib/violations.dart` (the CI example job checks that every rule fires there). Only add a rule if it can be made reliable with a syntactic or resolved-type check; if it needs a heuristic, document the heuristic in the class comment and keep it conservative. + +## Development + +```sh +dart pub get +dart format . +dart analyze +dart test +cd example && dart pub get && dart analyze # 7 warnings, all from violations*.dart +``` diff --git a/plugin/hedgehoglab_lints/analysis_options.yaml b/plugin/hedgehoglab_lints/analysis_options.yaml new file mode 100644 index 0000000..0bc7d3c --- /dev/null +++ b/plugin/hedgehoglab_lints/analysis_options.yaml @@ -0,0 +1,6 @@ +# Self-contained on purpose: the repository root's analysis_options.yaml +# includes the published lint set, which this package does not depend on. +analyzer: + language: + strict-casts: true + strict-raw-types: true diff --git a/plugin/hedgehoglab_lints/example/analysis_options.yaml b/plugin/hedgehoglab_lints/example/analysis_options.yaml new file mode 100644 index 0000000..1842703 --- /dev/null +++ b/plugin/hedgehoglab_lints/example/analysis_options.yaml @@ -0,0 +1,14 @@ +# The plugin must be listed here, in the consuming project's own file. +# See ../README.md for the include-vs-root finding. +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** +plugins: + hedgehoglab_lints: + path: .. diff --git a/plugin/hedgehoglab_lints/example/lib/clean.dart b/plugin/hedgehoglab_lints/example/lib/clean.dart new file mode 100644 index 0000000..ec9f3cb --- /dev/null +++ b/plugin/hedgehoglab_lints/example/lib/clean.dart @@ -0,0 +1,21 @@ +import 'stubs.dart'; + +class _$CartNotifier {} + +@riverpod +class CartNotifier extends _$CartNotifier { + int build() => 0; + + Future onSaveTapped() async {} + + void onClearTapped() {} +} + +class Money { + const Money(this.pence); + + final int pence; + static const zero = Money(0); +} + +int add(int a, int b) => a + b; diff --git a/plugin/hedgehoglab_lints/example/lib/stubs.dart b/plugin/hedgehoglab_lints/example/lib/stubs.dart new file mode 100644 index 0000000..1095743 --- /dev/null +++ b/plugin/hedgehoglab_lints/example/lib/stubs.dart @@ -0,0 +1,6 @@ +// Stand-in for riverpod_annotation so this example needs no dependencies. The +// rules match syntactically on @riverpod and an `extends _$Name` clause, as in +// a real project. The `_$Name` bases are declared beside each notifier here +// because in a real project they sit in the generated part file of the same +// library. +const riverpod = Object(); diff --git a/plugin/hedgehoglab_lints/example/lib/violations.dart b/plugin/hedgehoglab_lints/example/lib/violations.dart new file mode 100644 index 0000000..bca0692 --- /dev/null +++ b/plugin/hedgehoglab_lints/example/lib/violations.dart @@ -0,0 +1,41 @@ +import 'stubs.dart'; + +class _$CartController {} + +class _$CartNotifier {} + +// STATE-005: notifier not suffixed `Notifier`. +@riverpod +class CartController extends _$CartController {} + +// STATE-007: public notifier method returns a value. +@riverpod +class SavedCartNotifier extends _$CartNotifier { + Future onSaveTapped() async => true; +} + +// STATE-002: static singleton. +class SettingsStore { + static final instance = SettingsStore(); + final String name = 'settings'; +} + +// Metric: too many parameters (limit 4). +int sum(int a, int b, int c, int d, int e) => a + b + c + d + e; + +// Metric: nesting too deep (limit 5). +void deep(bool a) { + if (a) { + if (a) { + if (a) { + if (a) { + if (a) { + if (a) { + print('deep'); + } + } + } + } + } + } +} diff --git a/plugin/hedgehoglab_lints/example/lib/violations_metrics.dart b/plugin/hedgehoglab_lints/example/lib/violations_metrics.dart new file mode 100644 index 0000000..fef1d35 --- /dev/null +++ b/plugin/hedgehoglab_lints/example/lib/violations_metrics.dart @@ -0,0 +1,79 @@ +// Metric: cyclomatic complexity 23 (limit 20). +void branchy(int a) { + if (a == 0) print(0); + if (a == 1) print(1); + if (a == 2) print(2); + if (a == 3) print(3); + if (a == 4) print(4); + if (a == 5) print(5); + if (a == 6) print(6); + if (a == 7) print(7); + if (a == 8) print(8); + if (a == 9) print(9); + if (a == 10) print(10); + if (a == 11) print(11); + if (a == 12) print(12); + if (a == 13) print(13); + if (a == 14) print(14); + if (a == 15) print(15); + if (a == 16) print(16); + if (a == 17) print(17); + if (a == 18) print(18); + if (a == 19) print(19); + if (a == 20) print(20); + if (a > 100) print('big'); +} + +// Metric: function length 52 lines (limit 50). +void long() { + print(0); + print(1); + print(2); + print(3); + print(4); + print(5); + print(6); + print(7); + print(8); + print(9); + print(10); + print(11); + print(12); + print(13); + print(14); + print(15); + print(16); + print(17); + print(18); + print(19); + print(20); + print(21); + print(22); + print(23); + print(24); + print(25); + print(26); + print(27); + print(28); + print(29); + print(30); + print(31); + print(32); + print(33); + print(34); + print(35); + print(36); + print(37); + print(38); + print(39); + print(40); + print(41); + print(42); + print(43); + print(44); + print(45); + print(46); + print(47); + print(48); + print(49); +} diff --git a/plugin/hedgehoglab_lints/example/pubspec.yaml b/plugin/hedgehoglab_lints/example/pubspec.yaml new file mode 100644 index 0000000..ab28fc7 --- /dev/null +++ b/plugin/hedgehoglab_lints/example/pubspec.yaml @@ -0,0 +1,6 @@ +name: hedgehoglab_lints_example +description: Demonstrates hedgehoglab_lints enabled in a consuming project. +publish_to: none + +environment: + sdk: ^3.10.0 diff --git a/plugin/hedgehoglab_lints/lib/main.dart b/plugin/hedgehoglab_lints/lib/main.dart new file mode 100644 index 0000000..6cb7e4a --- /dev/null +++ b/plugin/hedgehoglab_lints/lib/main.dart @@ -0,0 +1,33 @@ +import 'package:analysis_server_plugin/plugin.dart'; +import 'package:analysis_server_plugin/registry.dart'; + +import 'src/rules/max_cyclomatic_complexity.dart'; +import 'src/rules/max_function_length.dart'; +import 'src/rules/max_nesting.dart'; +import 'src/rules/max_parameters.dart'; +import 'src/rules/no_static_singleton.dart'; +import 'src/rules/notifier_suffix.dart'; +import 'src/rules/notifier_void_methods.dart'; + +/// Entry point discovered by the analysis server from `plugins:` in +/// `analysis_options.yaml`. +final plugin = HedgehogLabLintsPlugin(); + +/// Registers every hedgehog lab rule as a warning rule, so each is enabled as +/// soon as the plugin is, with no per-rule `diagnostics:` entry needed. +class HedgehogLabLintsPlugin extends Plugin { + @override + String get name => 'hedgehoglab_lints'; + + @override + void register(PluginRegistry registry) { + registry + ..registerWarningRule(NotifierSuffix()) + ..registerWarningRule(NotifierVoidMethods()) + ..registerWarningRule(NoStaticSingleton()) + ..registerWarningRule(MaxFunctionLength()) + ..registerWarningRule(MaxCyclomaticComplexity()) + ..registerWarningRule(MaxNesting()) + ..registerWarningRule(MaxParameters()); + } +} diff --git a/plugin/hedgehoglab_lints/lib/src/limits.dart b/plugin/hedgehoglab_lints/lib/src/limits.dart new file mode 100644 index 0000000..5dcc7e4 --- /dev/null +++ b/plugin/hedgehoglab_lints/lib/src/limits.dart @@ -0,0 +1,17 @@ +/// House limits for the metric rules. They match the defaults of +/// dart_code_linter so a team moving between the two sees the same numbers. +/// Change a limit here (one release for every project) rather than per +/// project; use `// ignore:` for a justified one-off. +abstract final class Limits { + /// Source lines of code in one function or method body. + static const functionLines = 50; + + /// McCabe cyclomatic complexity of one function or method. + static const cyclomaticComplexity = 20; + + /// Depth of nested control structures (`if`, loops, `switch`, `try`). + static const nesting = 5; + + /// Formal parameters (positional and named) of one function or method. + static const parameters = 4; +} diff --git a/plugin/hedgehoglab_lints/lib/src/rules/max_cyclomatic_complexity.dart b/plugin/hedgehoglab_lints/lib/src/rules/max_cyclomatic_complexity.dart new file mode 100644 index 0000000..4686487 --- /dev/null +++ b/plugin/hedgehoglab_lints/lib/src/rules/max_cyclomatic_complexity.dart @@ -0,0 +1,118 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:analyzer/analysis_rule/rule_context.dart'; +import 'package:analyzer/analysis_rule/rule_visitor_registry.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/token.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/error/error.dart'; + +import '../limits.dart'; +import '../support.dart'; + +/// Metric: McCabe cyclomatic complexity above [Limits.cyclomaticComplexity]. +/// +/// Counted as 1 plus each `if`, loop, `case` (not `default`), `catch`, +/// conditional expression, `&&`, `||` and `??` in the body, closures included. +class MaxCyclomaticComplexity extends AnalysisRule { + static const LintCode code = LintCode( + 'hl_max_cyclomatic_complexity', + 'Function "{0}" has a cyclomatic complexity of {1}, over the limit of {2}.', + correctionMessage: 'Split the branching into smaller functions.', + severity: DiagnosticSeverity.WARNING, + ); + + MaxCyclomaticComplexity() + : super( + name: 'hl_max_cyclomatic_complexity', + description: 'Limits the cyclomatic complexity of a function.', + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + if (!shouldAnalyse(context)) return; + registerFunctionVisitor(registry, this, _Visitor(this)); + } +} + +class _Visitor extends FunctionVisitor { + final MaxCyclomaticComplexity rule; + + _Visitor(this.rule); + + @override + void onFunction( + Token name, + FunctionBody body, + FormalParameterList? parameters, + NodeList metadata, + ) { + final counter = _Counter(); + body.accept(counter); + final complexity = counter.count + 1; + if (complexity > Limits.cyclomaticComplexity) { + rule.reportAtToken( + name, + arguments: [name.lexeme, complexity, Limits.cyclomaticComplexity], + ); + } + } +} + +class _Counter extends RecursiveAstVisitor { + int count = 0; + + void _hit(AstNode node) { + count++; + node.visitChildren(this); + } + + @override + void visitIfStatement(IfStatement node) => _hit(node); + + @override + void visitIfElement(IfElement node) => _hit(node); + + @override + void visitForStatement(ForStatement node) => _hit(node); + + @override + void visitForElement(ForElement node) => _hit(node); + + @override + void visitWhileStatement(WhileStatement node) => _hit(node); + + @override + void visitDoStatement(DoStatement node) => _hit(node); + + @override + void visitSwitchCase(SwitchCase node) => _hit(node); + + @override + void visitSwitchPatternCase(SwitchPatternCase node) => _hit(node); + + @override + void visitSwitchExpressionCase(SwitchExpressionCase node) => _hit(node); + + @override + void visitCatchClause(CatchClause node) => _hit(node); + + @override + void visitConditionalExpression(ConditionalExpression node) => _hit(node); + + @override + void visitBinaryExpression(BinaryExpression node) { + final type = node.operator.type; + if (type == TokenType.AMPERSAND_AMPERSAND || + type == TokenType.BAR_BAR || + type == TokenType.QUESTION_QUESTION) { + count++; + } + super.visitBinaryExpression(node); + } +} diff --git a/plugin/hedgehoglab_lints/lib/src/rules/max_function_length.dart b/plugin/hedgehoglab_lints/lib/src/rules/max_function_length.dart new file mode 100644 index 0000000..4ed5882 --- /dev/null +++ b/plugin/hedgehoglab_lints/lib/src/rules/max_function_length.dart @@ -0,0 +1,63 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:analyzer/analysis_rule/rule_context.dart'; +import 'package:analyzer/analysis_rule/rule_visitor_registry.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/token.dart'; +import 'package:analyzer/error/error.dart'; + +import '../limits.dart'; +import '../support.dart'; + +/// Metric: a function or method body has more than [Limits.functionLines] +/// source lines of code (blank and comment-only lines excluded). +class MaxFunctionLength extends AnalysisRule { + static const LintCode code = LintCode( + 'hl_max_function_length', + 'Function "{0}" has {1} lines of code, over the limit of {2}.', + correctionMessage: 'Extract part of the body into a well-named function.', + severity: DiagnosticSeverity.WARNING, + ); + + MaxFunctionLength() + : super( + name: 'hl_max_function_length', + description: 'Limits the source lines of code in a function.', + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + if (!shouldAnalyse(context)) return; + registerFunctionVisitor(registry, this, _Visitor(this, context)); + } +} + +class _Visitor extends FunctionVisitor { + final MaxFunctionLength rule; + final RuleContext context; + + _Visitor(this.rule, this.context); + + @override + void onFunction( + Token name, + FunctionBody body, + FormalParameterList? parameters, + NodeList metadata, + ) { + final unit = context.currentUnit?.unit; + if (unit == null) return; + final lines = sourceLinesOfCode(body, unit.lineInfo); + if (lines > Limits.functionLines) { + rule.reportAtToken( + name, + arguments: [name.lexeme, lines, Limits.functionLines], + ); + } + } +} diff --git a/plugin/hedgehoglab_lints/lib/src/rules/max_nesting.dart b/plugin/hedgehoglab_lints/lib/src/rules/max_nesting.dart new file mode 100644 index 0000000..9c705f2 --- /dev/null +++ b/plugin/hedgehoglab_lints/lib/src/rules/max_nesting.dart @@ -0,0 +1,100 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:analyzer/analysis_rule/rule_context.dart'; +import 'package:analyzer/analysis_rule/rule_visitor_registry.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/token.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/error/error.dart'; + +import '../limits.dart'; +import '../support.dart'; + +/// Metric: control structures (`if`, loops, `switch`, `try`) nested deeper than +/// [Limits.nesting]. An `else if` continues its chain and adds no depth. +class MaxNesting extends AnalysisRule { + static const LintCode code = LintCode( + 'hl_max_nesting', + 'Function "{0}" nests control flow {1} levels deep, over the limit of {2}.', + correctionMessage: 'Use early returns or extract the inner block.', + severity: DiagnosticSeverity.WARNING, + ); + + MaxNesting() + : super( + name: 'hl_max_nesting', + description: 'Limits the nesting depth of control flow.', + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + if (!shouldAnalyse(context)) return; + registerFunctionVisitor(registry, this, _Visitor(this)); + } +} + +class _Visitor extends FunctionVisitor { + final MaxNesting rule; + + _Visitor(this.rule); + + @override + void onFunction( + Token name, + FunctionBody body, + FormalParameterList? parameters, + NodeList metadata, + ) { + final depth = _Depth(); + body.accept(depth); + if (depth.max > Limits.nesting) { + rule.reportAtToken( + name, + arguments: [name.lexeme, depth.max, Limits.nesting], + ); + } + } +} + +class _Depth extends RecursiveAstVisitor { + int _current = 0; + int max = 0; + + void _nest(AstNode node) { + _current++; + if (_current > max) max = _current; + node.visitChildren(this); + _current--; + } + + @override + void visitIfStatement(IfStatement node) { + // Visit the else branch at this level so `else if` chains stay flat. + _current++; + if (_current > max) max = _current; + node.expression.accept(this); + node.thenStatement.accept(this); + _current--; + node.elseStatement?.accept(this); + } + + @override + void visitForStatement(ForStatement node) => _nest(node); + + @override + void visitWhileStatement(WhileStatement node) => _nest(node); + + @override + void visitDoStatement(DoStatement node) => _nest(node); + + @override + void visitSwitchStatement(SwitchStatement node) => _nest(node); + + @override + void visitTryStatement(TryStatement node) => _nest(node); +} diff --git a/plugin/hedgehoglab_lints/lib/src/rules/max_parameters.dart b/plugin/hedgehoglab_lints/lib/src/rules/max_parameters.dart new file mode 100644 index 0000000..5d2d6db --- /dev/null +++ b/plugin/hedgehoglab_lints/lib/src/rules/max_parameters.dart @@ -0,0 +1,63 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:analyzer/analysis_rule/rule_context.dart'; +import 'package:analyzer/analysis_rule/rule_visitor_registry.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/token.dart'; +import 'package:analyzer/error/error.dart'; + +import '../limits.dart'; +import '../support.dart'; + +/// Metric: a function or method declares more than [Limits.parameters] +/// parameters. Constructors are not measured (widgets legitimately take many +/// named arguments) and neither are `@override` members, whose signature is +/// dictated by the supertype. +class MaxParameters extends AnalysisRule { + static const LintCode code = LintCode( + 'hl_max_parameters', + 'Function "{0}" has {1} parameters, over the limit of {2}.', + correctionMessage: 'Group related parameters into an object.', + severity: DiagnosticSeverity.WARNING, + ); + + MaxParameters() + : super( + name: 'hl_max_parameters', + description: 'Limits the number of parameters of a function.', + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + if (!shouldAnalyse(context)) return; + registerFunctionVisitor(registry, this, _Visitor(this)); + } +} + +class _Visitor extends FunctionVisitor { + final MaxParameters rule; + + _Visitor(this.rule); + + @override + void onFunction( + Token name, + FunctionBody body, + FormalParameterList? parameters, + NodeList metadata, + ) { + if (parameters == null || hasAnnotation(metadata, 'override')) return; + final count = parameters.parameters.length; + if (count > Limits.parameters) { + rule.reportAtToken( + name, + arguments: [name.lexeme, count, Limits.parameters], + ); + } + } +} diff --git a/plugin/hedgehoglab_lints/lib/src/rules/no_static_singleton.dart b/plugin/hedgehoglab_lints/lib/src/rules/no_static_singleton.dart new file mode 100644 index 0000000..a3b149e --- /dev/null +++ b/plugin/hedgehoglab_lints/lib/src/rules/no_static_singleton.dart @@ -0,0 +1,105 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:analyzer/analysis_rule/rule_context.dart'; +import 'package:analyzer/analysis_rule/rule_visitor_registry.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/error/error.dart'; + +import '../support.dart'; + +/// STATE-002: no app state in a singleton. +/// +/// Deliberately conservative. In `lib/`, a `static` field of class `C` that +/// is not `const` and holds a `C` (declared type `C` or `C?`, or initialised +/// by constructing `C`, including `C._()` and `C.named()`) is reported when +/// any of these holds: +/// +/// * it is not `final` (a mutable holder, as in `static C? _instance`); +/// * it is named like a singleton accessor (`instance`, `_instance`, +/// `shared`, `singleton`, with or without a leading underscore); +/// * `C` has a non-final, non-static instance field (mutable state). +/// +/// Not reported: `const` fields, `static final empty = C()` on an immutable +/// value class, static fields of other types (a cache `Map`, a `Logger`), and +/// anything outside `lib/` or in generated files. Static fields holding some +/// other mutable object are out of scope: that needs data-flow analysis and +/// would produce false positives. +class NoStaticSingleton extends AnalysisRule { + static const LintCode code = LintCode( + 'hl_no_static_singleton', + 'STATE-002: Static field "{0}" holds an instance of its own class, a ' + 'singleton.', + correctionMessage: 'Expose it through a Riverpod provider instead.', + severity: DiagnosticSeverity.WARNING, + ); + + NoStaticSingleton() + : super( + name: 'hl_no_static_singleton', + description: 'STATE-002: do not hold app state in a singleton.', + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + if (!shouldAnalyse(context)) return; + registry.addClassDeclaration(this, _Visitor(this)); + } +} + +const _singletonNames = {'instance', 'shared', 'singleton'}; + +class _Visitor extends SimpleAstVisitor { + final NoStaticSingleton rule; + + _Visitor(this.rule); + + @override + void visitClassDeclaration(ClassDeclaration node) { + final body = node.body; + if (body is! BlockClassBody) return; + final className = node.namePart.typeName.lexeme; + final fields = body.members.whereType().toList(); + final hasMutableState = fields.any( + (f) => !f.isStatic && !f.fields.isFinal && !f.fields.isConst, + ); + for (final field in fields.where((f) => f.isStatic)) { + for (final variable in field.fields.variables) { + if (_isSingleton(field.fields, variable, className, hasMutableState)) { + rule.reportAtToken(variable.name, arguments: [variable.name.lexeme]); + } + } + } + } + + bool _isSingleton( + VariableDeclarationList list, + VariableDeclaration variable, + String className, + bool classHasMutableState, + ) { + if (list.isConst) return false; + if (!_holdsOwnClass(list, variable, className)) return false; + final name = variable.name.lexeme.replaceFirst(RegExp('^_+'), ''); + return !list.isFinal || + _singletonNames.contains(name) || + classHasMutableState; + } + + bool _holdsOwnClass( + VariableDeclarationList list, + VariableDeclaration variable, + String className, + ) { + final type = list.type; + if (type is NamedType && type.name.lexeme == className) return true; + final init = variable.initializer; + return init is InstanceCreationExpression && + init.constructorName.type.name.lexeme == className; + } +} diff --git a/plugin/hedgehoglab_lints/lib/src/rules/notifier_suffix.dart b/plugin/hedgehoglab_lints/lib/src/rules/notifier_suffix.dart new file mode 100644 index 0000000..dfef372 --- /dev/null +++ b/plugin/hedgehoglab_lints/lib/src/rules/notifier_suffix.dart @@ -0,0 +1,55 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:analyzer/analysis_rule/rule_context.dart'; +import 'package:analyzer/analysis_rule/rule_visitor_registry.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/error/error.dart'; + +import '../support.dart'; + +/// STATE-005: a class extending a generated Riverpod notifier base +/// (`_$Something`, from `@riverpod`) must be named with the `Notifier` suffix. +/// +/// Riverpod strips the suffix when generating the provider, so any other name +/// makes the generated provider name unpredictable. +class NotifierSuffix extends AnalysisRule { + static const LintCode code = LintCode( + 'hl_notifier_suffix', + 'STATE-005: Notifier class "{0}" must be named with the "Notifier" suffix.', + correctionMessage: 'Rename it to "{0}Notifier" (see standards 04).', + severity: DiagnosticSeverity.WARNING, + ); + + NotifierSuffix() + : super( + name: 'hl_notifier_suffix', + description: 'STATE-005: suffix Riverpod notifier classes Notifier.', + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + if (isGeneratedFile(context)) return; + registry.addClassDeclaration(this, _Visitor(this)); + } +} + +class _Visitor extends SimpleAstVisitor { + final NotifierSuffix rule; + + _Visitor(this.rule); + + @override + void visitClassDeclaration(ClassDeclaration node) { + if (!isRiverpodNotifier(node)) return; + final name = node.namePart.typeName; + if (!name.lexeme.endsWith('Notifier')) { + rule.reportAtToken(name, arguments: [name.lexeme]); + } + } +} diff --git a/plugin/hedgehoglab_lints/lib/src/rules/notifier_void_methods.dart b/plugin/hedgehoglab_lints/lib/src/rules/notifier_void_methods.dart new file mode 100644 index 0000000..47e6cfa --- /dev/null +++ b/plugin/hedgehoglab_lints/lib/src/rules/notifier_void_methods.dart @@ -0,0 +1,88 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:analyzer/analysis_rule/rule_context.dart'; +import 'package:analyzer/analysis_rule/rule_visitor_registry.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:analyzer/error/error.dart'; + +import '../support.dart'; + +/// STATE-007: public methods on a Riverpod notifier must not return a value. +/// +/// The view reads `state`; a return value is a second source of truth. +/// Exempt: `build` (it returns the initial state by contract), `@override` +/// members, static members, getters, setters and operators. A method with no +/// declared return type is not reported (`always_declare_return_types` covers +/// that). +class NotifierVoidMethods extends AnalysisRule { + static const LintCode code = LintCode( + 'hl_notifier_void_methods', + 'STATE-007: Public notifier method "{0}" must return void or ' + 'Future, not "{1}".', + correctionMessage: 'Put the outcome in state and return nothing.', + severity: DiagnosticSeverity.WARNING, + ); + + NotifierVoidMethods() + : super( + name: 'hl_notifier_void_methods', + description: 'STATE-007: public notifier methods return no value.', + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + if (isGeneratedFile(context)) return; + registry.addClassDeclaration(this, _Visitor(this)); + } +} + +class _Visitor extends SimpleAstVisitor { + final NotifierVoidMethods rule; + + _Visitor(this.rule); + + @override + void visitClassDeclaration(ClassDeclaration node) { + if (!isRiverpodNotifier(node)) return; + final body = node.body; + if (body is! BlockClassBody) return; + for (final member in body.members) { + if (member is MethodDeclaration) _check(member); + } + } + + void _check(MethodDeclaration method) { + final name = method.name.lexeme; + if (name.startsWith('_') || + name == 'build' || + method.isStatic || + method.isGetter || + method.isSetter || + method.isOperator || + hasAnnotation(method.metadata, 'override')) { + return; + } + final declared = method.returnType; + if (declared == null) return; + final type = declared.type; + if (type == null || _returnsNothing(type)) return; + rule.reportAtToken(method.name, arguments: [name, type.getDisplayString()]); + } + + /// `void`, `Future` or `FutureOr`. + bool _returnsNothing(DartType type) { + if (type is VoidType) return true; + if (type is InterfaceType && + (type.isDartAsyncFuture || type.isDartAsyncFutureOr)) { + return type.typeArguments.single is VoidType; + } + return false; + } +} diff --git a/plugin/hedgehoglab_lints/lib/src/support.dart b/plugin/hedgehoglab_lints/lib/src/support.dart new file mode 100644 index 0000000..e73aed4 --- /dev/null +++ b/plugin/hedgehoglab_lints/lib/src/support.dart @@ -0,0 +1,89 @@ +import 'package:analyzer/analysis_rule/rule_context.dart'; +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:analyzer/analysis_rule/rule_visitor_registry.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/token.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/source/line_info.dart'; + +/// Files produced by code generation. Rules skip them: the author cannot act +/// on a diagnostic there. +bool isGeneratedFile(RuleContext context) { + final path = context.currentUnit?.file.path ?? ''; + return path.endsWith('.g.dart') || path.endsWith('.freezed.dart'); +} + +/// Whether the rule should look at this file at all (`lib/`, hand-written). +bool shouldAnalyse(RuleContext context) => + context.isInLibDir && !isGeneratedFile(context); + +/// The last identifier of an annotation name: `riverpod` for `@riverpod`, +/// `@r.riverpod` and `@Riverpod(keepAlive: true)` (as `Riverpod`). +String annotationName(Annotation annotation) { + final name = annotation.name; + return name is PrefixedIdentifier ? name.identifier.name : name.name; +} + +bool hasAnnotation(NodeList metadata, String name) => + metadata.any((a) => annotationName(a) == name); + +/// A class annotated `@riverpod`/`@Riverpod(...)` that extends the generated +/// base `_$Something`. Detected syntactically so it works before code +/// generation has run, when `_$Something` does not resolve yet. +bool isRiverpodNotifier(ClassDeclaration node) { + final superclass = node.extendsClause?.superclass.name.lexeme; + if (superclass == null || !superclass.startsWith(r'_$')) return false; + return hasAnnotation(node.metadata, 'riverpod') || + hasAnnotation(node.metadata, 'Riverpod'); +} + +/// Number of distinct source lines in [node] that carry code. Blank lines and +/// comment-only lines do not count. +int sourceLinesOfCode(AstNode node, LineInfo lines) { + final seen = {}; + for (var t = node.beginToken; !t.isEof; t = t.next!) { + final first = lines.getLocation(t.offset).lineNumber; + final last = lines + .getLocation(t.end > t.offset ? t.end - 1 : t.offset) + .lineNumber; + for (var l = first; l <= last; l++) { + seen.add(l); + } + if (t == node.endToken) break; + } + return seen.length; +} + +/// Visits every named function and method, the units the metric rules measure. +abstract class FunctionVisitor extends SimpleAstVisitor { + /// Called for each top-level function, local function and method. + void onFunction( + Token name, + FunctionBody body, + FormalParameterList? parameters, + NodeList metadata, + ); + + @override + void visitFunctionDeclaration(FunctionDeclaration node) => onFunction( + node.name, + node.functionExpression.body, + node.functionExpression.parameters, + node.metadata, + ); + + @override + void visitMethodDeclaration(MethodDeclaration node) => + onFunction(node.name, node.body, node.parameters, node.metadata); +} + +/// Registers [visitor] for all named functions and methods. +void registerFunctionVisitor( + RuleVisitorRegistry registry, + AbstractAnalysisRule rule, + FunctionVisitor visitor, +) { + registry + ..addFunctionDeclaration(rule, visitor) + ..addMethodDeclaration(rule, visitor); +} diff --git a/plugin/hedgehoglab_lints/pubspec.yaml b/plugin/hedgehoglab_lints/pubspec.yaml new file mode 100644 index 0000000..a6ca07d --- /dev/null +++ b/plugin/hedgehoglab_lints/pubspec.yaml @@ -0,0 +1,16 @@ +name: hedgehoglab_lints +description: Analyzer plugin enforcing hedgehog lab Flutter standards and code-metric limits through dart analyze. +version: 0.1.0 +publish_to: none + +environment: + sdk: ^3.10.0 + +dependencies: + analysis_server_plugin: ^0.3.24 + analyzer: ^14.5.0 + +dev_dependencies: + analyzer_testing: ^0.4.3 + test: ^1.25.0 + test_reflective_loader: ^0.4.0 diff --git a/plugin/hedgehoglab_lints/test/metric_rules_test.dart b/plugin/hedgehoglab_lints/test/metric_rules_test.dart new file mode 100644 index 0000000..3cb71a3 --- /dev/null +++ b/plugin/hedgehoglab_lints/test/metric_rules_test.dart @@ -0,0 +1,202 @@ +import 'package:analyzer_testing/analysis_rule/analysis_rule.dart'; +import 'package:hedgehoglab_lints/src/rules/max_cyclomatic_complexity.dart'; +import 'package:hedgehoglab_lints/src/rules/max_function_length.dart'; +import 'package:hedgehoglab_lints/src/rules/max_nesting.dart'; +import 'package:hedgehoglab_lints/src/rules/max_parameters.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import 'test_support.dart'; + +void main() { + defineReflectiveSuite(() { + defineReflectiveTests(MaxFunctionLengthTest); + defineReflectiveTests(MaxCyclomaticComplexityTest); + defineReflectiveTests(MaxNestingTest); + defineReflectiveTests(MaxParametersTest); + }); +} + +/// A function whose body has [lines] source lines including both braces. +String _functionOfLines(int lines) => + 'void f() {\n${' print(1);\n' * (lines - 2)}}\n'; + +@reflectiveTest +class MaxFunctionLengthTest extends AnalysisRuleTest { + @override + void setUp() { + rule = MaxFunctionLength(); + super.setUp(); + } + + Future test_overLimit() async { + final code = _functionOfLines(51); + await assertDiagnostics(code, [ + lint(at(code, 'f()'), 1, messageContainsAll: ['51', '50']), + ]); + } + + Future test_atLimit() async => + assertNoDiagnostics(_functionOfLines(50)); + + /// Edge: blank lines and comments do not count towards the limit. + Future test_blankAndCommentLinesIgnored() async { + final code = + 'void f() {\n${' // note\n\n' * 40}${' print(1);\n' * 48}}\n'; + await assertNoDiagnostics(code); + } + + Future test_methodsAreMeasured() async { + final body = ' print(1);\n' * 50; + final code = 'class A {\n void run() {\n$body }\n}\n'; + await assertDiagnostics(code, [lint(at(code, 'run'), 3)]); + } +} + +@reflectiveTest +class MaxCyclomaticComplexityTest extends AnalysisRuleTest { + @override + void setUp() { + rule = MaxCyclomaticComplexity(); + super.setUp(); + } + + String _ifs(int n) => + 'void f(int a) {\n${' if (a == 1) print(1);\n' * n}}\n'; + + Future test_overLimit() async { + final code = _ifs(20); // 1 + 20 = 21 + await assertDiagnostics(code, [ + lint(at(code, 'f('), 1, messageContainsAll: ['21', '20']), + ]); + } + + Future test_atLimit() async => assertNoDiagnostics(_ifs(19)); + + /// `&&`, `||`, `??`, `?:`, `case` and `catch` each add a path; `default` + /// does not. Base complexity here is 1 + 4 operators + 12 cases + catch = 18. + String _mixed(int extraIfs) => + ''' +int f(int a, int? b, bool c) { + var x = a > 0 && c || b != null ? 1 : 2; + x += b ?? 0; + switch (a) { +${List.generate(12, (i) => ' case $i:').join('\n')} + x++; + default: + x--; + } + try { + x++; + } catch (e) { + x--; + } +${' if (c) x++;\n' * extraIfs} return x; +} +'''; + + Future test_operatorsAndCasesCount_underLimit() async => + assertNoDiagnostics(_mixed(2)); // 20 + + Future test_operatorsAndCasesCount_overLimit() async { + final code = _mixed(3); // 21 + await assertDiagnostics(code, [lint(at(code, 'f('), 1)]); + } +} + +@reflectiveTest +class MaxNestingTest extends AnalysisRuleTest { + @override + void setUp() { + rule = MaxNesting(); + super.setUp(); + } + + String _nested(int depth) { + final open = List.generate(depth, (_) => 'if (a) {').join('\n'); + return 'void f(bool a) {\n$open\nprint(1);\n${'}\n' * depth}}\n'; + } + + Future test_overLimit() async { + final code = _nested(6); + await assertDiagnostics(code, [ + lint(at(code, 'f('), 1, messageContainsAll: ['6', '5']), + ]); + } + + Future test_atLimit() async => assertNoDiagnostics(_nested(5)); + + /// Edge: a long `else if` chain is flat, and sequential blocks do not add. + Future test_elseIfChainIsFlat() async { + final chain = List.generate(10, (i) => 'if (a == $i) {}').join(' else '); + await assertNoDiagnostics('void f(int a) {\n$chain\n}\n'); + } + + Future test_loopsSwitchAndTryNest() async { + const code = ''' +void f(List xs, bool a) { + for (final x in xs) { + while (a) { + try { + switch (x) { + case 1: + if (a) { + do {} while (a); + } + } + } catch (_) {} + } + } +} +'''; + await assertDiagnostics(code, [lint(at(code, 'f('), 1)]); + } +} + +@reflectiveTest +class MaxParametersTest extends AnalysisRuleTest { + @override + void setUp() { + rule = MaxParameters(); + super.setUp(); + } + + Future test_overLimit() async { + const code = 'void f(int a, int b, int c, int d, int e) {}\n'; + await assertDiagnostics(code, [ + lint(at(code, 'f('), 1, messageContainsAll: ['5', '4']), + ]); + } + + Future test_atLimit() async => + assertNoDiagnostics('void f(int a, int b, int c, int d) {}\n'); + + /// Edge: named parameters count towards the limit. + Future test_namedParametersCount() async { + const code = + 'void f({int a = 0, int b = 0, int c = 0, int d = 0, ' + 'int e = 0}) {}\n'; + await assertDiagnostics(code, [lint(at(code, 'f('), 1)]); + } + + /// Edge: constructors and `@override` members are not measured. + Future test_constructorsAndOverridesAreExempt() async { + // Only the declaration of `run` on Base is reported. + const code = ''' +class Base { + void run(int a, int b, int c, int d, int e) {} +} +class Widget extends Base { + Widget(int a, int b, int c, int d, int e); + @override + void run(int a, int b, int c, int d, int e) {} +} +'''; + await assertDiagnostics(code, [lint(at(code, 'run'), 3)]); + } + + Future test_notAppliedOutsideLib() async { + final path = '$testPackageTestPath/a_test.dart'; + newFile(path, 'void f(int a, int b, int c, int d, int e) {}\n'); + await assertNoDiagnosticsInFile(path); + } +} diff --git a/plugin/hedgehoglab_lints/test/standards_rules_test.dart b/plugin/hedgehoglab_lints/test/standards_rules_test.dart new file mode 100644 index 0000000..f752f3a --- /dev/null +++ b/plugin/hedgehoglab_lints/test/standards_rules_test.dart @@ -0,0 +1,227 @@ +import 'package:analyzer_testing/analysis_rule/analysis_rule.dart'; +import 'package:hedgehoglab_lints/src/rules/no_static_singleton.dart'; +import 'package:hedgehoglab_lints/src/rules/notifier_suffix.dart'; +import 'package:hedgehoglab_lints/src/rules/notifier_void_methods.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import 'test_support.dart'; + +void main() { + defineReflectiveSuite(() { + defineReflectiveTests(NotifierSuffixTest); + defineReflectiveTests(NotifierVoidMethodsTest); + defineReflectiveTests(NoStaticSingletonTest); + }); +} + +@reflectiveTest +class NotifierSuffixTest extends AnalysisRuleTest { + @override + void setUp() { + rule = NotifierSuffix(); + super.setUp(); + } + + Future test_missingSuffix() async { + const code = + ''' +$riverpodStub +@riverpod +class CartController extends _\$CartController {} +'''; + await assertDiagnostics(code, [ + lint( + at(code, 'CartController extends'), + 'CartController'.length, + messageContainsAll: ['STATE-005'], + ), + ]); + } + + Future test_withSuffix() async { + await assertNoDiagnostics(''' +$riverpodStub +@riverpod +class CartNotifier extends _\$CartNotifier {} +'''); + } + + Future test_keepAliveAnnotationIsRecognised() async { + const code = + ''' +$riverpodStub +@Riverpod(keepAlive: true) +class CartController extends _\$CartController {} +'''; + await assertDiagnostics(code, [ + lint(at(code, 'CartController extends'), 'CartController'.length), + ]); + } + + /// Edge: without `@riverpod` the class is not a generated notifier, so its + /// name is not this rule's business even though the base starts with `_$`. + Future test_notAnnotatedIsIgnored() async { + await assertNoDiagnostics(''' +$riverpodStub +class CartController extends _\$CartController {} +'''); + } + + /// Edge: an ordinary subclass of a normal class is never touched. + Future test_ordinaryClassIsIgnored() async { + await assertNoDiagnostics(''' +class Base {} +class CartController extends Base {} +'''); + } +} + +@reflectiveTest +class NotifierVoidMethodsTest extends AnalysisRuleTest { + @override + void setUp() { + rule = NotifierVoidMethods(); + super.setUp(); + } + + Future test_returnsValue() async { + const code = + ''' +$riverpodStub +@riverpod +class CartNotifier extends _\$CartNotifier { + Future onSaveTapped() async => true; + int onCountRequested() => 1; +} +'''; + await assertDiagnostics(code, [ + lint( + at(code, 'onSaveTapped'), + 'onSaveTapped'.length, + messageContainsAll: ['STATE-007', 'Future'], + ), + lint(at(code, 'onCountRequested'), 'onCountRequested'.length), + ]); + } + + Future test_voidAndFutureVoidAreFine() async { + await assertNoDiagnostics(''' +import 'dart:async'; +$riverpodStub +@riverpod +class CartNotifier extends _\$CartNotifier { + void onClearTapped() {} + Future onSaveTapped() async {} + FutureOr onRefresh() {} +} +'''); + } + + /// Edge: everything the standard does not cover must stay quiet. + Future test_exemptMembers() async { + await assertNoDiagnostics(''' +$riverpodStub +@riverpod +class CartNotifier extends _\$CartNotifier { + int build() => 0; + int get total => 1; + set total(int value) {} + int _private() => 1; + static int helper() => 1; + @override + String toString() => ''; + bool operator ==(Object other) => true; + onUntyped() => 1; +} +'''); + } + + Future test_nonNotifierClassIsIgnored() async { + await assertNoDiagnostics(''' +class Repo { + Future save() async => true; +} +'''); + } +} + +@reflectiveTest +class NoStaticSingletonTest extends AnalysisRuleTest { + @override + void setUp() { + rule = NoStaticSingleton(); + super.setUp(); + } + + Future test_staticFinalInstance() async { + const code = ''' +class SettingsStore { + static final instance = SettingsStore(); + final String name = 'a'; +} +'''; + await assertDiagnostics(code, [ + lint( + at(code, 'instance'), + 'instance'.length, + messageContainsAll: ['STATE-002'], + ), + ]); + } + + Future test_lazyNullableHolder() async { + const code = ''' +class SettingsStore { + static SettingsStore? _instance; + SettingsStore._(); + factory SettingsStore() => _instance ??= SettingsStore._(); +} +'''; + await assertDiagnostics(code, [ + lint(at(code, '_instance;'), '_instance'.length), + ]); + } + + Future test_namedPrivateConstructorWithMutableState() async { + const code = ''' +class Cart { + static final Cart _cart = Cart._(); + Cart._(); + int count = 0; +} +'''; + await assertDiagnostics(code, [lint(at(code, '_cart ='), '_cart'.length)]); + } + + Future test_constIsFine() async { + await assertNoDiagnostics(''' +class Spacing { + const Spacing(); + static const standard = Spacing(); +} +'''); + } + + /// Edge: an immutable value object with a shared default is not a + /// singleton, and a static cache of another type is out of scope. + Future test_immutableDefaultAndOtherTypesAreFine() async { + await assertNoDiagnostics(''' +class Money { + final int pence; + Money(this.pence); + static final zero = Money(0); + static final Map cache = {}; +} +'''); + } + + Future test_outsideLibIsIgnored() async { + final path = '$testPackageTestPath/store_test.dart'; + newFile(path, ''' +class Store { + static final instance = Store(); +} +'''); + await assertNoDiagnosticsInFile(path); + } +} diff --git a/plugin/hedgehoglab_lints/test/test_support.dart b/plugin/hedgehoglab_lints/test/test_support.dart new file mode 100644 index 0000000..38d97f3 --- /dev/null +++ b/plugin/hedgehoglab_lints/test/test_support.dart @@ -0,0 +1,18 @@ +/// Offset of the first occurrence of [needle] in [code], for `lint(...)`. +int at(String code, String needle) { + final i = code.indexOf(needle); + if (i < 0) throw ArgumentError('"$needle" not found in test code'); + return i; +} + +/// Stand-ins for `riverpod_annotation` and the generated base class, so the +/// tests do not need code generation. The rules are syntactic and match on +/// `@riverpod` / `@Riverpod(...)` plus an `extends _$Name` clause. +const riverpodStub = r''' +const riverpod = Object(); +class Riverpod { + const Riverpod({bool keepAlive = false}); +} +class _$CartNotifier {} +class _$CartController {} +'''; From 27a4144c0ce42bc58ddddaa8b35b14b71c26208d Mon Sep 17 00:00:00 2001 From: Owain Brown Date: Fri, 9 Oct 2026 14:48:41 +0100 Subject: [PATCH 02/11] Keep example analysis_options minimal Co-Authored-By: Claude Opus 5.5 --- .../hedgehoglab_lints/example/analysis_options.yaml | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/plugin/hedgehoglab_lints/example/analysis_options.yaml b/plugin/hedgehoglab_lints/example/analysis_options.yaml index 1842703..eb8f5c7 100644 --- a/plugin/hedgehoglab_lints/example/analysis_options.yaml +++ b/plugin/hedgehoglab_lints/example/analysis_options.yaml @@ -1,14 +1,5 @@ -# The plugin must be listed here, in the consuming project's own file. -# See ../README.md for the include-vs-root finding. -analyzer: - exclude: - - build/** - - android/** - - ios/** - - web/** - - windows/** - - macos/** - - linux/** +# The plugin is listed here, in the consuming project's own file. See +# ../README.md ("Enabling it") for the git form and the include findings. plugins: hedgehoglab_lints: path: .. From 1147a0831ae37f48a4f2efb7ea4205307c5b7b11 Mon Sep 17 00:00:00 2001 From: Owain Brown Date: Fri, 9 Oct 2026 14:49:02 +0100 Subject: [PATCH 03/11] Exclude the violating example from the plugin package's own analysis Co-Authored-By: Claude Opus 5.5 --- plugin/hedgehoglab_lints/analysis_options.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugin/hedgehoglab_lints/analysis_options.yaml b/plugin/hedgehoglab_lints/analysis_options.yaml index 0bc7d3c..e1cd8f4 100644 --- a/plugin/hedgehoglab_lints/analysis_options.yaml +++ b/plugin/hedgehoglab_lints/analysis_options.yaml @@ -4,3 +4,6 @@ analyzer: language: strict-casts: true strict-raw-types: true + exclude: + # Deliberately violating code, analysed on its own by the CI example job. + - example/** From 5cf53185b13c0ac2920a5688fdb2b074dd864848 Mon Sep 17 00:00:00 2001 From: Owain Brown Date: Fri, 9 Oct 2026 16:23:17 +0100 Subject: [PATCH 04/11] Name the renamed root package in the plugin README Co-Authored-By: Claude Opus 5.5 --- analysis_options.yaml | 9 +++++++++ example/analysis_options.yaml | 9 +++++++++ plugin/hedgehoglab_lints/README.md | 2 +- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index a4bfc29..2cba550 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1 +1,10 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: lib/analysis_options.yaml diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index 96a8d76..7df92cf 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -1 +1,10 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:hedgehoglab_flutter_analysis/analysis_options.6.0.0.yaml diff --git a/plugin/hedgehoglab_lints/README.md b/plugin/hedgehoglab_lints/README.md index d1f9f78..6c03277 100644 --- a/plugin/hedgehoglab_lints/README.md +++ b/plugin/hedgehoglab_lints/README.md @@ -2,7 +2,7 @@ An analyzer plugin that enforces hedgehog lab Flutter standards and code-metric limits from `dart analyze`. It costs nothing until a rule is violated, which makes it the cheapest layer to hold a standard at: a developer or coding agent sees the warning in the editor or in CI and fixes it, with no prose in context and no review round trip. -It is built on the `analysis_server_plugin` API (Dart 3.10 / Flutter 3.38 and later; developed against Dart 3.13 / Flutter 3.47), the same system `riverpod_lint` 3 uses. It is separate from the root `netsells_flutter_analysis` lint set and needs nothing from it. +It is built on the `analysis_server_plugin` API (Dart 3.10 / Flutter 3.38 and later; developed against Dart 3.13 / Flutter 3.47), the same system `riverpod_lint` 3 uses. It is separate from the root `hedgehoglab_flutter_analysis` lint set and needs nothing from it. ## Rules From c2403da97566161669672ac993e7ab4ce43d6384 Mon Sep 17 00:00:00 2001 From: Owain Brown Date: Fri, 9 Oct 2026 16:25:26 +0100 Subject: [PATCH 05/11] Analyse only the root package's sources in root CI The root job ran dart analyze over the whole repository, which now includes the plugin package; without its dependencies resolved that reported 380 unresolved-type issues. The plugin has its own workflow. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/main.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 895685d..9e934df 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -33,8 +33,10 @@ jobs: - name: Format run: dart format --output=none --set-exit-if-changed . + # Only the root package's own sources; plugin/ is a separate package with + # its own dependencies, analysed by the plugin workflow. - name: Analyse package - run: dart analyze --fatal-infos . 2>&1 | tee analyze-root.log + run: dart analyze --fatal-infos lib 2>&1 | tee analyze-root.log - name: Analyse example run: dart analyze --fatal-infos example 2>&1 | tee analyze-example.log From d0a698e1216939d776ded5d0ca2e6a9911bc8732 Mon Sep 17 00:00:00 2001 From: Owain Brown Date: Fri, 9 Oct 2026 16:42:26 +0100 Subject: [PATCH 06/11] Split function-length limits, pin standards lints to the Quilltrail catalogue Widget build methods keep 50 source lines; every other function gets 30. Add standards.lock.json (catalogue revision and per-rule content hash), a offline test that the lock and registered rules agree, and tool/verify_standards_pin.dart, which recomputes Quilltrail's content_hash. Document exceptions and the fix-on-change versioning policy. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/plugin.yml | 17 ++ plugin/hedgehoglab_lints/README.md | 37 +++- .../example/lib/violations_metrics.dart | 64 ++++--- plugin/hedgehoglab_lints/lib/main.dart | 19 +-- plugin/hedgehoglab_lints/lib/src/limits.dart | 17 +- plugin/hedgehoglab_lints/lib/src/rules.dart | 22 +++ .../lib/src/rules/max_function_length.dart | 40 +++-- plugin/hedgehoglab_lints/lib/src/support.dart | 19 +++ plugin/hedgehoglab_lints/pubspec.yaml | 1 + plugin/hedgehoglab_lints/standards.lock.json | 28 +++ .../test/metric_rules_test.dart | 159 +++++++++++++++++- .../test/standards_lock_test.dart | 138 +++++++++++++++ .../hedgehoglab_lints/tool/standards_pin.dart | 87 ++++++++++ .../tool/verify_standards_pin.dart | 142 ++++++++++++++++ 14 files changed, 724 insertions(+), 66 deletions(-) create mode 100644 plugin/hedgehoglab_lints/lib/src/rules.dart create mode 100644 plugin/hedgehoglab_lints/standards.lock.json create mode 100644 plugin/hedgehoglab_lints/test/standards_lock_test.dart create mode 100644 plugin/hedgehoglab_lints/tool/standards_pin.dart create mode 100644 plugin/hedgehoglab_lints/tool/verify_standards_pin.dart diff --git a/.github/workflows/plugin.yml b/.github/workflows/plugin.yml index a2b62a3..847314e 100644 --- a/.github/workflows/plugin.yml +++ b/.github/workflows/plugin.yml @@ -9,6 +9,8 @@ on: jobs: hedgehoglab_lints: runs-on: ubuntu-latest + env: + STANDARDS_READ_TOKEN: ${{ secrets.STANDARDS_READ_TOKEN }} defaults: run: working-directory: plugin/hedgehoglab_lints @@ -30,6 +32,15 @@ jobs: - name: Test run: dart test + # Offline: the lock's rule IDs and lints are checked by `dart test`. The + # hash check fetches the pinned catalogue and needs a token that can read + # it, so it runs only where STANDARDS_READ_TOKEN is configured. + - name: Standards pin + if: env.STANDARDS_READ_TOKEN != '' + env: + GH_TOKEN: ${{ secrets.STANDARDS_READ_TOKEN }} + run: dart run tool/verify_standards_pin.dart + # End to end: the plugin, enabled from the example's analysis_options.yaml, # must report every rule on violating code and nothing on clean code. - name: Example project @@ -43,3 +54,9 @@ jobs: hl_max_function_length hl_max_cyclomatic_complexity hl_max_nesting hl_max_parameters; do echo "$out" | grep -q " $rule\$" || { echo "missing $rule"; exit 1; } done + # Both limits are exercised: a notifier build() gets the function + # limit (30), not the widget build limit (50). + echo "$out" | grep -q 'Function "long" has 31 lines of code, over the function limit of 30' \ + || { echo "missing function limit message"; exit 1; } + echo "$out" | grep -q 'Function "build" has 31 lines of code, over the function limit of 30' \ + || { echo "missing notifier build message"; exit 1; } diff --git a/plugin/hedgehoglab_lints/README.md b/plugin/hedgehoglab_lints/README.md index 6c03277..aa0c12d 100644 --- a/plugin/hedgehoglab_lints/README.md +++ b/plugin/hedgehoglab_lints/README.md @@ -6,19 +6,27 @@ It is built on the `analysis_server_plugin` API (Dart 3.10 / Flutter 3.38 and la ## Rules -All rules are warnings, are enabled as soon as the plugin is, and apply to hand-written files under `lib/` (generated `*.g.dart` and `*.freezed.dart` files are skipped). Standards IDs refer to [standards/04-state-management.md](https://github.com/hedgehoglab-engineering/flutter-standards/blob/main/standards/04-state-management.md); the source does not print numeric IDs, so `STATE-nnn` is the rule's position in that file (001 generate with `@riverpod`, 002 no singletons, 003 `keepAlive`, 004 file grouping, 005 suffix, 006 method naming, 007 void returns). +All rules are warnings, are enabled as soon as the plugin is, and apply to hand-written files under `lib/` (generated `*.g.dart` and `*.freezed.dart` files are skipped). + +Standards IDs (`STATE-002` and so on) are the rule IDs of the Quilltrail standards catalogue, at the catalogue revision recorded in [`standards.lock.json`](standards.lock.json) (repository, commit, tag and policy version). Each standards lint is also pinned to the rule's `content_hash`, computed the way Quilltrail computes it (SHA-256 of the rule's title, tag and trimmed body). When a standard is edited its hash changes, `tool/verify_standards_pin.dart` fails, and the lint gets re-reviewed against the new text before the lock is updated. See "Standards pin" below. | Rule | Standard | Reports | |---|---|---| | `hl_notifier_suffix` | STATE-005 | A `@riverpod` class extending a generated `_$Name` base whose name does not end in `Notifier`. | | `hl_notifier_void_methods` | STATE-007 | A public method on such a class declared to return anything other than `void`, `Future` or `FutureOr`. Exempt: `build`, `@override`, static, private, getters, setters, operators, and methods with no declared return type. | | `hl_no_static_singleton` | STATE-002 | A non-`const` static field that holds an instance of its own class, when it is not `final`, is named `instance`/`shared`/`singleton` (leading underscore allowed), or the class has mutable instance fields. | -| `hl_max_function_length` | metric | More than 50 source lines of code in a function or method (blank and comment-only lines excluded). | +| `hl_max_function_length` | metric | More than 30 source lines of code in a function or method, or more than 50 in a Flutter widget `build` method (blank and comment-only lines excluded). The message says which limit applied. | | `hl_max_cyclomatic_complexity` | metric | Cyclomatic complexity above 20: 1 plus each `if`, loop, `case`, `catch`, `?:`, `&&`, `||`, `??`. | | `hl_max_nesting` | metric | Control flow (`if`, loops, `switch`, `try`) nested more than 5 deep. `else if` does not add depth. | | `hl_max_parameters` | metric | More than 4 parameters, positional or named. Constructors and `@override` members are not measured. | -STATE-005 and STATE-007 match syntactically (`@riverpod` or `@Riverpod(...)` plus `extends _$Name`), so they work before `build_runner` has run. STATE-002 is deliberately conservative: it only looks at a field whose own type is the enclosing class, so a static `Map` cache, a `const` value, or `static final zero = Money(0)` on an immutable class is not reported. A static field holding some other mutable object would need data-flow analysis and is out of scope. Metric limits are constants in `lib/src/limits.dart` and match the `dart_code_linter` defaults; they are not per-project options (the plugin API has no structured rule configuration, only on/off). Use `// ignore: hl_max_parameters` for a justified exception. +STATE-005 and STATE-007 match syntactically (`@riverpod` or `@Riverpod(...)` plus `extends _$Name`), so they work before `build_runner` has run. STATE-002 is deliberately conservative: it only looks at a field whose own type is the enclosing class, so a static `Map` cache, a `const` value, or `static final zero = Money(0)` on an immutable class is not reported. A static field holding some other mutable object would need data-flow analysis and is out of scope. Metric limits are constants in `lib/src/limits.dart`; they are not per-project options (the plugin API has no structured rule configuration, only on/off). + +A widget `build` is a non-static method named `build` whose resolved return type is `Widget` (from `package:flutter`) or a subtype, which covers `StatelessWidget`, `State` and `ConsumerWidget` builds. It keeps the 50-line limit because widget trees nest by nature. Every other function gets 30, including a Riverpod notifier's `build()` (it returns state, not a widget) and helpers such as `_buildBody()` that return widgets but are not named `build`. Extracting a sub-widget is the usual fix for an over-long build. + +### Exceptions + +Exceptions are allowed but need human approval. The mechanism is `// ignore: hl_` (or `// ignore_for_file:`) with a reason. In a project that uses the agentic toolkit, the switch-off check fails any change that adds an `// ignore:` unless a human has applied the `rule-exception` label to the PR. That check lives in the toolkit, not in this plugin; the plugin only reports. Disabling a rule project-wide through `diagnostics:` in `analysis_options.yaml` is a switch-off too, and is not an exception route. ### Why the metrics are our own @@ -74,9 +82,27 @@ Both loaded together in a test project. The `riverpod_lint` entry in `dev_depend The plugin has its own version in `pubspec.yaml`, independent of the root package. Release by tagging `hedgehoglab_lints-vX.Y.Z`; projects pin that tag in `ref:`. New rules are warnings, so adding one can fail a project that gates on warnings: bump the minor version for a new rule or a tighter limit, the patch version for a false-positive fix, and say which projects need work in the release notes. +Policy: violations are fixed when a project adopts the plugin or when a rule or limit changes. There are no baselines and no tolerated trailing warnings; a project that cannot fix a violation uses an approved exception (see "Exceptions"). A new rule or a tighter limit is a minor version bump, and its release notes must say that adopting projects fix all resulting violations in the upgrade PR. + +## Standards pin + +`standards.lock.json` records the catalogue revision and, per standards rule, `{file, title, content_hash, lints}`. Quilltrail pins its catalogue the same way (`.quilltrail/standards/catalog-pin.json`, and `{id, file, title, binding, content_hash}` per rule in `standards/provenance.json`), so IDs and hashes here match what a Quilltrail project records. + +Two checks: + +* `dart test` (offline) checks that every standards rule class prints an ID that is in the lock, and that every lock entry names lints that are registered. +* `tool/verify_standards_pin.dart` recomputes each hash and fails on a mismatch, which means a standard changed and its lint needs re-reviewing. By default it fetches the rule files at the pinned commit with `gh` (needs read access to the catalogue repository). Offline alternatives: `--standards-dir DIR` for a checkout of the catalogue's `standards/` folder, or `--provenance FILE` to compare against a project's `standards/provenance.json` (which also checks the commit and policy version). + +```sh +dart run tool/verify_standards_pin.dart +dart run tool/verify_standards_pin.dart --provenance ../bnf-flutter/standards/provenance.json +``` + +The hash algorithm was read from Quilltrail's `resolve_standards.dart` and confirmed by reproducing the STATE-002, 005 and 007 hashes from the pinned commit and from bnf-flutter's `provenance.json`. + ## Adding a rule -Create `lib/src/rules/.dart` with an `AnalysisRule` subclass (a `static const LintCode` with `severity: DiagnosticSeverity.WARNING`, a `hl_` snake_case name, and the standards ID at the start of the message) and a `SimpleAstVisitor`; register it in `lib/main.dart` with `registerWarningRule`. Add a test class to `test/` extending `AnalysisRuleTest` with a positive case, a negative case and an edge case, add the rule to the table above, and add a violating example to `example/lib/violations.dart` (the CI example job checks that every rule fires there). Only add a rule if it can be made reliable with a syntactic or resolved-type check; if it needs a heuristic, document the heuristic in the class comment and keep it conservative. +Create `lib/src/rules/.dart` with an `AnalysisRule` subclass (a `static const LintCode` with `severity: DiagnosticSeverity.WARNING`, a `hl_` snake_case name, and the Quilltrail rule ID at the start of the message for a standards rule) and a `SimpleAstVisitor`; add it to `allRules()` in `lib/src/rules.dart`. For a standards rule, also add its entry to `standards.lock.json` (copy the hash from the catalogue's `provenance.json` and confirm with `tool/verify_standards_pin.dart`) and to `standardsRules` in `test/standards_lock_test.dart`. Add a test class to `test/` extending `AnalysisRuleTest` with a positive case, a negative case and an edge case, add the rule to the table above, and add a violating example to `example/lib/violations.dart` (the CI example job checks that every rule fires there). Only add a rule if it can be made reliable with a syntactic or resolved-type check; if it needs a heuristic, document the heuristic in the class comment and keep it conservative. ## Development @@ -85,5 +111,6 @@ dart pub get dart format . dart analyze dart test -cd example && dart pub get && dart analyze # 7 warnings, all from violations*.dart +dart run tool/verify_standards_pin.dart --provenance /path/to/provenance.json +cd example && dart pub get && dart analyze # 8 warnings, all from violations*.dart ``` diff --git a/plugin/hedgehoglab_lints/example/lib/violations_metrics.dart b/plugin/hedgehoglab_lints/example/lib/violations_metrics.dart index fef1d35..98da09b 100644 --- a/plugin/hedgehoglab_lints/example/lib/violations_metrics.dart +++ b/plugin/hedgehoglab_lints/example/lib/violations_metrics.dart @@ -1,3 +1,5 @@ +import 'stubs.dart'; + // Metric: cyclomatic complexity 23 (limit 20). void branchy(int a) { if (a == 0) print(0); @@ -24,7 +26,7 @@ void branchy(int a) { if (a > 100) print('big'); } -// Metric: function length 52 lines (limit 50). +// Metric: function length 31 lines (limit 30). void long() { print(0); print(1); @@ -55,25 +57,43 @@ void long() { print(26); print(27); print(28); - print(29); - print(30); - print(31); - print(32); - print(33); - print(34); - print(35); - print(36); - print(37); - print(38); - print(39); - print(40); - print(41); - print(42); - print(43); - print(44); - print(45); - print(46); - print(47); - print(48); - print(49); +} + +// Metric: a Riverpod notifier's build() returns state, not a widget, so it gets +// the function limit (30), not the widget build limit (50). 31 lines here. +class _$LongNotifier {} + +@riverpod +class LongNotifier extends _$LongNotifier { + int build() { + print(0); + print(1); + print(2); + print(3); + print(4); + print(5); + print(6); + print(7); + print(8); + print(9); + print(10); + print(11); + print(12); + print(13); + print(14); + print(15); + print(16); + print(17); + print(18); + print(19); + print(20); + print(21); + print(22); + print(23); + print(24); + print(25); + print(26); + print(27); + return 0; + } } diff --git a/plugin/hedgehoglab_lints/lib/main.dart b/plugin/hedgehoglab_lints/lib/main.dart index 6cb7e4a..3ec7a1a 100644 --- a/plugin/hedgehoglab_lints/lib/main.dart +++ b/plugin/hedgehoglab_lints/lib/main.dart @@ -1,13 +1,7 @@ import 'package:analysis_server_plugin/plugin.dart'; import 'package:analysis_server_plugin/registry.dart'; -import 'src/rules/max_cyclomatic_complexity.dart'; -import 'src/rules/max_function_length.dart'; -import 'src/rules/max_nesting.dart'; -import 'src/rules/max_parameters.dart'; -import 'src/rules/no_static_singleton.dart'; -import 'src/rules/notifier_suffix.dart'; -import 'src/rules/notifier_void_methods.dart'; +import 'src/rules.dart'; /// Entry point discovered by the analysis server from `plugins:` in /// `analysis_options.yaml`. @@ -21,13 +15,8 @@ class HedgehogLabLintsPlugin extends Plugin { @override void register(PluginRegistry registry) { - registry - ..registerWarningRule(NotifierSuffix()) - ..registerWarningRule(NotifierVoidMethods()) - ..registerWarningRule(NoStaticSingleton()) - ..registerWarningRule(MaxFunctionLength()) - ..registerWarningRule(MaxCyclomaticComplexity()) - ..registerWarningRule(MaxNesting()) - ..registerWarningRule(MaxParameters()); + for (final rule in allRules()) { + registry.registerWarningRule(rule); + } } } diff --git a/plugin/hedgehoglab_lints/lib/src/limits.dart b/plugin/hedgehoglab_lints/lib/src/limits.dart index 5dcc7e4..ae79508 100644 --- a/plugin/hedgehoglab_lints/lib/src/limits.dart +++ b/plugin/hedgehoglab_lints/lib/src/limits.dart @@ -1,10 +1,15 @@ -/// House limits for the metric rules. They match the defaults of -/// dart_code_linter so a team moving between the two sees the same numbers. -/// Change a limit here (one release for every project) rather than per -/// project; use `// ignore:` for a justified one-off. +/// House limits for the metric rules. Change a limit here (one release for +/// every project) rather than per project. An exception is a justified +/// `// ignore:` that a human has approved (see the README). abstract final class Limits { - /// Source lines of code in one function or method body. - static const functionLines = 50; + /// Source lines of code in one function or method body, widget `build` + /// methods excepted. + static const functionLines = 30; + + /// Source lines of code in a Flutter widget `build` method: a method named + /// `build` whose declared return type is `Widget` or a subtype. Widget trees + /// nest deeply by nature, so they keep the looser limit. + static const widgetBuildLines = 50; /// McCabe cyclomatic complexity of one function or method. static const cyclomaticComplexity = 20; diff --git a/plugin/hedgehoglab_lints/lib/src/rules.dart b/plugin/hedgehoglab_lints/lib/src/rules.dart new file mode 100644 index 0000000..bc8ff03 --- /dev/null +++ b/plugin/hedgehoglab_lints/lib/src/rules.dart @@ -0,0 +1,22 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; + +import 'rules/max_cyclomatic_complexity.dart'; +import 'rules/max_function_length.dart'; +import 'rules/max_nesting.dart'; +import 'rules/max_parameters.dart'; +import 'rules/no_static_singleton.dart'; +import 'rules/notifier_suffix.dart'; +import 'rules/notifier_void_methods.dart'; + +/// Every rule the plugin registers. `main.dart` registers from this list and +/// the tests check `standards.lock.json` against it, so a rule cannot be added +/// to one and forgotten in the other. +List allRules() => [ + NotifierSuffix(), + NotifierVoidMethods(), + NoStaticSingleton(), + MaxFunctionLength(), + MaxCyclomaticComplexity(), + MaxNesting(), + MaxParameters(), +]; diff --git a/plugin/hedgehoglab_lints/lib/src/rules/max_function_length.dart b/plugin/hedgehoglab_lints/lib/src/rules/max_function_length.dart index 4ed5882..314c263 100644 --- a/plugin/hedgehoglab_lints/lib/src/rules/max_function_length.dart +++ b/plugin/hedgehoglab_lints/lib/src/rules/max_function_length.dart @@ -3,17 +3,20 @@ import 'package:analyzer/analysis_rule/rule_context.dart'; import 'package:analyzer/analysis_rule/rule_visitor_registry.dart'; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/token.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; import 'package:analyzer/error/error.dart'; import '../limits.dart'; import '../support.dart'; -/// Metric: a function or method body has more than [Limits.functionLines] -/// source lines of code (blank and comment-only lines excluded). +/// Metric: a function or method body has too many source lines of code (blank +/// and comment-only lines excluded). Flutter widget `build` methods (see +/// [isWidgetBuild]) are allowed [Limits.widgetBuildLines]; everything else, +/// including a Riverpod notifier's `build()`, [Limits.functionLines]. class MaxFunctionLength extends AnalysisRule { static const LintCode code = LintCode( 'hl_max_function_length', - 'Function "{0}" has {1} lines of code, over the limit of {2}.', + 'Function "{0}" has {1} lines of code, over the {3} limit of {2}.', correctionMessage: 'Extract part of the body into a well-named function.', severity: DiagnosticSeverity.WARNING, ); @@ -33,30 +36,41 @@ class MaxFunctionLength extends AnalysisRule { RuleContext context, ) { if (!shouldAnalyse(context)) return; - registerFunctionVisitor(registry, this, _Visitor(this, context)); + final visitor = _Visitor(this, context); + registry + ..addFunctionDeclaration(this, visitor) + ..addMethodDeclaration(this, visitor); } } -class _Visitor extends FunctionVisitor { +class _Visitor extends SimpleAstVisitor { final MaxFunctionLength rule; final RuleContext context; _Visitor(this.rule, this.context); @override - void onFunction( - Token name, - FunctionBody body, - FormalParameterList? parameters, - NodeList metadata, - ) { + void visitMethodDeclaration(MethodDeclaration node) => + _check(node.name, node.body, widgetBuild: isWidgetBuild(node)); + + @override + void visitFunctionDeclaration(FunctionDeclaration node) => + _check(node.name, node.functionExpression.body, widgetBuild: false); + + void _check(Token name, FunctionBody body, {required bool widgetBuild}) { final unit = context.currentUnit?.unit; if (unit == null) return; + final limit = widgetBuild ? Limits.widgetBuildLines : Limits.functionLines; final lines = sourceLinesOfCode(body, unit.lineInfo); - if (lines > Limits.functionLines) { + if (lines > limit) { rule.reportAtToken( name, - arguments: [name.lexeme, lines, Limits.functionLines], + arguments: [ + name.lexeme, + lines, + limit, + widgetBuild ? 'widget build' : 'function', + ], ); } } diff --git a/plugin/hedgehoglab_lints/lib/src/support.dart b/plugin/hedgehoglab_lints/lib/src/support.dart index e73aed4..6a298bb 100644 --- a/plugin/hedgehoglab_lints/lib/src/support.dart +++ b/plugin/hedgehoglab_lints/lib/src/support.dart @@ -4,6 +4,7 @@ import 'package:analyzer/analysis_rule/rule_visitor_registry.dart'; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/token.dart'; import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/dart/element/type.dart'; import 'package:analyzer/source/line_info.dart'; /// Files produced by code generation. Rules skip them: the author cannot act @@ -87,3 +88,21 @@ void registerFunctionVisitor( ..addFunctionDeclaration(rule, visitor) ..addMethodDeclaration(rule, visitor); } + +/// Whether [node] is a Flutter widget `build` method: named `build`, an +/// instance method, with a resolved return type of `Widget` (from +/// `package:flutter`) or a subtype. This covers `StatelessWidget`, `State` and +/// `ConsumerWidget` builds. A Riverpod notifier's `build()` returns state, not a +/// widget, so it is not one. +bool isWidgetBuild(MethodDeclaration node) { + if (node.name.lexeme != 'build' || node.isStatic) return false; + final returnType = node.declaredFragment?.element.returnType; + if (returnType is! InterfaceType) return false; + return [returnType, ...returnType.allSupertypes].any(_isFlutterWidget); +} + +bool _isFlutterWidget(InterfaceType type) { + final element = type.element; + return element.name == 'Widget' && + element.library.uri.toString().startsWith('package:flutter/'); +} diff --git a/plugin/hedgehoglab_lints/pubspec.yaml b/plugin/hedgehoglab_lints/pubspec.yaml index a6ca07d..8531287 100644 --- a/plugin/hedgehoglab_lints/pubspec.yaml +++ b/plugin/hedgehoglab_lints/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: analyzer: ^14.5.0 dev_dependencies: + crypto: ^3.0.0 analyzer_testing: ^0.4.3 test: ^1.25.0 test_reflective_loader: ^0.4.0 diff --git a/plugin/hedgehoglab_lints/standards.lock.json b/plugin/hedgehoglab_lints/standards.lock.json new file mode 100644 index 0000000..813a88c --- /dev/null +++ b/plugin/hedgehoglab_lints/standards.lock.json @@ -0,0 +1,28 @@ +{ + "catalog": { + "repository": "hedgehoglab-engineering/flutter-standards", + "commit": "67839f58358677304d85c8aeb484af9ebae42ea7", + "tag": "feat/quilltrail-catalog-reference", + "policy_version": "2026.09.2" + }, + "rules": { + "STATE-002": { + "file": "04-state-management.md", + "title": "Never hold app state in a singleton", + "content_hash": "c9b37909d9fae3993c17d9d898ba7616fbee239c0ae67373e800b568014dbd2d", + "lints": ["hl_no_static_singleton"] + }, + "STATE-005": { + "file": "04-state-management.md", + "title": "Suffix notifier classes `Notifier`", + "content_hash": "195e65fce7c980f705ee1bcaf30868b3dc02f02525e82a22084cc5877e548bc5", + "lints": ["hl_notifier_suffix"] + }, + "STATE-007": { + "file": "04-state-management.md", + "title": "Public notifier methods must not return a value", + "content_hash": "e7fb716974d6778bd9669b61ea6f47b1ac3c6bba33b2d8bc01551a984f9f8ae8", + "lints": ["hl_notifier_void_methods"] + } + } +} diff --git a/plugin/hedgehoglab_lints/test/metric_rules_test.dart b/plugin/hedgehoglab_lints/test/metric_rules_test.dart index 3cb71a3..404864e 100644 --- a/plugin/hedgehoglab_lints/test/metric_rules_test.dart +++ b/plugin/hedgehoglab_lints/test/metric_rules_test.dart @@ -22,34 +22,183 @@ String _functionOfLines(int lines) => @reflectiveTest class MaxFunctionLengthTest extends AnalysisRuleTest { + @override + bool get addFlutterPackageDep => true; + @override void setUp() { rule = MaxFunctionLength(); super.setUp(); } + /// [lines] source lines inside braces, i.e. a body of that many lines + /// including both braces. + String _body(int lines, [String? returns]) { + final prints = returns == null ? lines - 2 : lines - 3; + final ret = returns == null ? '' : ' return $returns;\n'; + return '{\n${' print(1);\n' * prints}$ret }'; + } + Future test_overLimit() async { - final code = _functionOfLines(51); + final code = _functionOfLines(31); await assertDiagnostics(code, [ - lint(at(code, 'f()'), 1, messageContainsAll: ['51', '50']), + lint( + at(code, 'f()'), + 1, + messageContainsAll: ['31', 'function limit of 30'], + ), ]); } Future test_atLimit() async => - assertNoDiagnostics(_functionOfLines(50)); + assertNoDiagnostics(_functionOfLines(30)); /// Edge: blank lines and comments do not count towards the limit. Future test_blankAndCommentLinesIgnored() async { final code = - 'void f() {\n${' // note\n\n' * 40}${' print(1);\n' * 48}}\n'; + 'void f() {\n${' // note\n\n' * 40}${' print(1);\n' * 28}}\n'; await assertNoDiagnostics(code); } Future test_methodsAreMeasured() async { - final body = ' print(1);\n' * 50; + final body = ' print(1);\n' * 30; final code = 'class A {\n void run() {\n$body }\n}\n'; await assertDiagnostics(code, [lint(at(code, 'run'), 3)]); } + + Future test_widgetBuild_atWidgetLimit() async { + final code = + """ +import 'package:flutter/widgets.dart'; +class W extends StatelessWidget { + @override + Widget build(BuildContext context) ${_body(50, 'const SizedBox()')} +} +"""; + await assertNoDiagnostics(code); + } + + Future test_widgetBuild_overWidgetLimit() async { + final code = + """ +import 'package:flutter/widgets.dart'; +class W extends StatelessWidget { + @override + Widget build(BuildContext context) ${_body(51, 'const SizedBox()')} +} +"""; + await assertDiagnostics(code, [ + lint( + at(code, 'build'), + 5, + messageContainsAll: ['51', 'widget build limit of 50'], + ), + ]); + } + + /// A widget build between 31 and 50 lines is allowed. + Future test_widgetBuild_overFunctionLimitIsAllowed() async { + final code = + """ +import 'package:flutter/widgets.dart'; +class W extends StatelessWidget { + @override + Widget build(BuildContext context) ${_body(40, 'const SizedBox()')} +} +"""; + await assertNoDiagnostics(code); + } + + Future test_stateBuild_getsWidgetLimit() async { + final code = + """ +import 'package:flutter/widgets.dart'; +class W extends StatefulWidget { + @override + State createState() => _S(); +} +class _S extends State { + @override + Widget build(BuildContext context) ${_body(45, 'const SizedBox()')} +} +"""; + await assertNoDiagnostics(code); + } + + /// Subtype return types and indirect widget bases (a `ConsumerWidget` is a + /// `StatelessWidget` subclass) are widget builds. + Future test_widgetSubtypeReturnAndIndirectBase() async { + final code = + """ +import 'package:flutter/widgets.dart'; +abstract class ConsumerWidget extends StatelessWidget { + const ConsumerWidget(); +} +class W extends ConsumerWidget { + @override + SizedBox build(BuildContext context) ${_body(45, 'const SizedBox()')} +} +"""; + await assertNoDiagnostics(code); + } + + /// A Riverpod notifier's `build()` returns state, not a widget: 30 applies. + Future test_notifierBuild_overFunctionLimit() async { + final code = + """ +$riverpodStub +@riverpod +class CartNotifier extends _\$CartNotifier { + int build() ${_body(31, '0')} +} +"""; + await assertDiagnostics(code, [ + lint( + at(code, 'build'), + 5, + messageContainsAll: ['31', 'function limit of 30'], + ), + ]); + } + + Future test_notifierBuild_atLimit() async { + final code = + """ +$riverpodStub +@riverpod +class CartNotifier extends _\$CartNotifier { + int build() ${_body(30, '0')} +} +"""; + await assertNoDiagnostics(code); + } + + /// Only a method named exactly `build` qualifies: a `Widget` helper gets 30. + Future test_widgetHelperMethod_getsFunctionLimit() async { + final code = + """ +import 'package:flutter/widgets.dart'; +class W extends StatelessWidget { + Widget _buildBody() ${_body(31, 'const SizedBox()')} + @override + Widget build(BuildContext context) => _buildBody(); +} +"""; + await assertDiagnostics(code, [ + lint(at(code, '_buildBody()'), 10, messageContainsAll: ['31', '30']), + ]); + } + + /// A `build` that does not return a widget, outside Riverpod, gets 30. + Future test_nonWidgetBuild_getsFunctionLimit() async { + final code = + """ +class Builder { + String build() ${_body(31, "''")} +} +"""; + await assertDiagnostics(code, [lint(at(code, 'build'), 5)]); + } } @reflectiveTest diff --git a/plugin/hedgehoglab_lints/test/standards_lock_test.dart b/plugin/hedgehoglab_lints/test/standards_lock_test.dart new file mode 100644 index 0000000..d62a1a0 --- /dev/null +++ b/plugin/hedgehoglab_lints/test/standards_lock_test.dart @@ -0,0 +1,138 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:hedgehoglab_lints/src/rules.dart'; +import 'package:hedgehoglab_lints/src/rules/no_static_singleton.dart'; +import 'package:hedgehoglab_lints/src/rules/notifier_suffix.dart'; +import 'package:hedgehoglab_lints/src/rules/notifier_void_methods.dart'; +import 'package:test/test.dart'; + +import '../tool/standards_pin.dart'; + +/// Offline checks that `standards.lock.json` and the registered rules agree. +/// `tool/verify_standards_pin.dart` checks the lock against the catalogue. +void main() { + final lock = + jsonDecode(File('standards.lock.json').readAsStringSync()) as Map; + final pinned = { + for (final e in (lock['rules'] as Map).entries) + e.key as String: PinnedRule( + e.key as String, + (e.value as Map).cast(), + ), + }; + final registered = {for (final r in allRules()) r.name}; + + // Rule classes whose messages start with a standards ID, e.g. "STATE-005:". + final standardsRules = { + NotifierSuffix.code, + NotifierVoidMethods.code, + NoStaticSingleton.code, + }; + + test('the catalogue pin is complete', () { + final catalog = lock['catalog'] as Map; + expect(catalog['repository'], matches(r'^[\w.-]+/[\w.-]+$')); + expect(catalog['commit'], matches(r'^[0-9a-f]{40}$')); + expect(catalog['tag'], isNotEmpty); + expect(catalog['policy_version'], isNotEmpty); + }); + + test('every standards rule class ID is in the lock, naming that lint', () { + for (final code in standardsRules) { + final id = RegExp( + r'^([A-Z][A-Z0-9-]*-[0-9]{3}):', + ).firstMatch(code.problemMessage)?.group(1); + expect(id, isNotNull, reason: '${code.lowerCaseName} has no ID'); + expect(pinned, contains(id)); + expect(pinned[id]!.lints, contains(code.lowerCaseName)); + } + }); + + test('every lock entry names existing registered lints and a hash', () { + for (final rule in pinned.values) { + expect(rule.lints, isNotEmpty, reason: rule.id); + expect(registered, containsAll(rule.lints), reason: rule.id); + expect(rule.contentHash, matches(r'^[0-9a-f]{64}$'), reason: rule.id); + expect(rule.title, isNotEmpty, reason: rule.id); + } + }); + + test('every lint in the lock belongs to a rule that prints its ID', () { + for (final rule in pinned.values) { + final codes = standardsRules.where( + (c) => rule.lints.contains(c.lowerCaseName), + ); + expect(codes, isNotEmpty, reason: rule.id); + for (final code in codes) { + expect(code.problemMessage, startsWith('${rule.id}:')); + } + } + }); + + group('hash algorithm (Quilltrail content_hash)', () { + const markdown = ''' +# Section + +### TEST-001 — First rule [mandatory] + +Body of the first rule. + +### TEST-002 — Second rule [advisory] + +Body of the second rule. +More text. +'''; + + test('parses IDs, titles, tags and bodies', () { + final rules = parseRules(markdown); + expect(rules.keys, ['TEST-001', 'TEST-002']); + expect(rules['TEST-001']!.title, 'First rule'); + expect(rules['TEST-002']!.rawTag, 'advisory'); + expect(rules['TEST-001']!.body, 'Body of the first rule.'); + }); + + test('hashes title, tag and trimmed body with SHA-256', () { + // Computed independently: + // printf 'First rule\nmandatory\nBody of the first rule.' | shasum -a 256 + expect( + parseRules(markdown)['TEST-001']!.contentHash, + '7272390566e95ce62446fa044652c4dcccf2705ce75e926f87af106053e1ca0f', + ); + }); + + test('a changed body changes the hash and is reported', () { + final before = parseRules(markdown)['TEST-002']!; + final after = parseRules( + markdown.replaceFirst('More text.', 'Other text.'), + )['TEST-002']!; + expect(after.contentHash, isNot(before.contentHash)); + final pin = PinnedRule('TEST-002', { + 'file': 'x.md', + 'title': before.title, + 'content_hash': before.contentHash, + 'lints': ['hl_example'], + }); + expect( + comparePin( + pin, + actualHash: before.contentHash, + actualTitle: before.title, + ), + isNull, + ); + expect( + comparePin( + pin, + actualHash: after.contentHash, + actualTitle: after.title, + ), + allOf( + contains('TEST-002'), + contains('hl_example'), + contains('changed'), + ), + ); + }); + }); +} diff --git a/plugin/hedgehoglab_lints/tool/standards_pin.dart b/plugin/hedgehoglab_lints/tool/standards_pin.dart new file mode 100644 index 0000000..1eb5aa6 --- /dev/null +++ b/plugin/hedgehoglab_lints/tool/standards_pin.dart @@ -0,0 +1,87 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; + +/// One rule parsed from a standards markdown file, the way Quilltrail parses +/// it (`desktop/core/lib/setup/application/resolve_standards.dart`). +class StandardRule { + StandardRule(this.id, this.title, this.rawTag, this.body); + + final String id; + final String title; + final String rawTag; + final String body; + + String get contentHash => hashRuleContent(title, rawTag, body); +} + +/// A rule heading: `### STATE-002 — Title [mandatory]`. Same pattern as +/// Quilltrail's `ruleHeadingPattern`. +final _heading = RegExp( + r'^###\s+(?:([A-Z][A-Z0-9-]*-[0-9]{3})\s+[—-]\s+)?(.+?)\s*\[([^\]]+)\]\s*$', + multiLine: true, +); + +/// Quilltrail's `content_hash`: SHA-256 of `title \n tag \n trimmed body` +/// (UTF-8), where the body runs from the end of the heading to the next +/// `\n### ` or the end of the file. +String hashRuleContent(String title, String rawTag, String body) => + sha256.convert(utf8.encode('$title\n$rawTag\n${body.trim()}')).toString(); + +/// Rules in [markdown] that carry an ID, keyed by ID. +Map parseRules(String markdown) { + final rules = {}; + for (final m in _heading.allMatches(markdown)) { + final id = m.group(1)?.trim(); + if (id == null) continue; + final next = markdown.indexOf('\n### ', m.end); + final body = next == -1 + ? markdown.substring(m.end) + : markdown.substring(m.end, next); + rules[id] = StandardRule( + id, + m.group(2)!.trim(), + m.group(3)!.trim(), + body.trim(), + ); + } + return rules; +} + +/// A pinned rule as recorded in `standards.lock.json`. +class PinnedRule { + PinnedRule(this.id, Map json) + : file = json['file'] as String, + title = json['title'] as String, + contentHash = json['content_hash'] as String, + lints = [for (final l in json['lints'] as List) l as String]; + + final String id; + final String file; + final String title; + final String contentHash; + final List lints; +} + +/// Compares [pinned] with the current rule [actual] (null when the catalogue +/// no longer has it). Returns the problem, or null when the pin still holds. +String? comparePin( + PinnedRule pinned, { + String? actualHash, + String? actualTitle, +}) { + final lints = pinned.lints.join(', '); + if (actualHash == null) { + return '${pinned.id}: not found in the catalogue. Re-review $lints.'; + } + if (actualHash != pinned.contentHash) { + return '${pinned.id}: the standard changed (lock ${pinned.contentHash}, ' + 'catalogue $actualHash). Re-review $lints, then update ' + 'standards.lock.json.'; + } + if (actualTitle != pinned.title) { + return '${pinned.id}: title differs ("${pinned.title}" vs ' + '"$actualTitle").'; + } + return null; +} diff --git a/plugin/hedgehoglab_lints/tool/verify_standards_pin.dart b/plugin/hedgehoglab_lints/tool/verify_standards_pin.dart new file mode 100644 index 0000000..9aca789 --- /dev/null +++ b/plugin/hedgehoglab_lints/tool/verify_standards_pin.dart @@ -0,0 +1,142 @@ +// Verifies that every standards lint is still pinned to the standard it was +// written against. Exit 0: all pins hold. Exit 1: a standard changed (or went +// missing), so the lint must be re-reviewed. Exit 2: usage or fetch error. +// +// dart run tool/verify_standards_pin.dart # fetch via gh +// dart run tool/verify_standards_pin.dart --standards-dir DIR # a checkout +// dart run tool/verify_standards_pin.dart --provenance FILE # Quilltrail's +// # provenance.json +// +// The default mode fetches `standards/` at the pinned commit with the +// GitHub CLI (`gh`), which needs read access to the catalogue repository. +import 'dart:convert'; +import 'dart:io'; + +import 'standards_pin.dart'; + +Future main(List args) async { + var lockPath = 'standards.lock.json'; + String? standardsDir; + String? provenancePath; + for (var i = 0; i < args.length; i++) { + switch (args[i]) { + case '--lock': + lockPath = _value(args, ++i); + case '--standards-dir': + standardsDir = _value(args, ++i); + case '--provenance': + provenancePath = _value(args, ++i); + default: + _usage('Unknown argument ${args[i]}'); + } + } + if (standardsDir != null && provenancePath != null) { + _usage('Use either --standards-dir or --provenance, not both.'); + } + + final lock = jsonDecode(File(lockPath).readAsStringSync()) as Map; + final catalog = lock['catalog'] as Map; + final pinned = [ + for (final e in (lock['rules'] as Map).entries) + PinnedRule(e.key as String, (e.value as Map).cast()), + ]; + + final problems = []; + if (provenancePath != null) { + _checkProvenance(provenancePath, catalog, pinned, problems); + } else { + final files = >{}; + for (final rule in pinned) { + final rules = files[rule.file] ??= parseRules( + standardsDir != null + ? _readLocal(standardsDir, rule.file) + : _fetch( + catalog['repository'] as String, + catalog['commit'] as String, + rule.file, + ), + ); + final actual = rules[rule.id]; + final problem = comparePin( + rule, + actualHash: actual?.contentHash, + actualTitle: actual?.title, + ); + if (problem != null) problems.add(problem); + } + } + + if (problems.isEmpty) return; + stderr.writeln(problems.take(10).join('\n')); + exit(1); +} + +void _checkProvenance( + String path, + Map catalog, + List pinned, + List problems, +) { + final provenance = jsonDecode(File(path).readAsStringSync()) as Map; + final baseline = provenance['accepted_baseline'] as Map?; + if (baseline != null && baseline['commit'] != catalog['commit']) { + problems.add( + 'Catalogue commit differs: lock ${catalog['commit']}, provenance ' + '${baseline['commit']}.', + ); + } + if (provenance['policy_version'] != catalog['policy_version']) { + problems.add( + 'Policy version differs: lock ${catalog['policy_version']}, provenance ' + '${provenance['policy_version']}.', + ); + } + final byId = { + for (final r in provenance['rules'] as List) (r as Map)['id']: r, + }; + for (final rule in pinned) { + final actual = byId[rule.id]; + final problem = comparePin( + rule, + actualHash: actual?['content_hash'] as String?, + actualTitle: actual?['title'] as String?, + ); + if (problem != null) problems.add(problem); + } +} + +String _readLocal(String dir, String file) { + final f = File('$dir/$file'); + if (!f.existsSync()) _fail('${f.path} not found.'); + return f.readAsStringSync(); +} + +String _fetch(String repository, String commit, String file) { + final result = Process.runSync('gh', [ + 'api', + '-H', + 'Accept: application/vnd.github.raw', + 'repos/$repository/contents/standards/$file?ref=$commit', + ]); + if (result.exitCode != 0) { + _fail( + 'Could not fetch standards/$file at $commit from $repository with gh ' + '(${result.stderr.toString().trim()}). Use --standards-dir or ' + '--provenance for an offline check.', + ); + } + return result.stdout as String; +} + +String _value(List args, int i) => + i < args.length ? args[i] : _usage('${args[i - 1]} needs a value.'); + +Never _usage(String message) => _fail( + '$message\nUsage: verify_standards_pin.dart [--lock FILE] ' + '[--standards-dir DIR | --provenance FILE]', +); + +Never _fail(String message) { + stderr.writeln(message); + exit(2); +} From 9220efd72065b45f6fdb3dda71b966d3371e4c04 Mon Sep 17 00:00:00 2001 From: Owain Brown Date: Fri, 9 Oct 2026 16:46:05 +0100 Subject: [PATCH 07/11] Make the standards lock test format identically under both language versions Co-Authored-By: Claude Sonnet 5.5 --- plugin/hedgehoglab_lints/test/standards_lock_test.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugin/hedgehoglab_lints/test/standards_lock_test.dart b/plugin/hedgehoglab_lints/test/standards_lock_test.dart index d62a1a0..1a5b8b4 100644 --- a/plugin/hedgehoglab_lints/test/standards_lock_test.dart +++ b/plugin/hedgehoglab_lints/test/standards_lock_test.dart @@ -11,6 +11,8 @@ import '../tool/standards_pin.dart'; /// Offline checks that `standards.lock.json` and the registered rules agree. /// `tool/verify_standards_pin.dart` checks the lock against the catalogue. +final idPrefix = RegExp(r'^([A-Z][A-Z0-9-]*-[0-9]{3}):'); + void main() { final lock = jsonDecode(File('standards.lock.json').readAsStringSync()) as Map; @@ -40,9 +42,8 @@ void main() { test('every standards rule class ID is in the lock, naming that lint', () { for (final code in standardsRules) { - final id = RegExp( - r'^([A-Z][A-Z0-9-]*-[0-9]{3}):', - ).firstMatch(code.problemMessage)?.group(1); + final match = idPrefix.firstMatch(code.problemMessage); + final id = match?.group(1); expect(id, isNotNull, reason: '${code.lowerCaseName} has no ID'); expect(pinned, contains(id)); expect(pinned[id]!.lints, contains(code.lowerCaseName)); From 1051317a1abcc10f534d9c365ff08978b0b090ce Mon Sep 17 00:00:00 2001 From: Owain Brown Date: Fri, 9 Oct 2026 19:59:04 +0100 Subject: [PATCH 08/11] Analyse only the package's own lib/, not build output or nested packages shouldAnalyse used RuleContext.isInLibDir, which accepts any path with a lib segment, so a Swift Package Manager checkout under build/ was reported. Resolve against the package root and skip build/ and .dart_tool/ (including a package nested inside another's output). Read the defining unit: currentUnit is null while processors register, so the generated-file skip never applied. Co-Authored-By: Claude Sonnet 5.5 --- plugin/hedgehoglab_lints/README.md | 2 + plugin/hedgehoglab_lints/lib/src/support.dart | 64 +++++++- plugin/hedgehoglab_lints/pubspec.yaml | 1 + plugin/hedgehoglab_lints/test/scope_test.dart | 152 ++++++++++++++++++ 4 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 plugin/hedgehoglab_lints/test/scope_test.dart diff --git a/plugin/hedgehoglab_lints/README.md b/plugin/hedgehoglab_lints/README.md index aa0c12d..7eebc36 100644 --- a/plugin/hedgehoglab_lints/README.md +++ b/plugin/hedgehoglab_lints/README.md @@ -8,6 +8,8 @@ It is built on the `analysis_server_plugin` API (Dart 3.10 / Flutter 3.38 and la All rules are warnings, are enabled as soon as the plugin is, and apply to hand-written files under `lib/` (generated `*.g.dart` and `*.freezed.dart` files are skipped). +"Under `lib/`" means the analysed package's own `lib/`, resolved against its package root. Nothing else is reported: not `test/`, not a nested `example/lib/`, not anything under a `build/` or `.dart_tool/` directory, and not another package's `lib/` that happens to sit inside one (a Swift Package Manager checkout at `build/ios/SourcePackages//lib/` is the case that bit a trial adoption). This holds whether or not the project excludes `build/**` in its own options. + Standards IDs (`STATE-002` and so on) are the rule IDs of the Quilltrail standards catalogue, at the catalogue revision recorded in [`standards.lock.json`](standards.lock.json) (repository, commit, tag and policy version). Each standards lint is also pinned to the rule's `content_hash`, computed the way Quilltrail computes it (SHA-256 of the rule's title, tag and trimmed body). When a standard is edited its hash changes, `tool/verify_standards_pin.dart` fails, and the lint gets re-reviewed against the new text before the lock is updated. See "Standards pin" below. | Rule | Standard | Reports | diff --git a/plugin/hedgehoglab_lints/lib/src/support.dart b/plugin/hedgehoglab_lints/lib/src/support.dart index 6a298bb..259b2a7 100644 --- a/plugin/hedgehoglab_lints/lib/src/support.dart +++ b/plugin/hedgehoglab_lints/lib/src/support.dart @@ -6,17 +6,71 @@ import 'package:analyzer/dart/ast/token.dart'; import 'package:analyzer/dart/ast/visitor.dart'; import 'package:analyzer/dart/element/type.dart'; import 'package:analyzer/source/line_info.dart'; +import 'package:path/path.dart' as p; /// Files produced by code generation. Rules skip them: the author cannot act -/// on a diagnostic there. +/// on a diagnostic there. Reads the defining unit because `currentUnit` is +/// `null` while node processors are being registered. bool isGeneratedFile(RuleContext context) { - final path = context.currentUnit?.file.path ?? ''; + final path = context.definingUnit.file.path; return path.endsWith('.g.dart') || path.endsWith('.freezed.dart'); } -/// Whether the rule should look at this file at all (`lib/`, hand-written). -bool shouldAnalyse(RuleContext context) => - context.isInLibDir && !isGeneratedFile(context); +/// Whether the rule should look at this file at all: hand-written, under the +/// analysed package's own `lib/`. +/// +/// `RuleContext.isInLibDir` is not enough. It accepts any path with a `lib` +/// segment, so a Swift Package Manager checkout such as +/// `build/ios/SourcePackages/firebase_analytics-12.6.0/lib/` was analysed +/// whenever `build/` was not excluded. Here the path is resolved against the +/// package root, so only `/lib/**` qualifies, and anything under a +/// `build/` or `.dart_tool/` directory (including one that holds its own +/// `lib/`) is skipped. +bool shouldAnalyse(RuleContext context) { + if (isGeneratedFile(context)) return false; + final file = context.definingUnit.file; + final root = context.package?.root.path; + if (root == null) return false; + final pathContext = file.provider.pathContext; + return isOwnLibFile(file.path, root, pathContext) && + !isBuildOutput( + root, + pathContext, + (dir) => + file.provider.getFile(pathContext.join(dir, 'pubspec.yaml')).exists, + ); +} + +/// Whether the package at [root] lives inside another package's `build/` or +/// `.dart_tool/` directory, which is where checkouts such as Swift Package +/// Manager sources land. Such a package has a `pubspec.yaml` of its own, so +/// the analyzer treats it as the package and [isOwnLibFile] alone would accept +/// its `lib/`. [hasPubspec] says whether a directory holds a `pubspec.yaml`. +bool isBuildOutput( + String root, + p.Context pathContext, + bool Function(String directory) hasPubspec, +) { + var dir = root; + while (true) { + final parent = pathContext.dirname(dir); + if (parent == dir) return false; + final name = pathContext.basename(dir); + if ((name == 'build' || name == '.dart_tool') && hasPubspec(parent)) { + return true; + } + dir = parent; + } +} + +/// Whether [path] is a hand-written source under `<[root]>/lib/` and not under +/// a `build/` or `.dart_tool/` directory at any depth below the root. +bool isOwnLibFile(String path, String root, p.Context pathContext) { + if (!pathContext.isWithin(root, path)) return false; + final segments = pathContext.split(pathContext.relative(path, from: root)); + if (segments.first != 'lib') return false; + return !segments.any((s) => s == 'build' || s == '.dart_tool'); +} /// The last identifier of an annotation name: `riverpod` for `@riverpod`, /// `@r.riverpod` and `@Riverpod(keepAlive: true)` (as `Riverpod`). diff --git a/plugin/hedgehoglab_lints/pubspec.yaml b/plugin/hedgehoglab_lints/pubspec.yaml index 8531287..10dc18a 100644 --- a/plugin/hedgehoglab_lints/pubspec.yaml +++ b/plugin/hedgehoglab_lints/pubspec.yaml @@ -9,6 +9,7 @@ environment: dependencies: analysis_server_plugin: ^0.3.24 analyzer: ^14.5.0 + path: ^1.9.0 dev_dependencies: crypto: ^3.0.0 diff --git a/plugin/hedgehoglab_lints/test/scope_test.dart b/plugin/hedgehoglab_lints/test/scope_test.dart new file mode 100644 index 0000000..49e5417 --- /dev/null +++ b/plugin/hedgehoglab_lints/test/scope_test.dart @@ -0,0 +1,152 @@ +import 'package:analyzer_testing/analysis_rule/analysis_rule.dart'; +import 'package:hedgehoglab_lints/src/rules/max_parameters.dart'; +import 'package:hedgehoglab_lints/src/support.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart' as t; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +void main() { + t.group('isOwnLibFile', () { + final posix = p.posix; + const root = '/work/app'; + + t.test('accepts files under the package lib', () { + t.expect(isOwnLibFile('/work/app/lib/a.dart', root, posix), t.isTrue); + t.expect(isOwnLibFile('/work/app/lib/src/a.dart', root, posix), t.isTrue); + }); + + t.test('rejects test, bin and root-level files', () { + t.expect(isOwnLibFile('/work/app/test/a.dart', root, posix), t.isFalse); + t.expect(isOwnLibFile('/work/app/bin/a.dart', root, posix), t.isFalse); + t.expect(isOwnLibFile('/work/app/a.dart', root, posix), t.isFalse); + }); + + t.test('rejects a lib directory nested elsewhere in the package', () { + t.expect( + isOwnLibFile('/work/app/example/lib/a.dart', root, posix), + t.isFalse, + ); + t.expect( + isOwnLibFile( + '/work/app/build/ios/SourcePackages/x-1.0.0/lib/a.dart', + root, + posix, + ), + t.isFalse, + ); + }); + + t.test('rejects build and .dart_tool below lib', () { + t.expect( + isOwnLibFile('/work/app/lib/build/a.dart', root, posix), + t.isFalse, + ); + t.expect( + isOwnLibFile('/work/app/lib/.dart_tool/a.dart', root, posix), + t.isFalse, + ); + }); + + t.test('a build directory above the root does not matter', () { + t.expect( + isOwnLibFile('/build/app/lib/a.dart', '/build/app', posix), + t.isTrue, + ); + }); + + t.group('isBuildOutput', () { + bool Function(String) pubspecIn(Set dirs) => dirs.contains; + + t.test('a package nested in build/ of another package', () { + t.expect( + isBuildOutput( + '/work/app/build/ios/SourcePackages/x-1.0.0', + posix, + pubspecIn({'/work/app'}), + ), + t.isTrue, + ); + }); + + t.test('a package nested in .dart_tool/ of another package', () { + t.expect( + isBuildOutput( + '/work/app/.dart_tool/x', + posix, + pubspecIn({'/work/app'}), + ), + t.isTrue, + ); + }); + + t.test('a project that merely lives under a build directory', () { + t.expect( + isBuildOutput('/build/app', posix, pubspecIn({'/build/app'})), + t.isFalse, + ); + t.expect( + isBuildOutput('/work/build/app', posix, pubspecIn({})), + t.isFalse, + ); + }); + + t.test('an ordinary package', () { + t.expect( + isBuildOutput('/work/app', posix, pubspecIn({'/work/app'})), + t.isFalse, + ); + }); + }); + + t.test('rejects files outside the root', () { + t.expect(isOwnLibFile('/other/lib/a.dart', root, posix), t.isFalse); + t.expect(isOwnLibFile('/work/app2/lib/a.dart', root, posix), t.isFalse); + }); + }); + + defineReflectiveSuite(() { + defineReflectiveTests(ScopeTest); + }); +} + +const _tooManyParameters = 'void f(int a, int b, int c, int d, int e) {}\n'; + +/// The rule must report in the package's own `lib/` and nowhere else. The +/// nested cases reproduce a Swift Package Manager checkout under `build/`, +/// which holds a complete `lib/` of someone else's package. +@reflectiveTest +class ScopeTest extends AnalysisRuleTest { + @override + void setUp() { + rule = MaxParameters(); + super.setUp(); + } + + String get _spm => + '$testPackageRootPath/build/ios/SourcePackages/firebase_analytics-12.6.0'; + + Future test_ownLibIsAnalysed() async { + final path = '$testPackageLibPath/own.dart'; + newFile(path, _tooManyParameters); + await assertDiagnosticsInFile(path, [lint(5, 1)]); + } + + Future test_nestedBuildLibIsSkipped() async { + final path = '$_spm/lib/src/nested.dart'; + newFile(path, _tooManyParameters); + await assertNoDiagnosticsInFile(path); + } + + Future test_nestedBuildLibWithOwnPubspecIsSkipped() async { + newFile('$_spm/pubspec.yaml', 'name: firebase_analytics\n'); + final path = '$_spm/lib/src/nested.dart'; + newFile(path, _tooManyParameters); + await assertNoDiagnosticsInFile(path); + } + + Future test_generatedFilesAreSkipped() async { + final path = '$testPackageLibPath/model.g.dart'; + newFile(path, _tooManyParameters); + await assertNoDiagnosticsInFile(path); + } +} From 7da9aa0d51a61f1fd3d0295a3f4810fea295b6ee Mon Sep 17 00:00:00 2001 From: Owain Brown Date: Fri, 9 Oct 2026 19:59:19 +0100 Subject: [PATCH 09/11] Document the seven analyzer excludes Flutter 3.47 requires in the project file flutter pub get rewrites analysis_options.yaml to add them unless the project's own file lists them; it does not follow include:. Show the exact recommended project file. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index acae8c1..32c6b92 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,31 @@ dev_dependencies: ref: 6.0.0 ``` -Then include the versioned file in `analysis_options.yaml`: +Then use this as the project's `analysis_options.yaml`: ```yaml include: package:hedgehoglab_flutter_analysis/analysis_options.6.0.0.yaml + +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** + +plugins: + hedgehoglab_lints: + git: + url: https://github.com/hedgehoglab-engineering/flutter-analysis + path: plugin/hedgehoglab_lints + ref: hedgehoglab_lints-v0.1.0 ``` +The seven excludes repeat what 6.0.0 already excludes, on purpose. Flutter 3.47's `flutter pub get` checks the project's own `analysis_options.yaml` for exactly these seven entries (it does not follow `include:`), and rewrites the file to add any that are missing (see `analysis_options_migration.dart` in `flutter_tools`). Keeping them in the project file stops the rewrite. Drop the `plugins:` block if the project is not adopting the [analyzer plugin](#analyzer-plugin). + Run `flutter pub get`, then `flutter analyze`. Expect new findings on the first run: 6.0.0 turns on `strict-casts`, `strict-inference` and `strict-raw-types`. Requires Dart 3.13 or later. The package is not published to pub.dev. @@ -54,7 +73,7 @@ Each release that changes rules adds a new versioned file, `lib/analysis_options 1. Add the dependency and replace the copied `linter:` block with the include line. 2. Keep only project-specific `analyzer:` entries (extra excludes or severity changes) and `linter.rules` overrides. Anything that duplicates 6.0.0 can go. -3. Run `flutter analyze` and compare with the previous result. The usual differences are the strict modes and the generated-code excludes. +3. Make sure the project file lists the seven excludes shown under "Adopting it". Run `flutter analyze` and compare with the previous result. The usual differences are the strict modes and the generated-code excludes. ## Suppressing lints From 43bbd12afaeeb0ba9a02df3d9202038ef300da5d Mon Sep 17 00:00:00 2001 From: Owain Brown Date: Fri, 9 Oct 2026 19:59:23 +0100 Subject: [PATCH 10/11] Recommend dart analyze over flutter analyze, and say the plugin tag is created on release Co-Authored-By: Claude Sonnet 5.5 --- README.md | 10 ++++++---- plugin/hedgehoglab_lints/README.md | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 32c6b92..9134f87 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,11 @@ plugins: ref: hedgehoglab_lints-v0.1.0 ``` -The seven excludes repeat what 6.0.0 already excludes, on purpose. Flutter 3.47's `flutter pub get` checks the project's own `analysis_options.yaml` for exactly these seven entries (it does not follow `include:`), and rewrites the file to add any that are missing (see `analysis_options_migration.dart` in `flutter_tools`). Keeping them in the project file stops the rewrite. Drop the `plugins:` block if the project is not adopting the [analyzer plugin](#analyzer-plugin). +The seven excludes repeat what 6.0.0 already excludes, on purpose. Flutter 3.47's `flutter pub get` checks the project's own `analysis_options.yaml` for exactly these seven entries (it does not follow `include:`), and rewrites the file to add any that are missing (see `analysis_options_migration.dart` in `flutter_tools`). Keeping them in the project file stops the rewrite. Drop the `plugins:` block if the project is not adopting the [analyzer plugin](#analyzer-plugin). Its `hedgehoglab_lints-v0.1.0` tag is created on release, so the `git:` ref resolves only once the plugin PR has merged and been tagged. -Run `flutter pub get`, then `flutter analyze`. Expect new findings on the first run: 6.0.0 turns on `strict-casts`, `strict-inference` and `strict-raw-types`. +Run `flutter pub get`, then `dart analyze --fatal-infos`. Expect new findings on the first run: 6.0.0 turns on `strict-casts`, `strict-inference` and `strict-raw-types`. + +Use `dart analyze`, not `flutter analyze`, for CI and agent checks. `flutter analyze` on 3.47 returns before analyzer plugins have reported and drops their diagnostics intermittently (see the plugin README); `dart analyze` reports the same lint set reliably. Requires Dart 3.13 or later. The package is not published to pub.dev. @@ -73,7 +75,7 @@ Each release that changes rules adds a new versioned file, `lib/analysis_options 1. Add the dependency and replace the copied `linter:` block with the include line. 2. Keep only project-specific `analyzer:` entries (extra excludes or severity changes) and `linter.rules` overrides. Anything that duplicates 6.0.0 can go. -3. Make sure the project file lists the seven excludes shown under "Adopting it". Run `flutter analyze` and compare with the previous result. The usual differences are the strict modes and the generated-code excludes. +3. Make sure the project file lists the seven excludes shown under "Adopting it". Run `dart analyze --fatal-infos` and compare with the previous result. The usual differences are the strict modes and the generated-code excludes. ## Suppressing lints @@ -102,7 +104,7 @@ linter: ## Analyzer plugin -`plugin/hedgehoglab_lints/` is a separate package: an analyzer plugin that turns code-metric limits and the mechanically checkable Flutter standards into warnings from `dart analyze`. See its [README](plugin/hedgehoglab_lints/README.md). It is independent of the lint set above. +`plugin/hedgehoglab_lints/` is a separate package: an analyzer plugin that turns code-metric limits and the mechanically checkable Flutter standards into warnings from `dart analyze` (not `flutter analyze`, which drops them intermittently on 3.47). See its [README](plugin/hedgehoglab_lints/README.md). It is independent of the lint set above. ## Developing this package diff --git a/plugin/hedgehoglab_lints/README.md b/plugin/hedgehoglab_lints/README.md index 7eebc36..cda55f2 100644 --- a/plugin/hedgehoglab_lints/README.md +++ b/plugin/hedgehoglab_lints/README.md @@ -47,7 +47,7 @@ plugins: ref: hedgehoglab_lints-v0.1.0 ``` -For local development use `path: /absolute/or/relative/path/to/hedgehoglab_lints` instead of `git:` (see `example/analysis_options.yaml`). Restart the Dart analysis server after changing the `plugins:` section. The first run resolves the plugin's dependencies and takes about 20 seconds; later runs are fast. +The `hedgehoglab_lints-v0.1.0` tag is created on release, once the PR that adds the plugin has merged; until then the `git:` form cannot resolve and a project should use the `path:` form below. For local development use `path: /absolute/or/relative/path/to/hedgehoglab_lints` instead of `git:` (see `example/analysis_options.yaml`). Restart the Dart analysis server after changing the `plugins:` section. The first run resolves the plugin's dependencies and takes about 20 seconds; later runs are fast. Rules are warning rules, so they are on by default. To switch one off in a project: From e5478f335f8d78e69590614f985a92fa8588f358 Mon Sep 17 00:00:00 2001 From: Owain Brown Date: Fri, 9 Oct 2026 19:59:36 +0100 Subject: [PATCH 11/11] Note that riverpod_lint leaves pubspec.yaml once listed under plugins Co-Authored-By: Claude Sonnet 5.5 --- plugin/hedgehoglab_lints/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/hedgehoglab_lints/README.md b/plugin/hedgehoglab_lints/README.md index cda55f2..741223c 100644 --- a/plugin/hedgehoglab_lints/README.md +++ b/plugin/hedgehoglab_lints/README.md @@ -78,7 +78,7 @@ plugins: ref: hedgehoglab_lints-v0.1.0 ``` -Both loaded together in a test project. The `riverpod_lint` entry in `dev_dependencies` can be dropped once it is listed here. +Both loaded together in a test project. Once `riverpod_lint` is listed under `plugins:`, remove it from `pubspec.yaml` (`dependencies` or `dev_dependencies`, wherever it was), as the bnf-flutter trial adoption did. ## Versioning