Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions .github/workflows/plugin.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: plugin

on:
pull_request:
paths:
- "plugin/**"
- ".github/workflows/plugin.yml"

jobs:
hedgehoglab_lints:
runs-on: ubuntu-latest
env:
STANDARDS_READ_TOKEN: ${{ secrets.STANDARDS_READ_TOKEN }}
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

# 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
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
# 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; }
31 changes: 28 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,34 @@ 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
```

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`.
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 `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.

Expand Down Expand Up @@ -54,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. 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

Expand All @@ -81,6 +102,10 @@ linter:
lines_longer_than_80_chars: false
```

## 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` (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

CI formats and analyses the package and the `example` project with `--fatal-infos`, and fails on any undefined, removed or deprecated lint. Before adding a rule, check its status on [dart.dev](https://dart.dev/tools/linter-rules/all). Run the same checks locally:
Expand Down
9 changes: 9 additions & 0 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -1 +1,10 @@
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: lib/analysis_options.yaml
9 changes: 9 additions & 0 deletions example/analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -1 +1,10 @@
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:hedgehoglab_flutter_analysis/analysis_options.6.0.0.yaml
118 changes: 118 additions & 0 deletions plugin/hedgehoglab_lints/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# 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 `hedgehoglab_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).

"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/<pkg>/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 |
|---|---|---|
| `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<void>` or `FutureOr<void>`. 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 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`; 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_<rule>` (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

`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
```

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:

```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. 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

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/<name>.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

```sh
dart pub get
dart format .
dart analyze
dart test
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
```
9 changes: 9 additions & 0 deletions plugin/hedgehoglab_lints/analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# 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
exclude:
# Deliberately violating code, analysed on its own by the CI example job.
- example/**
5 changes: 5 additions & 0 deletions plugin/hedgehoglab_lints/example/analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# 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: ..
21 changes: 21 additions & 0 deletions plugin/hedgehoglab_lints/example/lib/clean.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import 'stubs.dart';

class _$CartNotifier {}

@riverpod
class CartNotifier extends _$CartNotifier {
int build() => 0;

Future<void> 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;
6 changes: 6 additions & 0 deletions plugin/hedgehoglab_lints/example/lib/stubs.dart
Original file line number Diff line number Diff line change
@@ -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();
Loading
Loading