From 865173639a78689a49347c44ef856b60267d9374 Mon Sep 17 00:00:00 2001 From: 1morr Date: Tue, 29 Sep 2026 11:48:56 +0800 Subject: [PATCH 1/5] feat(app): add fmp_lints with a wiring sentinel flutter analyze does not show analyzer plugin diagnostics (#187999), so CI runs dart analyze and a sentinel proves every enabled rule fires. --- .github/workflows/ci.yml | 21 +- app/AGENTS.md | 57 +++- app/analysis_options.yaml | 31 ++ app/packages/fmp_lints/analysis_options.yaml | 6 + app/packages/fmp_lints/lib/main.dart | 51 ++++ app/packages/fmp_lints/lib/src/ast_names.dart | 11 + .../fmp_lints/lib/src/package_path.dart | 53 ++++ .../lib/src/rules/design_tokens.dart | 139 +++++++++ .../lib/src/rules/http_client_owner.dart | 54 ++++ .../lib/src/rules/ignore_reason.dart | 84 ++++++ .../lib/src/rules/layer_imports.dart | 180 ++++++++++++ .../fmp_lints/lib/src/rules/log_facade.dart | 120 ++++++++ .../lib/src/rules/material_import.dart | 65 +++++ .../lib/src/rules/no_empty_catch.dart | 44 +++ .../lib/src/rules/no_for_testing.dart | 87 ++++++ .../lib/src/rules/platform_checks.dart | 97 +++++++ .../lib/src/rules/source_id_literal.dart | 63 ++++ .../fmp_lints/lib/src/rules/test_waits.dart | 51 ++++ .../fmp_lints/lib/src/rules/toast_entry.dart | 77 +++++ .../fmp_lints/lib/src/rules/url_literal.dart | 63 ++++ app/packages/fmp_lints/pubspec.yaml | 26 ++ .../fmp_lints/test/package_path_test.dart | 117 ++++++++ app/packages/fmp_lints/test/plugin_test.dart | 71 +++++ .../test/rules/design_tokens_test.dart | 165 +++++++++++ .../test/rules/http_client_owner_test.dart | 61 ++++ .../test/rules/ignore_reason_test.dart | 56 ++++ .../test/rules/layer_imports_test.dart | 140 +++++++++ .../fmp_lints/test/rules/log_facade_test.dart | 97 +++++++ .../test/rules/material_import_test.dart | 41 +++ .../test/rules/no_empty_catch_test.dart | 67 +++++ .../test/rules/no_for_testing_test.dart | 51 ++++ .../test/rules/platform_checks_test.dart | 71 +++++ .../test/rules/source_id_literal_test.dart | 46 +++ .../fmp_lints/test/rules/test_waits_test.dart | 42 +++ .../test/rules/toast_entry_test.dart | 87 ++++++ .../test/rules/url_literal_test.dart | 45 +++ .../test/support/rule_test_base.dart | 60 ++++ app/pubspec.lock | 268 +++++++++++++++++- app/pubspec.yaml | 6 +- app/test/identity/windows_identity_test.dart | 17 +- .../material_import_static_rule_test.dart | 59 ---- app/tool/lint_sentinel.dart | 147 ++++++++++ 42 files changed, 3016 insertions(+), 78 deletions(-) create mode 100644 app/packages/fmp_lints/analysis_options.yaml create mode 100644 app/packages/fmp_lints/lib/main.dart create mode 100644 app/packages/fmp_lints/lib/src/ast_names.dart create mode 100644 app/packages/fmp_lints/lib/src/package_path.dart create mode 100644 app/packages/fmp_lints/lib/src/rules/design_tokens.dart create mode 100644 app/packages/fmp_lints/lib/src/rules/http_client_owner.dart create mode 100644 app/packages/fmp_lints/lib/src/rules/ignore_reason.dart create mode 100644 app/packages/fmp_lints/lib/src/rules/layer_imports.dart create mode 100644 app/packages/fmp_lints/lib/src/rules/log_facade.dart create mode 100644 app/packages/fmp_lints/lib/src/rules/material_import.dart create mode 100644 app/packages/fmp_lints/lib/src/rules/no_empty_catch.dart create mode 100644 app/packages/fmp_lints/lib/src/rules/no_for_testing.dart create mode 100644 app/packages/fmp_lints/lib/src/rules/platform_checks.dart create mode 100644 app/packages/fmp_lints/lib/src/rules/source_id_literal.dart create mode 100644 app/packages/fmp_lints/lib/src/rules/test_waits.dart create mode 100644 app/packages/fmp_lints/lib/src/rules/toast_entry.dart create mode 100644 app/packages/fmp_lints/lib/src/rules/url_literal.dart create mode 100644 app/packages/fmp_lints/pubspec.yaml create mode 100644 app/packages/fmp_lints/test/package_path_test.dart create mode 100644 app/packages/fmp_lints/test/plugin_test.dart create mode 100644 app/packages/fmp_lints/test/rules/design_tokens_test.dart create mode 100644 app/packages/fmp_lints/test/rules/http_client_owner_test.dart create mode 100644 app/packages/fmp_lints/test/rules/ignore_reason_test.dart create mode 100644 app/packages/fmp_lints/test/rules/layer_imports_test.dart create mode 100644 app/packages/fmp_lints/test/rules/log_facade_test.dart create mode 100644 app/packages/fmp_lints/test/rules/material_import_test.dart create mode 100644 app/packages/fmp_lints/test/rules/no_empty_catch_test.dart create mode 100644 app/packages/fmp_lints/test/rules/no_for_testing_test.dart create mode 100644 app/packages/fmp_lints/test/rules/platform_checks_test.dart create mode 100644 app/packages/fmp_lints/test/rules/source_id_literal_test.dart create mode 100644 app/packages/fmp_lints/test/rules/test_waits_test.dart create mode 100644 app/packages/fmp_lints/test/rules/toast_entry_test.dart create mode 100644 app/packages/fmp_lints/test/rules/url_literal_test.dart create mode 100644 app/packages/fmp_lints/test/support/rule_test_base.dart delete mode 100644 app/test/static_rules/material_import_static_rule_test.dart create mode 100644 app/tool/lint_sentinel.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5491d5e4..e3e13aed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,7 +203,8 @@ jobs: needs: changes if: needs.changes.outputs.app == 'true' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest - timeout-minutes: 15 + # 兩次 dart analyze(含第一次編譯插件)比 PR 2 多幾分鐘。 + timeout-minutes: 20 defaults: run: working-directory: app @@ -226,9 +227,27 @@ jobs: - name: Check formatting run: dart format --output=none --set-exit-if-changed . + # fmp_lints 與 riverpod_lint 是 analyzer 插件,只有 dart analyze 看得到 + # 它們的診斷(flutter/flutter#187999)。第一次會解析並編譯插件,要連 pub。 + - name: Plugin lints (dart analyze) + run: dart analyze --fatal-infos + + # 暫放違反每條 fmp 規則的檔案,斷言 dart analyze 失敗且報出每條規則名: + # 插件沒載入或規則沒開時,上一步會照樣綠。 + - name: Lint wiring sentinel + run: dart run tool/lint_sentinel.dart + + # 留著:Flutter 專屬的診斷只在這裡。它不顯示插件診斷,見上。 - name: Static analysis run: flutter analyze + # 第二次以 Windows 路徑重跑,驗證規則的允許清單不受分隔符影響。 + - name: Lint rule tests + working-directory: app/packages/fmp_lints + run: | + dart test + TEST_ANALYZER_WINDOWS_PATHS=true dart test + # 不加參數:dart_test.yaml 讓 live 預設跳過(ADR 0015 §決定 3)。 # 身分測試要 cmake,ubuntu runner 內建。 - name: Unit and widget tests diff --git a/app/AGENTS.md b/app/AGENTS.md index 4385bec4..183b0aa0 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -9,11 +9,14 @@ | 改動 | 最少要跑 | |---|---| -| 任何改動 | `dart format --output=none --set-exit-if-changed .`、`flutter analyze`、`flutter test` | -| 原生身分(`android/app/`、`windows/runner/`) | 上一列,加 `flutter build apk --flavor dev --debug`/`--flavor prod --debug` 與 `flutter build windows --flavor dev`/`--flavor prod` | - -- `flutter test` 不加參數:`live` 預設跳過(見「零聯網」)。CI 的 `app` job 跑的就是 - 上表第一列。 +| 任何改動 | `dart format --output=none --set-exit-if-changed .`、`dart analyze --fatal-infos`、`flutter analyze`、`flutter test` | +| `packages/fmp_lints/`、`analysis_options.yaml` 的 `plugins:` | 上一列,加 `packages/fmp_lints/` 內的 `dart test` 與 `dart run tool/lint_sentinel.dart` | +| 原生身分(`android/app/`、`windows/runner/`) | 第一列,加 `flutter build apk --flavor dev --debug`/`--flavor prod --debug` 與 `flutter build windows --flavor dev`/`--flavor prod` | + +- `flutter test` 不加參數:`live` 預設跳過(見「零聯網」)。CI 的 `app` job 跑上表前兩列; + `fmp_lints` 的測試另外以 `TEST_ANALYZER_WINDOWS_PATHS=true` 再跑一次(Windows 路徑)。 +- `flutter analyze` 看不到 analyzer 插件的診斷、照樣回 No issues(flutter/flutter#187999), + 所以兩個都要跑:插件規則看 `dart analyze`,Flutter 專屬的診斷看 `flutter analyze`。 - `test/identity/windows_identity_test.dart` 要 `cmake`:CI 的 ubuntu runner 內建; Windows 的 PATH 上沒有時,測試以 vswhere 找 Visual Studio 附的那一份。 - 不帶 `--flavor` 的 run/build 是 dev(`pubspec.yaml` 的 `default-flavor`)。 @@ -86,5 +89,45 @@ Flutter 3.47 起 Material 以獨立套件 `material_ui` 發佈,框架內的 `services.dart` 等非設計系統的函式庫仍從 `package:flutter/` 匯入。Cupertino 同理用 `cupertino_ui`,需要時再加依賴。 -閘門:`test/static_rules/material_import_static_rule_test.dart`(掃 `lib/` 與 -`test/`)。`fmp_lints`(M1 PR 3)接手後刪掉它。 +閘門:lint `fmp_material_import`(見「Lint」)。 + +## Lint + +`packages/fmp_lints/` 是自寫的 analyzer 插件(ADR 0015 §決定 2),在 +`analysis_options.yaml` 的 `plugins:` 逐條開啟。規則都是 warning,`dart analyze` 失敗。 + +| 規則 | 守什麼(只看 `lib/`,除非另外寫) | 允許清單在 | +|---|---|---| +| `fmp_layer_imports` | 相對路徑跳出 `app/` 或非 `package:`/`dart:` 的 URI(全 package);外部套件只准在擁有它的目錄;`lib/legacy_import/` 只被自己 import;`core/`、`domain/` 不 import `ui/`、`playback/`、`plugins/`、`data/`、`settings/`,`data/` 不 import `ui/` | `rules/layer_imports.dart` 的 `externalPackageOwners`、`platformPackages`、`forbiddenLayerImports`、`sealedDirectories` | +| `fmp_no_empty_catch` | catch 本體沒有陳述式(只有註解也算;全 package) | 無 | +| `fmp_log_facade` | `print`、`debugPrint`、沒以 `show` 排除 `log` 的 `dart:developer` import、`package:talker*` | `logFacadeDirectory`(`lib/core/logging/`) | +| `fmp_source_id_literal` | 字串整個等於官方插件 id(全 package) | `officialPluginIds`、`sourceIdAllowedDirectories`(`lib/legacy_import/`、`test/`) | +| `fmp_url_literal` | 含 `http://`/`https://` 的字串 | `endpointsFile`(`lib/core/endpoints.dart`) | +| `fmp_no_for_testing` | 名稱以 `ForTesting` 結尾(或就叫 `forTesting`)的方法、欄位、getter/setter、具名建構子、頂層函式與變數 | 無 | +| `fmp_http_client_owner` | 建立 `Dio`(任何建構子) | `networkDirectory`(`lib/core/network/`) | +| `fmp_test_waits` | `test/` 內直接呼叫 `pumpEventQueue` | `waitHelperFile`(`test/support/pump_until.dart`) | +| `fmp_ignore_reason` | 忽略 `fmp_` 規則的 `// ignore:`/`// ignore_for_file:` 沒在規則名後寫 ` — 理由` 或 ` - 理由`(全 package) | 無 | +| `fmp_platform_checks` | `Platform.isXxx`、`Platform.operatingSystem`、`defaultTargetPlatform`、`TargetPlatform` | `platformDirectory`(`lib/platform/`) | +| `fmp_toast_entry` | `SnackBar(`、`ScaffoldMessenger.of`/`.maybeOf`、`showSnackBar`、`clearSnackBars` | `toastDirectory`(`lib/ui/toast/`) | +| `fmp_design_tokens` | `lib/ui/` 內:`EdgeInsets`/`EdgeInsetsDirectional` 的參數、`SizedBox` 的 `width`/`height`/`dimension` 與傳給它的 `Size`、`BorderRadius.circular`/`.all`、`BorderRadiusDirectional`、`Radius.circular`/`.elliptical`、`fontSize:` 用 `0` 以外的數字字面值;`Color(…)`、`Color.fromARGB`/`fromRGBO`/`from` 帶數字字面值(`0` 也算);`Colors` | `uiDirectory`、`themeDirectory`(`lib/ui/theme/` 豁免) | +| `fmp_material_import` | import/export `package:flutter/material.dart`、`cupertino.dart`(全 package) | `frozenDesignLibraries` | + +允許清單都是 `packages/fmp_lints/lib/src/rules/` 裡的常數。閘門: + +- 每條規則在 `packages/fmp_lints/test/rules/<規則>_test.dart` 有報與不報的案例; + `test/plugin_test.dart` 斷言 `analysis_options.yaml` 開的正好是註冊的全部規則。 +- `tool/lint_sentinel.dart` 暫放違規檔跑 `dart analyze`,斷言每條開啟的規則都報出來。插件 + 沒載入或編譯失敗時 `dart analyze` 會照樣綠,只有它會紅。 + +幾件從設定看不出來的事: + +- 規則以名稱判斷(`Dio`、`SnackBar`、`Platform`),不查它來自哪個函式庫;同名的自訂型別 + 也會被報。 +- 忽略寫法:`// ignore: fmp_lints/fmp_url_literal — 理由`。理由接在名稱之後,analyzer 仍 + 照常忽略。 +- `fmp_lints` 釘 `analyzer` 13.3.0,不是 pub.dev 最新:它和 `flutter_test` 同一個 + workspace,`flutter_test` 釘的 `test_api` 讓 `analyzer_testing` 用不了 14.x。新 Flutter + 放寬後三個套件一起升(`.trellis/tasks/09-29-fmp-lints/research/notes.md` §1)。 +- `riverpod_lint` 也接在 `plugins:`;`missing_provider_scope` 暫時關掉,第一個加 + `ProviderScope` 的 PR 打開。 +- 新規則怎麼加:`.trellis/spec/app/lints/index.md`。 diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml index c7f49779..40045427 100644 --- a/app/analysis_options.yaml +++ b/app/analysis_options.yaml @@ -35,3 +35,34 @@ linter: # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options + +# analyzer 插件(app/AGENTS.md § Lint)。頂層 plugins:,不在 analyzer: 底下; +# 只能寫在 workspace 根,也就是這個檔案。改過這段要重啟 analysis server。 +# `flutter analyze` 看不到插件診斷(flutter/flutter#187999),要看就跑 +# `dart analyze --fatal-infos`。 +plugins: + # 自寫規則(ADR 0015 §決定 2)。lint rule 預設關閉,逐條開啟; + # packages/fmp_lints/test/plugin_test.dart 斷言這裡開的正好是註冊的全部規則。 + fmp_lints: + path: packages/fmp_lints + diagnostics: + fmp_layer_imports: true + fmp_no_empty_catch: true + fmp_log_facade: true + fmp_source_id_literal: true + fmp_url_literal: true + fmp_no_for_testing: true + fmp_http_client_owner: true + fmp_test_waits: true + fmp_ignore_reason: true + fmp_platform_checks: true + fmp_toast_entry: true + fmp_design_tokens: true + fmp_material_import: true + # 規則都是 warning,預設開啟。 + riverpod_lint: + version: ^3.1.9 + diagnostics: + # app/ 還沒有 Riverpod,main.dart 的 runApp 沒有 ProviderScope 是事實 + # 而不是遺漏。第一個加 ProviderScope 的 PR 刪掉這兩行。 + missing_provider_scope: false diff --git a/app/packages/fmp_lints/analysis_options.yaml b/app/packages/fmp_lints/analysis_options.yaml new file mode 100644 index 00000000..7c2121d3 --- /dev/null +++ b/app/packages/fmp_lints/analysis_options.yaml @@ -0,0 +1,6 @@ +include: ../../analysis_options.yaml + +linter: + rules: + # test_reflective_loader 以 `test_` 前綴的方法名找測試。 + non_constant_identifier_names: false diff --git a/app/packages/fmp_lints/lib/main.dart b/app/packages/fmp_lints/lib/main.dart new file mode 100644 index 00000000..2e272704 --- /dev/null +++ b/app/packages/fmp_lints/lib/main.dart @@ -0,0 +1,51 @@ +import 'package:analysis_server_plugin/plugin.dart'; +import 'package:analysis_server_plugin/registry.dart'; +import 'package:analyzer/analysis_rule/analysis_rule.dart'; + +import 'src/rules/design_tokens.dart'; +import 'src/rules/http_client_owner.dart'; +import 'src/rules/ignore_reason.dart'; +import 'src/rules/layer_imports.dart'; +import 'src/rules/log_facade.dart'; +import 'src/rules/material_import.dart'; +import 'src/rules/no_empty_catch.dart'; +import 'src/rules/no_for_testing.dart'; +import 'src/rules/platform_checks.dart'; +import 'src/rules/source_id_literal.dart'; +import 'src/rules/test_waits.dart'; +import 'src/rules/toast_entry.dart'; +import 'src/rules/url_literal.dart'; + +/// analysis server 以這個頂層變數載入插件(analysis_server_plugin 的 +/// `doc/writing_a_plugin.md`)。 +final plugin = FmpLintsPlugin(); + +/// 全部規則。都註冊成 lint rule:預設關閉,由 `app/analysis_options.yaml` +/// 的 `diagnostics:` 逐條開啟。 +List fmpRules() => [ + LayerImports(), + NoEmptyCatch(), + LogFacade(), + SourceIdLiteral(), + UrlLiteral(), + NoForTesting(), + HttpClientOwner(), + TestWaits(), + IgnoreReason(), + PlatformChecks(), + ToastEntry(), + DesignTokens(), + MaterialImport(), +]; + +class FmpLintsPlugin extends Plugin { + @override + String get name => 'fmp_lints'; + + @override + void register(PluginRegistry registry) { + for (final rule in fmpRules()) { + registry.registerLintRule(rule); + } + } +} diff --git a/app/packages/fmp_lints/lib/src/ast_names.dart b/app/packages/fmp_lints/lib/src/ast_names.dart new file mode 100644 index 00000000..01c74143 --- /dev/null +++ b/app/packages/fmp_lints/lib/src/ast_names.dart @@ -0,0 +1,11 @@ +import 'package:analyzer/dart/ast/ast.dart'; + +/// [expression] 是不是名為 [name] 的識別字,含帶 import 前綴的寫法 +/// (`ScaffoldMessenger`、`m.ScaffoldMessenger`)。 +bool isNamedReference(Expression? expression, String name) => + switch (expression) { + SimpleIdentifier(name: final actual) => actual == name, + PrefixedIdentifier(identifier: SimpleIdentifier(name: final actual)) => + actual == name, + _ => false, + }; diff --git a/app/packages/fmp_lints/lib/src/package_path.dart b/app/packages/fmp_lints/lib/src/package_path.dart new file mode 100644 index 00000000..8880f1bd --- /dev/null +++ b/app/packages/fmp_lints/lib/src/package_path.dart @@ -0,0 +1,53 @@ +import 'package:analyzer/analysis_rule/rule_context.dart'; +import 'package:path/path.dart' as p; + +/// 檔案在所屬 package 內的相對路徑,一律以 `/` 分隔,例如 `lib/ui/foo.dart`。 +/// +/// 規則的允許清單都寫成這種路徑;Windows 的 `\` 在這裡轉掉,規則本身不碰 +/// 分隔符。 +extension type const PackagePath(String value) { + /// 目前正在分析的檔案;不在任何 package 內時為 `null`。 + static PackagePath? of(RuleContext context) { + final file = context.currentUnit?.file; + final root = context.package?.root; + if (file == null || root == null) return null; + return relative(file.path, root.path, file.provider.pathContext); + } + + /// [filePath] 相對 [rootPath] 的位置;不在 [rootPath] 之下時為 `null`。 + static PackagePath? relative( + String filePath, + String rootPath, + p.Context pathContext, + ) { + if (!pathContext.isWithin(rootPath, filePath)) return null; + return PackagePath( + pathContext + .split(pathContext.relative(filePath, from: rootPath)) + .join('/'), + ); + } + + /// 是否在 [directory](例如 `lib/ui`,不帶結尾斜線)之內,含子目錄。 + bool isIn(String directory) => + value == directory || value.startsWith('$directory/'); + + /// 是否在 [directories] 任一個之內。 + bool isInAny(Iterable directories) => directories.any(isIn); + + bool get isInLib => isIn('lib'); + + bool get isInTest => isIn('test'); + + /// 從這個檔案以相對 URI [relativeUri] 指到的位置。 + /// + /// 跳出 package 根目錄(含 `/` 開頭的絕對路徑)時為 `null`。 + PackagePath? resolve(String relativeUri) { + if (relativeUri.startsWith('/')) return null; + final joined = p.posix.normalize( + p.posix.join(p.posix.dirname(value), relativeUri), + ); + if (joined == '..' || joined.startsWith('../')) return null; + return PackagePath(joined); + } +} diff --git a/app/packages/fmp_lints/lib/src/rules/design_tokens.dart b/app/packages/fmp_lints/lib/src/rules/design_tokens.dart new file mode 100644 index 00000000..30cba6d9 --- /dev/null +++ b/app/packages/fmp_lints/lib/src/rules/design_tokens.dart @@ -0,0 +1,139 @@ +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 '../package_path.dart'; + +/// 規則管的範圍:`lib/ui/`,但不含定義 token 的 [themeDirectory]。 +const uiDirectory = 'lib/ui'; +const themeDirectory = 'lib/ui/theme'; + +/// `SizedBox` 裡算尺寸的具名參數。 +const _sizedBoxDimensions = {'width', 'height', 'dimension'}; + +/// `fmp_design_tokens`(ADR 0024):[uiDirectory] 內([themeDirectory] +/// 除外),`EdgeInsets.*`/`EdgeInsetsDirectional.*` 的參數、`SizedBox` 的寬高 +/// 與傳給它的 `Size`、`BorderRadius.circular`/`.all`、 +/// `BorderRadiusDirectional.*`、`Radius.circular`/`.elliptical`、任何 +/// `fontSize:` 不得用 `0` 以外的數字字面值;`Color(…)`、`Color.fromARGB`/ +/// `fromRGBO`/`from` 不得有數字字面值(含 `0`);不得寫 `Colors.*`。以名稱判斷。 +class DesignTokens extends AnalysisRule { + static const LintCode code = LintCode( + 'fmp_design_tokens', + '{0} is a raw design value.', + correctionMessage: + 'Use AppTokens / AppLayout or the ColorScheme from ' + '$themeDirectory/.', + severity: DiagnosticSeverity.WARNING, + ); + + DesignTokens() + : super(name: 'fmp_design_tokens', description: 'UI uses design tokens.'); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + final visitor = _Visitor(this, context); + registry + ..addInstanceCreationExpression(this, visitor) + ..addNamedArgument(this, visitor) + ..addSimpleIdentifier(this, visitor); + } +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule, this.context); + + final AnalysisRule rule; + final RuleContext context; + + bool get _applies { + final path = PackagePath.of(context); + return path != null && path.isIn(uiDirectory) && !path.isIn(themeDirectory); + } + + @override + void visitInstanceCreationExpression(InstanceCreationExpression node) { + final type = node.constructorName.type.name.lexeme; + final constructor = node.constructorName.name?.name; + final arguments = node.argumentList.arguments; + final label = constructor == null ? type : '$type.$constructor'; + switch ((type, constructor)) { + case ('EdgeInsets' || 'EdgeInsetsDirectional', _): + case ('BorderRadius', 'circular' || 'all'): + case ('BorderRadiusDirectional', _): + case ('Radius', 'circular' || 'elliptical'): + // BorderRadius.only(topLeft: Radius.circular(8)) 這類巢狀寫法由 + // 內層的 Radius 自己報。 + _reportLiterals(label, arguments); + case ('SizedBox', _): + _reportLiterals(label, [ + for (final argument in arguments) + if (argument is NamedArgument && + _sizedBoxDimensions.contains(argument.name.lexeme)) + argument, + ]); + // SizedBox.fromSize(size: Size(8, 8)):只管傳給 SizedBox 的 Size。 + for (final argument in arguments) { + if (argument.argumentExpression + case InstanceCreationExpression( + :final constructorName, + :final argumentList, + ) + when constructorName.type.name.lexeme == 'Size') { + _reportLiterals('Size', argumentList.arguments); + } + } + case ('Color', null || 'fromARGB' || 'fromRGBO' || 'from'): + // 顏色的 0 也是寫死的值,不豁免。 + final raw = arguments.any((a) => _isNumber(a.argumentExpression)); + if (raw && _applies) { + rule.reportAtNode(node, arguments: ['$label()']); + } + } + } + + @override + void visitNamedArgument(NamedArgument node) { + if (node.name.lexeme == 'fontSize') _reportLiterals('fontSize', [node]); + } + + @override + void visitSimpleIdentifier(SimpleIdentifier node) { + if (node.name == 'Colors' && _applies) { + rule.reportAtNode(node, arguments: ['Colors']); + } + } + + void _reportLiterals(String label, Iterable arguments) { + for (final argument in arguments) { + final value = argument.argumentExpression; + if (_isNonZeroNumber(value) && _applies) { + rule.reportAtNode(value, arguments: ['A number literal in $label']); + } + } + } + + static bool _isNonZeroNumber(Expression expression) => switch (expression) { + IntegerLiteral(:final value) => value != 0, + DoubleLiteral(:final value) => value != 0, + PrefixExpression(:final operand) => _isNonZeroNumber(operand), + ParenthesizedExpression(:final expression) => _isNonZeroNumber(expression), + _ => false, + }; + + static bool _isNumber(Expression expression) => switch (expression) { + IntegerLiteral() || DoubleLiteral() => true, + PrefixExpression(:final operand) => _isNumber(operand), + ParenthesizedExpression(:final expression) => _isNumber(expression), + _ => false, + }; +} diff --git a/app/packages/fmp_lints/lib/src/rules/http_client_owner.dart b/app/packages/fmp_lints/lib/src/rules/http_client_owner.dart new file mode 100644 index 00000000..135f5566 --- /dev/null +++ b/app/packages/fmp_lints/lib/src/rules/http_client_owner.dart @@ -0,0 +1,54 @@ +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 '../package_path.dart'; + +/// 網路模組:唯一建立 HTTP client 的地方(ADR 0012)。 +const networkDirectory = 'lib/core/network'; + +/// `fmp_http_client_owner`:`lib/` 內建立 `Dio`(任何建構子)只准在 +/// [networkDirectory]。 +class HttpClientOwner extends AnalysisRule { + static const LintCode code = LintCode( + 'fmp_http_client_owner', + 'Dio is only constructed in $networkDirectory/.', + correctionMessage: 'Get the client from the network module.', + severity: DiagnosticSeverity.WARNING, + ); + + HttpClientOwner() + : super( + name: 'fmp_http_client_owner', + description: 'One owner of HTTP clients.', + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + registry.addInstanceCreationExpression(this, _Visitor(this, context)); + } +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule, this.context); + + final AnalysisRule rule; + final RuleContext context; + + @override + void visitInstanceCreationExpression(InstanceCreationExpression node) { + if (node.constructorName.type.name.lexeme != 'Dio') return; + final path = PackagePath.of(context); + if (path == null || !path.isInLib || path.isIn(networkDirectory)) return; + rule.reportAtNode(node.constructorName); + } +} diff --git a/app/packages/fmp_lints/lib/src/rules/ignore_reason.dart b/app/packages/fmp_lints/lib/src/rules/ignore_reason.dart new file mode 100644 index 00000000..3020c2ec --- /dev/null +++ b/app/packages/fmp_lints/lib/src/rules/ignore_reason.dart @@ -0,0 +1,84 @@ +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'; + +/// `// ignore:` 或 `// ignore_for_file:` 之後的診斷名稱清單與其餘文字。 +final _ignoreComment = RegExp( + r'^//+\s*ignore(?:_for_file)?:\s*' + r'(?[\w/]+(?:\s*,\s*[\w/]+)*)(?.*)$', +); + +/// 名稱清單之後的理由:` — ` 或 ` - ` 加文字。 +final _reason = RegExp(r'^\s+[—-]\s+\S'); + +/// `fmp_ignore_reason`:忽略任何 `fmp_` 規則的 `// ignore:`/ +/// `// ignore_for_file:` 註解,同一行的規則名之後要寫理由。 +/// +/// 理由接在名稱之後不影響 ignore 本身:analyzer 把名稱清單之後的文字當成 +/// 註解(`package:analyzer/src/ignore_comments/ignore_info.dart`)。 +class IgnoreReason extends AnalysisRule { + static const LintCode code = LintCode( + 'fmp_ignore_reason', + 'An ignore comment for an fmp_ rule needs a reason.', + correctionMessage: + "Append the reason after the rule name: '// ignore: " + "fmp_lints/fmp_… — why'.", + severity: DiagnosticSeverity.WARNING, + ); + + IgnoreReason() + : super( + name: 'fmp_ignore_reason', + description: 'Ignoring an fmp_ rule states why.', + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + registry.addCompilationUnit(this, _Visitor(this)); + } +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule); + + final AnalysisRule rule; + + @override + void visitCompilationUnit(CompilationUnit node) { + Token? token = node.beginToken; + while (token != null) { + for ( + Token? comment = token.precedingComments; + comment != null; + comment = comment.next + ) { + if (lacksReason(comment.lexeme)) { + rule.reportAtOffset(comment.offset, comment.length); + } + } + if (token.isEof) break; + token = token.next; + } + } +} + +/// [comment] 是忽略 `fmp_` 規則的 ignore 註解且沒寫理由。 +bool lacksReason(String comment) { + final match = _ignoreComment.firstMatch(comment); + if (match == null) return false; + final names = match.namedGroup('names')!.split(','); + final ignoresFmpRule = names.any( + (name) => name.trim().split('/').last.startsWith('fmp_'), + ); + return ignoresFmpRule && !_reason.hasMatch(match.namedGroup('rest')!); +} diff --git a/app/packages/fmp_lints/lib/src/rules/layer_imports.dart b/app/packages/fmp_lints/lib/src/rules/layer_imports.dart new file mode 100644 index 00000000..184d05d2 --- /dev/null +++ b/app/packages/fmp_lints/lib/src/rules/layer_imports.dart @@ -0,0 +1,180 @@ +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 '../package_path.dart'; + +/// 外部套件只准在哪個目錄 import。鍵是套件名,也涵蓋 `<鍵>_` 開頭的同系列 +/// 套件(`drift` 含 `drift_flutter`)。 +final externalPackageOwners = { + // ADR 0010:isar 只給舊資料匯入。 + 'isar_community': 'lib/legacy_import', + 'drift': 'lib/data', + 'sqlite3': 'lib/data', + // ADR 0018:兩個播放後端。 + 'just_audio': 'lib/playback/backends', + 'media_kit': 'lib/playback/backends', + 'dio': 'lib/core/network', + 'flutter_js': 'lib/plugins/runtime', + // M6 才有,先列入。 + 'background_downloader': 'lib/downloads', + for (final name in platformPackages) name: 'lib/platform', +}; + +/// 平台套件(ADR 0009):只准在平台層。之後的 ADR 在這裡加。 +const platformPackages = [ + 'path_provider', + 'window_manager', + 'tray_manager', + 'hotkey_manager', + 'launch_at_startup', + 'desktop_multi_window', + 'flutter_overlay_window', + 'permission_handler', + 'smtc_windows', + 'audio_service', + 'audio_service_mpris', + 'connectivity_plus', + 'file_picker', + 'package_info_plus', + 'flutter_inappwebview', + 'flutter_secure_storage', +]; + +/// 左邊目錄內的檔案不得 import 右邊的目錄。 +const forbiddenLayerImports = >{ + 'lib/core': _upperLayers, + 'lib/domain': _upperLayers, + 'lib/data': ['lib/ui'], +}; + +const _upperLayers = [ + 'lib/ui', + 'lib/playback', + 'lib/plugins', + 'lib/data', + 'lib/settings', +]; + +/// 只有自己目錄內能 import 的目錄(ADR 0010)。 +const sealedDirectories = ['lib/legacy_import']; + +/// `fmp_layer_imports`:依賴方向表(ADR 0015 §決定 2)。 +class LayerImports extends AnalysisRule { + static const LintCode code = LintCode( + 'fmp_layer_imports', + "'{0}' can't be imported from here: {1}.", + correctionMessage: + 'Move the code to the owning layer, or update the table in ' + 'fmp_lints/lib/src/rules/layer_imports.dart with an ADR.', + severity: DiagnosticSeverity.WARNING, + ); + + LayerImports() + : super(name: 'fmp_layer_imports', description: 'Layer dependency table.'); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + final visitor = _Visitor(this, context); + registry + ..addImportDirective(this, visitor) + ..addExportDirective(this, visitor); + } +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule, this.context); + + final AnalysisRule rule; + final RuleContext context; + + @override + void visitImportDirective(ImportDirective node) => _check(node); + + @override + void visitExportDirective(ExportDirective node) => _check(node); + + void _check(NamespaceDirective node) { + final from = PackagePath.of(context); + if (from == null) return; + final libraryUri = context.libraryElement?.uri; + final ownPackage = libraryUri != null && libraryUri.isScheme('package') + ? libraryUri.pathSegments.first + : null; + for (final literal in [ + node.uri, + for (final c in node.configurations) c.uri, + ]) { + final uri = literal.stringValue; + if (uri == null) continue; + final reason = layerViolation(from, uri, ownPackage: ownPackage); + if (reason != null) { + rule.reportAtNode(literal, arguments: [uri, reason]); + } + } + } +} + +/// [from] import [uri] 違反依賴方向表時回傳原因,否則 `null`。 +/// +/// [ownPackage] 是 [from] 所屬 package 的名稱;只有 `lib/` 內的檔案有。 +String? layerViolation(PackagePath from, String uri, {String? ownPackage}) { + final parsed = Uri.tryParse(uri); + if (parsed == null || parsed.isScheme('dart')) return null; + + PackagePath? target; + if (parsed.isScheme('package')) { + final segments = parsed.pathSegments; + if (segments.isEmpty) return null; + final name = segments.first; + if (name != ownPackage) return _externalViolation(from, name); + target = PackagePath('lib/${segments.skip(1).join('/')}'); + } else if (parsed.hasScheme) { + return 'only package: and relative URIs inside this package are allowed'; + } else { + target = from.resolve(uri); + if (target == null) { + return 'it leaves this package (the legacy project lives outside app/)'; + } + } + return _internalViolation(from, target); +} + +String? _externalViolation(PackagePath from, String packageName) { + if (!from.isInLib) return null; + for (final MapEntry(key: family, value: owner) + in externalPackageOwners.entries) { + final inFamily = + packageName == family || packageName.startsWith('${family}_'); + if (inFamily && !from.isIn(owner)) { + return "package:$family* is only allowed in $owner/"; + } + } + return null; +} + +String? _internalViolation(PackagePath from, PackagePath target) { + if (!from.isInLib || !target.isInLib) return null; + for (final sealed in sealedDirectories) { + if (target.isIn(sealed) && !from.isIn(sealed)) { + return '$sealed/ is only imported from inside itself'; + } + } + for (final MapEntry(key: layer, value: forbidden) + in forbiddenLayerImports.entries) { + if (!from.isIn(layer)) continue; + for (final upper in forbidden) { + if (target.isIn(upper)) return '$layer/ must not import $upper/'; + } + } + return null; +} diff --git a/app/packages/fmp_lints/lib/src/rules/log_facade.dart b/app/packages/fmp_lints/lib/src/rules/log_facade.dart new file mode 100644 index 00000000..eca01add --- /dev/null +++ b/app/packages/fmp_lints/lib/src/rules/log_facade.dart @@ -0,0 +1,120 @@ +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/element.dart'; +import 'package:analyzer/error/error.dart'; + +import '../package_path.dart'; + +/// log 門面所在的目錄(ADR 0011)。 +const logFacadeDirectory = 'lib/core/logging'; + +const _printFunctions = {'print', 'debugPrint'}; + +/// `fmp_log_facade`:`lib/` 內,`print`、`debugPrint`、`dart:developer` 的 +/// `log`、`package:talker*` 只准在 log 門面。 +/// +/// `dart:developer` 看 import:沒有以 `show` 排除 `log` 的 import 就報。 +class LogFacade extends AnalysisRule { + static const LintCode code = LintCode( + 'fmp_log_facade', + "'{0}' is only allowed in $logFacadeDirectory/.", + correctionMessage: 'Log through the facade in $logFacadeDirectory/.', + severity: DiagnosticSeverity.WARNING, + ); + + LogFacade() + : super( + name: 'fmp_log_facade', + description: 'Logging goes through the facade.', + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + final visitor = _Visitor(this, context); + registry + ..addMethodInvocation(this, visitor) + ..addFunctionExpressionInvocation(this, visitor) + ..addImportDirective(this, visitor); + } +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule, this.context); + + final AnalysisRule rule; + final RuleContext context; + + bool get _applies { + final path = PackagePath.of(context); + return path != null && path.isInLib && !path.isIn(logFacadeDirectory); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + if (_isImportPrefix(node.target) || node.target == null) { + _check(node.methodName); + } + } + + /// `debugPrint` 是函式型別的頂層變數,解析後的呼叫是 + /// [FunctionExpressionInvocation],不是 [MethodInvocation]。 + @override + void visitFunctionExpressionInvocation(FunctionExpressionInvocation node) { + switch (node.function) { + case SimpleIdentifier function: + _check(function); + case PrefixedIdentifier(:final prefix, :final identifier) + when _isImportPrefix(prefix): + _check(identifier); + } + } + + void _check(SimpleIdentifier name) { + if (_printFunctions.contains(name.name) && _applies) { + rule.reportAtNode(name, arguments: [name.name]); + } + } + + /// `foundation.debugPrint` 的 `foundation`:import 前綴,不是物件。 + static bool _isImportPrefix(Expression? target) => + target is SimpleIdentifier && target.element is PrefixElement; + + @override + void visitImportDirective(ImportDirective node) { + final uri = node.uri.stringValue; + if (uri == null || !_applies) return; + if (_isTalker(uri) || (uri == 'dart:developer' && _mayImportLog(node))) { + rule.reportAtNode(node.uri, arguments: [uri]); + } + } + + static bool _isTalker(String uri) { + final parsed = Uri.tryParse(uri); + if (parsed == null || !parsed.isScheme('package')) return false; + final name = parsed.pathSegments.firstOrNull; + return name == 'talker' || (name?.startsWith('talker_') ?? false); + } + + static bool _mayImportLog(ImportDirective node) { + for (final combinator in node.combinators) { + if (combinator is ShowCombinator && + !combinator.shownNames.any((n) => n.name == 'log')) { + return false; + } + if (combinator is HideCombinator && + combinator.hiddenNames.any((n) => n.name == 'log')) { + return false; + } + } + return true; + } +} diff --git a/app/packages/fmp_lints/lib/src/rules/material_import.dart b/app/packages/fmp_lints/lib/src/rules/material_import.dart new file mode 100644 index 00000000..8c28b2d3 --- /dev/null +++ b/app/packages/fmp_lints/lib/src/rules/material_import.dart @@ -0,0 +1,65 @@ +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'; + +/// 框架內凍結的設計系統函式庫,與取代它們的獨立套件(ADR 0024 §決定 1)。 +const frozenDesignLibraries = { + 'package:flutter/material.dart': 'package:material_ui/material_ui.dart', + 'package:flutter/cupertino.dart': 'package:cupertino_ui/cupertino_ui.dart', +}; + +/// `fmp_material_import`:package 內任何檔案都不得 import 或 export +/// [frozenDesignLibraries] 的鍵。接手 PR 2 的 +/// `test/static_rules/material_import_static_rule_test.dart`(那支掃 `lib/` +/// 與 `test/`),所以不限 `lib/`。 +class MaterialImport extends AnalysisRule { + static const LintCode code = LintCode( + 'fmp_material_import', + "'{0}' is frozen in the framework.", + correctionMessage: "Import '{1}' instead.", + severity: DiagnosticSeverity.WARNING, + ); + + MaterialImport() + : super( + name: 'fmp_material_import', + description: 'Use the standalone material_ui / cupertino_ui packages.', + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + final visitor = _Visitor(this); + registry + ..addImportDirective(this, visitor) + ..addExportDirective(this, visitor); + } +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule); + + final AnalysisRule rule; + + @override + void visitImportDirective(ImportDirective node) => _check(node); + + @override + void visitExportDirective(ExportDirective node) => _check(node); + + void _check(NamespaceDirective node) { + final uri = node.uri.stringValue; + final replacement = frozenDesignLibraries[uri]; + if (uri != null && replacement != null) { + rule.reportAtNode(node.uri, arguments: [uri, replacement]); + } + } +} diff --git a/app/packages/fmp_lints/lib/src/rules/no_empty_catch.dart b/app/packages/fmp_lints/lib/src/rules/no_empty_catch.dart new file mode 100644 index 00000000..5c2c3824 --- /dev/null +++ b/app/packages/fmp_lints/lib/src/rules/no_empty_catch.dart @@ -0,0 +1,44 @@ +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'; + +/// `fmp_no_empty_catch`:catch 本體沒有陳述式就違規;只有註解也算空, +/// 變數名 `_` 不豁免(ADR 0015 §決定 2)。沒有允許清單。 +class NoEmptyCatch extends AnalysisRule { + static const LintCode code = LintCode( + 'fmp_no_empty_catch', + 'Empty catch block.', + correctionMessage: + 'Handle the error: log it through the facade, rethrow, or map it to ' + 'an AppError.', + severity: DiagnosticSeverity.WARNING, + ); + + NoEmptyCatch() + : super(name: 'fmp_no_empty_catch', description: 'No empty catch blocks.'); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + registry.addCatchClause(this, _Visitor(this)); + } +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule); + + final AnalysisRule rule; + + @override + void visitCatchClause(CatchClause node) { + if (node.body.statements.isEmpty) rule.reportAtNode(node.body); + } +} diff --git a/app/packages/fmp_lints/lib/src/rules/no_for_testing.dart b/app/packages/fmp_lints/lib/src/rules/no_for_testing.dart new file mode 100644 index 00000000..82c52c65 --- /dev/null +++ b/app/packages/fmp_lints/lib/src/rules/no_for_testing.dart @@ -0,0 +1,87 @@ +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 '../package_path.dart'; + +/// `xForTesting`,以及具名建構子 `Foo.forTesting`。 +final _forTesting = RegExp(r'(^f|F)orTesting$'); + +/// `fmp_no_for_testing`:`lib/` 不得宣告 `*ForTesting` 的成員(方法、欄位、 +/// getter/setter、具名建構子、頂層函式與變數)。要替換的東西經建構子或 +/// provider 注入(ADR 0015 §決定 1)。 +class NoForTesting extends AnalysisRule { + static const LintCode code = LintCode( + 'fmp_no_for_testing', + "'{0}' is a test hook in production code.", + correctionMessage: + 'Inject the dependency through a constructor or a provider override.', + severity: DiagnosticSeverity.WARNING, + ); + + NoForTesting() + : super(name: 'fmp_no_for_testing', description: 'No *ForTesting members.'); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + final visitor = _Visitor(this, context); + registry + ..addMethodDeclaration(this, visitor) + ..addFunctionDeclaration(this, visitor) + ..addConstructorDeclaration(this, visitor) + ..addFieldDeclaration(this, visitor) + ..addTopLevelVariableDeclaration(this, visitor); + } +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule, this.context); + + final AnalysisRule rule; + final RuleContext context; + + @override + void visitMethodDeclaration(MethodDeclaration node) => _check(node.name); + + @override + void visitFunctionDeclaration(FunctionDeclaration node) { + // 區域函式不是成員;頂層函式的父節點是 CompilationUnit。 + if (node.parent is CompilationUnit) _check(node.name); + } + + @override + void visitConstructorDeclaration(ConstructorDeclaration node) { + if (node.name case final name?) _check(name); + } + + @override + void visitFieldDeclaration(FieldDeclaration node) => + _checkVariables(node.fields); + + @override + void visitTopLevelVariableDeclaration(TopLevelVariableDeclaration node) => + _checkVariables(node.variables); + + void _checkVariables(VariableDeclarationList list) { + for (final variable in list.variables) { + _check(variable.name); + } + } + + void _check(Token name) { + if (!_forTesting.hasMatch(name.lexeme)) return; + final path = PackagePath.of(context); + if (path == null || !path.isInLib) return; + rule.reportAtToken(name, arguments: [name.lexeme]); + } +} diff --git a/app/packages/fmp_lints/lib/src/rules/platform_checks.dart b/app/packages/fmp_lints/lib/src/rules/platform_checks.dart new file mode 100644 index 00000000..1e65a37e --- /dev/null +++ b/app/packages/fmp_lints/lib/src/rules/platform_checks.dart @@ -0,0 +1,97 @@ +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 '../ast_names.dart'; +import '../package_path.dart'; + +/// 平台層(ADR 0009):唯一可以判斷平台的地方。 +const platformDirectory = 'lib/platform'; + +/// 不論怎麼用都算平台判斷的識別字。 +const _platformIdentifiers = {'defaultTargetPlatform', 'TargetPlatform'}; + +/// `fmp_platform_checks`:`lib/` 內 `Platform.isXxx`、 +/// `Platform.operatingSystem`、`defaultTargetPlatform`、`TargetPlatform` +/// 只准在 [platformDirectory]。以名稱判斷,不看它來自哪個函式庫。 +class PlatformChecks extends AnalysisRule { + static const LintCode code = LintCode( + 'fmp_platform_checks', + "'{0}' is a platform check outside $platformDirectory/.", + correctionMessage: + 'Ask the platform layer for the capability instead of the platform.', + severity: DiagnosticSeverity.WARNING, + ); + + PlatformChecks() + : super( + name: 'fmp_platform_checks', + description: 'Platform checks live in the platform layer.', + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + final visitor = _Visitor(this, context); + registry + ..addSimpleIdentifier(this, visitor) + ..addNamedType(this, visitor); + } +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule, this.context); + + final AnalysisRule rule; + final RuleContext context; + + bool get _applies { + final path = PackagePath.of(context); + return path != null && path.isInLib && !path.isIn(platformDirectory); + } + + @override + void visitSimpleIdentifier(SimpleIdentifier node) { + final name = node.name; + if (_platformIdentifiers.contains(name) || _isPlatformGetter(node)) { + if (_applies) rule.reportAtNode(node, arguments: [name]); + } + } + + @override + void visitNamedType(NamedType node) { + final name = node.name.lexeme; + if (_platformIdentifiers.contains(name) && _applies) { + rule.reportAtToken(node.name, arguments: [name]); + } + } + + /// `Platform.isXxx`/`Platform.operatingSystem` 的屬性名(含 `io.Platform.…`)。 + static bool _isPlatformGetter(SimpleIdentifier node) { + final name = node.name; + final isGetter = + name == 'operatingSystem' || + (name.length > 2 && + name.startsWith('is') && + name[2].toUpperCase() == name[2]); + if (!isGetter) return false; + final target = switch (node.parent) { + PrefixedIdentifier(:final prefix, :final identifier) + when identifier == node => + prefix, + PropertyAccess(:final target?, :final propertyName) + when propertyName == node => + target, + _ => null, + }; + return isNamedReference(target, 'Platform'); + } +} diff --git a/app/packages/fmp_lints/lib/src/rules/source_id_literal.dart b/app/packages/fmp_lints/lib/src/rules/source_id_literal.dart new file mode 100644 index 00000000..c13a7fab --- /dev/null +++ b/app/packages/fmp_lints/lib/src/rules/source_id_literal.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/visitor.dart'; +import 'package:analyzer/error/error.dart'; + +import '../package_path.dart'; + +/// 官方插件的 id。M3 加 `youtube`、`netease`。 +const officialPluginIds = { + // ignore: fmp_lints/fmp_source_id_literal — 規則本身的清單 + 'bilibili', +}; + +/// 可以寫官方插件 id 的地方:舊資料匯入要對應舊資料的音源,測試要造資料。 +const sourceIdAllowedDirectories = ['lib/legacy_import', 'test']; + +/// `fmp_source_id_literal`:字串字面值整個等於官方插件 id 就報 +/// (ADR 0014;UI 與 service 不得依音源分支)。 +class SourceIdLiteral extends AnalysisRule { + static const LintCode code = LintCode( + 'fmp_source_id_literal', + "The official plugin id '{0}' is written as a literal.", + correctionMessage: + 'Branch on plugin capabilities, not on a plugin id. Ids are only ' + 'allowed in lib/legacy_import/ and test/.', + severity: DiagnosticSeverity.WARNING, + ); + + SourceIdLiteral() + : super( + name: 'fmp_source_id_literal', + description: 'No official plugin id literals.', + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + registry.addSimpleStringLiteral(this, _Visitor(this, context)); + } +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule, this.context); + + final AnalysisRule rule; + final RuleContext context; + + @override + void visitSimpleStringLiteral(SimpleStringLiteral node) { + final value = node.value; + if (!officialPluginIds.contains(value)) return; + final path = PackagePath.of(context); + if (path == null || path.isInAny(sourceIdAllowedDirectories)) return; + rule.reportAtNode(node, arguments: [value]); + } +} diff --git a/app/packages/fmp_lints/lib/src/rules/test_waits.dart b/app/packages/fmp_lints/lib/src/rules/test_waits.dart new file mode 100644 index 00000000..170abf1d --- /dev/null +++ b/app/packages/fmp_lints/lib/src/rules/test_waits.dart @@ -0,0 +1,51 @@ +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 '../package_path.dart'; + +/// 等待助手:唯一直接呼叫 `pumpEventQueue` 的檔案。 +const waitHelperFile = 'test/support/pump_until.dart'; + +/// `fmp_test_waits`:`test/` 內直接呼叫 `pumpEventQueue` 只准在 +/// [waitHelperFile]。 +class TestWaits extends AnalysisRule { + static const LintCode code = LintCode( + 'fmp_test_waits', + 'pumpEventQueue is only called from $waitHelperFile.', + correctionMessage: 'Wait with the helpers in $waitHelperFile.', + severity: DiagnosticSeverity.WARNING, + ); + + TestWaits() + : super(name: 'fmp_test_waits', description: 'One owner of test waits.'); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + registry.addMethodInvocation(this, _Visitor(this, context)); + } +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule, this.context); + + final AnalysisRule rule; + final RuleContext context; + + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.methodName.name != 'pumpEventQueue') return; + final path = PackagePath.of(context); + if (path == null || !path.isInTest || path.value == waitHelperFile) return; + rule.reportAtNode(node.methodName); + } +} diff --git a/app/packages/fmp_lints/lib/src/rules/toast_entry.dart b/app/packages/fmp_lints/lib/src/rules/toast_entry.dart new file mode 100644 index 00000000..093b6ce0 --- /dev/null +++ b/app/packages/fmp_lints/lib/src/rules/toast_entry.dart @@ -0,0 +1,77 @@ +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 '../ast_names.dart'; +import '../package_path.dart'; + +/// Toast 的唯一入口(ADR 0023)。 +const toastDirectory = 'lib/ui/toast'; + +const _snackBarMethods = {'showSnackBar', 'clearSnackBars'}; + +const _messengerLookups = {'of', 'maybeOf'}; + +/// `fmp_toast_entry`:`lib/` 內 `SnackBar(`、`ScaffoldMessenger.of`/ +/// `.maybeOf`、`showSnackBar`、`clearSnackBars` 只准在 [toastDirectory]。 +/// 以名稱判斷。 +class ToastEntry extends AnalysisRule { + static const LintCode code = LintCode( + 'fmp_toast_entry', + "'{0}' bypasses the toast entry in $toastDirectory/.", + correctionMessage: 'Show messages through the toast API.', + severity: DiagnosticSeverity.WARNING, + ); + + ToastEntry() + : super(name: 'fmp_toast_entry', description: 'One entry for toasts.'); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + final visitor = _Visitor(this, context); + registry + ..addInstanceCreationExpression(this, visitor) + ..addMethodInvocation(this, visitor); + } +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule, this.context); + + final AnalysisRule rule; + final RuleContext context; + + bool get _applies { + final path = PackagePath.of(context); + return path != null && path.isInLib && !path.isIn(toastDirectory); + } + + @override + void visitInstanceCreationExpression(InstanceCreationExpression node) { + final type = node.constructorName.type.name; + if (type.lexeme == 'SnackBar' && _applies) { + rule.reportAtToken(type, arguments: ['SnackBar']); + } + } + + @override + void visitMethodInvocation(MethodInvocation node) { + final name = node.methodName.name; + final isMessengerOf = + _messengerLookups.contains(name) && + isNamedReference(node.target, 'ScaffoldMessenger'); + if ((isMessengerOf || _snackBarMethods.contains(name)) && _applies) { + final label = isMessengerOf ? 'ScaffoldMessenger.$name' : name; + rule.reportAtNode(node.methodName, arguments: [label]); + } + } +} diff --git a/app/packages/fmp_lints/lib/src/rules/url_literal.dart b/app/packages/fmp_lints/lib/src/rules/url_literal.dart new file mode 100644 index 00000000..e6e6627e --- /dev/null +++ b/app/packages/fmp_lints/lib/src/rules/url_literal.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/visitor.dart'; +import 'package:analyzer/error/error.dart'; + +import '../package_path.dart'; + +/// `lib/` 內唯一可以寫網址的檔案(第一個需要網址的 PR 建立)。 +const endpointsFile = 'lib/core/endpoints.dart'; + +final _url = RegExp('https?://', caseSensitive: false); + +/// `fmp_url_literal`:`lib/` 內含 `http://`、`https://` 的字串字面值只准在 +/// [endpointsFile]。 +class UrlLiteral extends AnalysisRule { + static const LintCode code = LintCode( + 'fmp_url_literal', + 'URL literals are only allowed in $endpointsFile.', + correctionMessage: 'Move the URL to $endpointsFile.', + severity: DiagnosticSeverity.WARNING, + ); + + UrlLiteral() + : super(name: 'fmp_url_literal', description: 'URLs live in one file.'); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + final visitor = _Visitor(this, context); + registry + ..addSimpleStringLiteral(this, visitor) + ..addInterpolationString(this, visitor); + } +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule, this.context); + + final AnalysisRule rule; + final RuleContext context; + + @override + void visitSimpleStringLiteral(SimpleStringLiteral node) => + _check(node, node.value); + + @override + void visitInterpolationString(InterpolationString node) => + _check(node, node.value); + + void _check(AstNode node, String value) { + if (!_url.hasMatch(value)) return; + final path = PackagePath.of(context); + if (path == null || !path.isInLib || path.value == endpointsFile) return; + rule.reportAtNode(node); + } +} diff --git a/app/packages/fmp_lints/pubspec.yaml b/app/packages/fmp_lints/pubspec.yaml new file mode 100644 index 00000000..d618c84e --- /dev/null +++ b/app/packages/fmp_lints/pubspec.yaml @@ -0,0 +1,26 @@ +name: fmp_lints +description: FMP app 的自寫 lint 規則(ADR 0015 §決定 2)。 +publish_to: 'none' +version: 0.1.0 + +environment: + sdk: ^3.13.4 + +resolution: workspace + +# analyzer 與 analysis_server_plugin/analyzer_testing 逐版互相釘死。 +# 不能用 pub.dev 最新的 analyzer 14.4.0:這個 package 在 app/ 的 workspace +# 內,Flutter 3.47.5 的 flutter_test 釘 test_api 0.7.12 → test 1.31.1 → +# analyzer <14。能解出來的最新一組是 analyzer 13.3.0(預設語言版本已是 +# 3.13)。flutter_test 放寬 test_api 後三個一起升(research/notes.md)。 +dependencies: + analysis_server_plugin: 0.3.18 + analyzer: 13.3.0 + path: ^1.9.1 + +dev_dependencies: + analyzer_testing: 0.3.2 + # 版本由 flutter_test 的 test_api 釘版決定,見上。 + test: ^1.31.1 + test_reflective_loader: ^0.4.0 + yaml: ^3.1.4 diff --git a/app/packages/fmp_lints/test/package_path_test.dart b/app/packages/fmp_lints/test/package_path_test.dart new file mode 100644 index 00000000..90b5b002 --- /dev/null +++ b/app/packages/fmp_lints/test/package_path_test.dart @@ -0,0 +1,117 @@ +import 'package:fmp_lints/src/package_path.dart'; +import 'package:fmp_lints/src/rules/ignore_reason.dart'; +import 'package:fmp_lints/src/rules/layer_imports.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +/// 路徑判斷與analyzer 無關的部分。規則測試本身在 Windows 上(或設了 +/// `TEST_ANALYZER_WINDOWS_PATHS=true`)以 Windows 路徑跑;這裡固定兩種分隔符 +/// 各跑一次,不依賴執行平台。 +void main() { + group('PackagePath.relative', () { + test('Windows paths become posix package paths', () { + expect( + PackagePath.relative( + r'C:\Users\me\FMP\app\lib\ui\toast\toast_host.dart', + r'C:\Users\me\FMP\app', + p.windows, + ), + PackagePath('lib/ui/toast/toast_host.dart'), + ); + }); + + test('posix paths stay as they are', () { + expect( + PackagePath.relative( + '/home/me/FMP/app/test/support/pump_until.dart', + '/home/me/FMP/app', + p.posix, + ), + PackagePath('test/support/pump_until.dart'), + ); + }); + + test('a file outside the root has no package path', () { + expect( + PackagePath.relative( + r'C:\Users\me\FMP\lib\main.dart', + r'C:\Users\me\FMP\app', + p.windows, + ), + isNull, + ); + }); + }); + + group('PackagePath.isIn', () { + const path = PackagePath('lib/ui/theme/app_tokens.dart'); + + test('matches the directory and its subdirectories', () { + expect(path.isIn('lib/ui'), isTrue); + expect(path.isIn('lib/ui/theme'), isTrue); + }); + + test('does not match a sibling that shares a prefix', () { + expect(const PackagePath('lib/uikit/a.dart').isIn('lib/ui'), isFalse); + expect( + const PackagePath('lib/core/endpoints_test.dart') + .isIn('lib/core/endpoints.dart'), + isFalse, + ); + }); + }); + + group('PackagePath.resolve', () { + const from = PackagePath('lib/core/errors/app_error.dart'); + + test('resolves inside the package', () { + expect( + from.resolve('../../ui/page.dart'), + PackagePath('lib/ui/page.dart'), + ); + }); + + test('leaving the package or an absolute path is null', () { + expect(from.resolve('../../../../lib/main.dart'), isNull); + expect(from.resolve('/lib/main.dart'), isNull); + }); + }); + + group('layerViolation from a Windows-derived path', () { + final from = PackagePath.relative( + r'C:\FMP\app\lib\core\logging\log.dart', + r'C:\FMP\app', + p.windows, + )!; + + test('reports an upper layer', () { + expect( + layerViolation(from, 'package:fmp/ui/page.dart', ownPackage: 'fmp'), + contains('lib/core/ must not import lib/ui/'), + ); + }); + + test('allows the same layer', () { + expect( + layerViolation(from, '../errors/app_error.dart', ownPackage: 'fmp'), + isNull, + ); + }); + }); + + group('lacksReason', () { + test('an fmp ignore without a reason', () { + expect(lacksReason('// ignore: fmp_lints/fmp_url_literal'), isTrue); + expect(lacksReason('//ignore_for_file:fmp_design_tokens'), isTrue); + }); + + test('an fmp ignore with a reason, or a non-fmp ignore', () { + expect( + lacksReason('// ignore: fmp_lints/fmp_url_literal — why'), + isFalse, + ); + expect(lacksReason('// ignore: fmp_url_literal - why'), isFalse); + expect(lacksReason('// ignore: unused_element'), isFalse); + }); + }); +} diff --git a/app/packages/fmp_lints/test/plugin_test.dart b/app/packages/fmp_lints/test/plugin_test.dart new file mode 100644 index 00000000..67212065 --- /dev/null +++ b/app/packages/fmp_lints/test/plugin_test.dart @@ -0,0 +1,71 @@ +import 'dart:io'; + +import 'package:fmp_lints/main.dart'; +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; + +/// 規則註冊成 lint rule 後預設關閉:少開一條,那條就靜靜失效。這裡斷言 +/// `app/analysis_options.yaml` 開的正好是插件註冊的全部規則。 +void main() { + final names = [for (final rule in fmpRules()) rule.name]; + + test('rule names are unique and prefixed', () { + expect(names.toSet(), hasLength(names.length)); + expect(names, everyElement(startsWith('fmp_'))); + }); + + test('app/analysis_options.yaml enables exactly the registered rules', () { + final options = File('../../analysis_options.yaml').readAsStringSync(); + expect(enabledPluginRules(options), unorderedEquals(names)); + }); + + group('enabledPluginRules mutations', () { + const base = ''' +plugins: + fmp_lints: + path: packages/fmp_lints + diagnostics: + fmp_a: true + fmp_b: true +'''; + + test('a disabled or missing rule is dropped', () { + expect( + enabledPluginRules(base.replaceFirst('fmp_b: true', 'fmp_b: false')), + ['fmp_a'], + ); + expect(enabledPluginRules(base.replaceFirst(' fmp_b: true\n', '')), [ + 'fmp_a', + ]); + }); + + test('comments, ordering and other sections do not change the result', () { + const reformatted = ''' +# 註解 +analyzer: + exclude: [build/**] +plugins: + other_plugin: ^1.0.0 + fmp_lints: + diagnostics: {fmp_b: true, fmp_a: true} # 行內註解 + path: packages/fmp_lints +'''; + expect( + enabledPluginRules(reformatted), + unorderedEquals(['fmp_a', 'fmp_b']), + ); + }); + }); +} + +/// [analysisOptions] 的 `plugins: fmp_lints: diagnostics:` 裡設成 `true` 的規則。 +List enabledPluginRules(String analysisOptions) { + final yaml = loadYaml(analysisOptions) as YamlMap; + final diagnostics = + ((yaml['plugins'] as YamlMap?)?['fmp_lints'] as YamlMap?)?['diagnostics'] + as YamlMap?; + return [ + for (final MapEntry(:key, :value) in (diagnostics ?? YamlMap()).entries) + if (value == true) key as String, + ]; +} diff --git a/app/packages/fmp_lints/test/rules/design_tokens_test.dart b/app/packages/fmp_lints/test/rules/design_tokens_test.dart new file mode 100644 index 00000000..b896c484 --- /dev/null +++ b/app/packages/fmp_lints/test/rules/design_tokens_test.dart @@ -0,0 +1,165 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:fmp_lints/src/rules/design_tokens.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../support/rule_test_base.dart'; + +void main() { + defineReflectiveSuite(() => defineReflectiveTests(DesignTokensTest)); +} + +@reflectiveTest +class DesignTokensTest extends FmpRuleTest { + @override + AnalysisRule createRule() => DesignTokens(); + + @override + void addStubPackages() { + newPackage('material_ui').addFile('lib/material_ui.dart', ''' +class EdgeInsets { + const EdgeInsets.all(double value); + const EdgeInsets.only({double left = 0, double top = 0}); + const EdgeInsets.symmetric({double horizontal = 0, double vertical = 0}); +} +class EdgeInsetsDirectional { + const EdgeInsetsDirectional.only({double start = 0, double end = 0}); + const EdgeInsetsDirectional.all(double value); +} +class Size { + const Size(double width, double height); + const Size.square(double dimension); +} +class SizedBox { + const SizedBox({double? width, double? height, Object? child}); + const SizedBox.square({double? dimension}); + const SizedBox.fromSize({Size? size}); +} +class Radius { + const Radius.circular(double radius); + const Radius.elliptical(double x, double y); + static const zero = Radius.circular(0); +} +class BorderRadius { + const BorderRadius.circular(double radius); + const BorderRadius.all(Radius radius); + const BorderRadius.only({Radius topLeft = Radius.zero}); +} +class BorderRadiusDirectional { + const BorderRadiusDirectional.circular(double radius); + const BorderRadiusDirectional.only({Radius topStart = Radius.zero}); +} +class Layout { + const Layout({Size? size}); +} +class TextStyle { + const TextStyle({double? fontSize, double? height}); +} +class Color { + const Color(int value); + const Color.fromARGB(int a, int r, int g, int b); + const Color.fromRGBO(int r, int g, int b, double opacity); + const Color.from({required double alpha, required double red, + required double green, required double blue}); + static Color? lerp(Color? a, Color? b, double t) => a; +} +class Colors { + static const red = Color(0xFFFF0000); +} +class AppTokens { + static const double gap = 8; + static const int argb = 0xFF123456; + static const int alpha = 255; +} +'''); + } + + // 報 + + Future test_rawValuesInUi() => + assertLints('lib/ui/search/page.dart', ''' +import 'package:material_ui/material_ui.dart'; + +const a = EdgeInsets.all([!8!]); +const b = EdgeInsets.only(left: [!4.5!], top: 0); +const c = EdgeInsets.symmetric(horizontal: [!-2!]); +const d = SizedBox(width: [!16!], height: [!24!]); +const e = SizedBox.square(dimension: [!40!]); +const f = BorderRadius.circular([!12!]); +const g = TextStyle(fontSize: [!14!]); +const h = [!Color(0xFF123456)!]; +const i = [!Colors!].red; +const j = EdgeInsets.all([!(8)!]); +'''); + + Future test_neighbouringConstructors() => + assertLints('lib/ui/search/page.dart', ''' +import 'package:material_ui/material_ui.dart'; + +const a = EdgeInsetsDirectional.only(start: [!8!], end: 0); +const b = EdgeInsetsDirectional.all([!-4!]); +const c = Radius.circular([!8!]); +const d = Radius.elliptical([!4!], [!6.5!]); +const e = BorderRadius.all(Radius.circular([!12!])); +const f = BorderRadius.only(topLeft: Radius.circular([!2!])); +const g = BorderRadiusDirectional.circular([!10!]); +const h = BorderRadiusDirectional.only(topStart: Radius.circular([!3!])); +const i = SizedBox.fromSize(size: Size([!48!], [!0.5!])); +const j = SizedBox.fromSize(size: Size.square([!24!])); +'''); + + Future test_colorConstructors() => + assertLints('lib/ui/search/page.dart', ''' +import 'package:material_ui/material_ui.dart'; + +const a = [!Color.fromARGB(255, 0, 0, 0)!]; +const b = [!Color.fromRGBO(12, 34, 56, 0.5)!]; +const c = [!Color.from(alpha: 1, red: 0, green: 0, blue: 0)!]; +const d = [!Color(0)!]; +'''); + + // 不報 + + Future test_themeDirectory() => + assertLints('lib/ui/theme/app_tokens.dart', ''' +import 'package:material_ui/material_ui.dart'; + +const a = EdgeInsets.all(8); +const b = TextStyle(fontSize: 14); +const c = Color(0xFF123456); +const d = Colors.red; +'''); + + Future test_outsideUi() => + assertLints('lib/playback/controller.dart', ''' +import 'package:material_ui/material_ui.dart'; + +const a = EdgeInsets.all(8); +const b = Colors.red; +'''); + + Future test_tokensZeroAndOtherArguments() => + assertLints('lib/ui/search/page.dart', ''' +import 'package:material_ui/material_ui.dart'; + +const a = EdgeInsets.all(AppTokens.gap); +const b = EdgeInsets.all(0); +const b2 = EdgeInsets.all((0)); +const c = SizedBox(width: AppTokens.gap, height: 0.0); +const d = TextStyle(height: 1.4); +const e = BorderRadius.only(); +const f = Color(AppTokens.argb); +Color g(int a, int r, int gr, int b) => Color.fromARGB(a, r, gr, b); +const h = EdgeInsetsDirectional.only(start: AppTokens.gap, end: 0); +const i = Radius.circular(0); +const j = Radius.elliptical(AppTokens.gap, 0); +const k = BorderRadius.all(Radius.zero); +const l = BorderRadiusDirectional.circular(AppTokens.gap); +const m = SizedBox.fromSize(size: Size(AppTokens.gap, 0)); +// Size 只在傳給 SizedBox 時才管 +const n = Size(48, 48); +const o = Layout(size: Size(48, 48)); +final p = Color.lerp(null, null, 0.5); +// EdgeInsets.all(8) 與 Colors.red、Radius.circular(8) +const text = 'fontSize: 14'; +'''); +} diff --git a/app/packages/fmp_lints/test/rules/http_client_owner_test.dart b/app/packages/fmp_lints/test/rules/http_client_owner_test.dart new file mode 100644 index 00000000..f3344150 --- /dev/null +++ b/app/packages/fmp_lints/test/rules/http_client_owner_test.dart @@ -0,0 +1,61 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:fmp_lints/src/rules/http_client_owner.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../support/rule_test_base.dart'; + +void main() { + defineReflectiveSuite(() => defineReflectiveTests(HttpClientOwnerTest)); +} + +@reflectiveTest +class HttpClientOwnerTest extends FmpRuleTest { + @override + AnalysisRule createRule() => HttpClientOwner(); + + @override + void addStubPackages() { + newPackage('dio').addFile('lib/dio.dart', ''' +class Dio { + Dio([Object? options]); + Dio.withAdapter(Object adapter); +} +class DioOptions {} +'''); + } + + // 報 + + Future test_constructedOutsideNetwork() => + assertLints('lib/plugins/host_api.dart', ''' +import 'package:dio/dio.dart'; + +final a = [!Dio!](); +final b = [!Dio.withAdapter!](Object()); +'''); + + // 不報 + + Future test_insideNetwork() => + assertLints('lib/core/network/http.dart', ''' +import 'package:dio/dio.dart'; + +final a = Dio(); +'''); + + Future test_inTests() => + assertLints('test/core/network/http_test.dart', ''' +import 'package:dio/dio.dart'; + +final a = Dio(); +'''); + + Future test_nearMisses() => assertLints('lib/plugins/host_api.dart', ''' +import 'package:dio/dio.dart'; + +// Dio() +final options = DioOptions(); +Dio? client; +const text = 'Dio()'; +'''); +} diff --git a/app/packages/fmp_lints/test/rules/ignore_reason_test.dart b/app/packages/fmp_lints/test/rules/ignore_reason_test.dart new file mode 100644 index 00000000..a8975c6f --- /dev/null +++ b/app/packages/fmp_lints/test/rules/ignore_reason_test.dart @@ -0,0 +1,56 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:fmp_lints/src/rules/ignore_reason.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../support/rule_test_base.dart'; + +void main() { + defineReflectiveSuite(() => defineReflectiveTests(IgnoreReasonTest)); +} + +@reflectiveTest +class IgnoreReasonTest extends FmpRuleTest { + @override + AnalysisRule createRule() => IgnoreReason(); + + // 報 + + Future test_withoutReason() => assertLints('lib/ui/page.dart', ''' +[!// ignore_for_file: fmp_lints/fmp_url_literal!] +void f() { + [!// ignore: fmp_lints/fmp_no_empty_catch!] + try { + f(); + } catch (_) {} + final a = 1; [!// ignore: unused_local_variable, fmp_design_tokens!] + [!// ignore: fmp_lints/fmp_log_facade 理由沒有破折號!] + print(a); +} +'''); + + Future test_inTests() => assertLints('test/ui/page_test.dart', ''' +[!// ignore: fmp_lints/fmp_test_waits!] +void f() {} +'''); + + // 不報 + + Future test_withReason() => assertLints('lib/ui/page.dart', ''' +// ignore_for_file: fmp_lints/fmp_url_literal — 這個檔案是示範 +void f() { + // ignore: fmp_lints/fmp_log_facade - 啟動前 log 門面還沒建立 + print(1); + // ignore: unused_local_variable, fmp_lints/fmp_design_tokens — 系統規定的尺寸 + final a = 1; +} +'''); + + Future test_nonFmpIgnoreAndMentions() => + assertLints('lib/ui/page.dart', ''' +// ignore: unused_element +void _f() {} + +/// 忽略寫法:`// ignore: fmp_lints/fmp_… — 理由` +const text = '// ignore: fmp_lints/fmp_url_literal'; +'''); +} diff --git a/app/packages/fmp_lints/test/rules/layer_imports_test.dart b/app/packages/fmp_lints/test/rules/layer_imports_test.dart new file mode 100644 index 00000000..2da59384 --- /dev/null +++ b/app/packages/fmp_lints/test/rules/layer_imports_test.dart @@ -0,0 +1,140 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:fmp_lints/src/rules/layer_imports.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../support/rule_test_base.dart'; + +void main() { + defineReflectiveSuite(() => defineReflectiveTests(LayerImportsTest)); +} + +@reflectiveTest +class LayerImportsTest extends FmpRuleTest { + @override + AnalysisRule createRule() => LayerImports(); + + // 報 + + Future test_relativeImportLeavingThePackage() => assertLints( + 'lib/ui/page.dart', + "import [!'../../../lib/services/audio/audio_provider.dart'!];\n", + ); + + Future test_relativeImportLeavingThePackageFromTest() => assertLints( + 'test/ui/page_test.dart', + "import [!'../../../lib/main.dart'!];\n", + ); + + Future test_fileUri() => assertLints( + 'lib/ui/page.dart', + "import [!'file:///c/fmp/lib/x.dart'!];\n", + ); + + Future test_dataPackageOutsideData() => assertLints( + 'lib/ui/page.dart', + "import [!'package:drift/drift.dart'!];\n" + "import [!'package:drift_flutter/drift_flutter.dart'!];\n" + "import [!'package:sqlite3/sqlite3.dart'!];\n", + ); + + Future test_platformPackageOutsidePlatform() => assertLints( + 'lib/settings/theme.dart', + "import [!'package:path_provider/path_provider.dart'!];\n" + "import [!'package:window_manager/window_manager.dart'!];\n", + ); + + Future test_ownerPackagesOutsideTheirDirectory() => assertLints( + 'lib/playback/controller.dart', + "import [!'package:just_audio/just_audio.dart'!];\n" + "import [!'package:dio/dio.dart'!];\n" + "import [!'package:flutter_js/flutter_js.dart'!];\n" + "import [!'package:isar_community/isar.dart'!];\n" + "import [!'package:background_downloader/background_downloader.dart'!];\n", + ); + + Future test_legacyImportFromOutside() => assertLints( + 'lib/data/database.dart', + "import [!'package:test/legacy_import/reader.dart'!];\n", + ); + + Future test_coreImportsAnUpperLayer() => assertLints( + 'lib/core/errors/app_error.dart', + "import [!'package:test/ui/toast/toast.dart'!];\n" + "import [!'../../data/database.dart'!];\n" + "import [!'package:test/settings/appearance.dart'!];\n", + ); + + Future test_domainImportsPlayback() => assertLints( + 'lib/domain/track_key.dart', + "import [!'package:test/playback/queue_model.dart'!];\n", + ); + + Future test_dataImportsUi() => + assertLints('lib/data/database.dart', "import [!'../ui/page.dart'!];\n"); + + Future test_exportAndConditionalImport() => assertLints( + 'lib/ui/page.dart', + "export [!'package:dio/dio.dart'!];\n" + "import 'package:test/ui/a.dart'\n" + " if (dart.library.io) [!'package:path_provider/path_provider.dart'!];\n", + ); + + // 不報 + + Future test_ownerDirectoriesMayImport() async { + await assertLints( + 'lib/data/database.dart', + "import 'package:drift/drift.dart';\n" + "import 'package:sqlite3/sqlite3.dart';\n", + ); + await assertLints( + 'lib/platform/app_data_directory/app_data_directory.dart', + "import 'package:path_provider/path_provider.dart';\n", + ); + await assertLints( + 'lib/playback/backends/just_audio_backend.dart', + "import 'package:just_audio/just_audio.dart';\n", + ); + await assertLints( + 'lib/core/network/http.dart', + "import 'package:dio/dio.dart';\n", + ); + await assertLints( + 'lib/legacy_import/reader.dart', + "import 'package:isar_community/isar.dart';\n" + "import 'package:test/legacy_import/schema.dart';\n", + ); + } + + Future test_testsMayImportAnyPackage() => assertLints( + 'test/data/database_test.dart', + "import 'package:drift/drift.dart';\n" + "import 'package:test/legacy_import/reader.dart';\n", + ); + + Future test_sameNamePrefixWithoutUnderscore() => assertLints( + 'lib/ui/page.dart', + "import 'package:driftwood/driftwood.dart';\n" + "import 'package:diox/diox.dart';\n", + ); + + Future test_allowedDirections() => assertLints( + 'lib/ui/page.dart', + "import 'dart:async';\n" + "import 'package:test/core/errors/app_error.dart';\n" + "import 'package:test/data/database.dart';\n" + "import '../domain/track_key.dart';\n" + "import 'toast/toast.dart';\n", + ); + + Future test_dataMayImportDomain() => assertLints( + 'lib/data/database.dart', + "import '../domain/track_key.dart';\n", + ); + + Future test_mentionsInCommentsAndStrings() => assertLints( + 'lib/ui/page.dart', + "// import 'package:drift/drift.dart';\n" + "const text = \"import 'package:dio/dio.dart';\";\n", + ); +} diff --git a/app/packages/fmp_lints/test/rules/log_facade_test.dart b/app/packages/fmp_lints/test/rules/log_facade_test.dart new file mode 100644 index 00000000..5caa8bda --- /dev/null +++ b/app/packages/fmp_lints/test/rules/log_facade_test.dart @@ -0,0 +1,97 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:fmp_lints/src/rules/log_facade.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../support/rule_test_base.dart'; + +void main() { + defineReflectiveSuite(() => defineReflectiveTests(LogFacadeTest)); +} + +@reflectiveTest +class LogFacadeTest extends FmpRuleTest { + @override + AnalysisRule createRule() => LogFacade(); + + /// 照 Flutter 的宣告:`debugPrint` 是函式型別的頂層變數,呼叫它解析成 + /// FunctionExpressionInvocation,不是 MethodInvocation。 + @override + void addStubPackages() { + newPackage('flutter').addFile('lib/foundation.dart', ''' +typedef DebugPrintCallback = void Function(String? message); +DebugPrintCallback debugPrint = (String? message) {}; +'''); + } + + // 報 + + Future test_printAndDebugPrint() => assertLints('lib/ui/page.dart', ''' +void f() { + [!print!]('x'); + [!debugPrint!]('y'); +} +'''); + + Future test_resolvedDebugPrintAndImportPrefix() => + assertLints('lib/ui/page.dart', ''' +import 'dart:core' as core; +import 'package:flutter/foundation.dart'; +import 'package:flutter/foundation.dart' as foundation; + +void f() { + [!debugPrint!]('x'); + foundation.[!debugPrint!]('y'); + core.[!print!]('z'); +} +'''); + + Future test_developerAndTalkerImports() => + assertLints('lib/playback/controller.dart', ''' +import [!'dart:developer'!]; +import [!'dart:developer'!] as dev show log, Timeline; +import [!'package:talker/talker.dart'!]; +import [!'package:talker_flutter/talker_flutter.dart'!]; +'''); + + // 不報 + + Future test_insideTheFacade() => + assertLints('lib/core/logging/log.dart', ''' +import 'dart:developer'; +import 'package:talker/talker.dart'; + +void f() => print('x'); +'''); + + Future test_outsideLib() async { + await assertLints('test/ui/page_test.dart', "void f() => print('x');\n"); + await assertLints('tool/lint_sentinel.dart', "void f() => print('x');\n"); + } + + Future test_developerWithoutLog() => assertLints('lib/ui/page.dart', ''' +import 'dart:developer' show Timeline; +import 'dart:developer' hide log; +'''); + + Future test_renamedOrWithTarget() => assertLints('lib/ui/page.dart', ''' +import 'package:talkers/talkers.dart'; + +void printLine(String s) {} + +void f(StringBuffer out, void Function(String) debugPrinter) { + printLine('x'); + out.print('y'); + debugPrinter('z'); +} + +extension on StringBuffer { + void print(String s) => write(s); +} +'''); + + Future test_mentionsInCommentsAndStrings() => + assertLints('lib/ui/page.dart', ''' +// print('x'); +const text = "debugPrint('x') import 'dart:developer';"; +'''); +} diff --git a/app/packages/fmp_lints/test/rules/material_import_test.dart b/app/packages/fmp_lints/test/rules/material_import_test.dart new file mode 100644 index 00000000..8d3422fe --- /dev/null +++ b/app/packages/fmp_lints/test/rules/material_import_test.dart @@ -0,0 +1,41 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:fmp_lints/src/rules/material_import.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../support/rule_test_base.dart'; + +void main() { + defineReflectiveSuite(() => defineReflectiveTests(MaterialImportTest)); +} + +@reflectiveTest +class MaterialImportTest extends FmpRuleTest { + @override + AnalysisRule createRule() => MaterialImport(); + + // 報 + + Future test_frozenLibrariesInLib() => + assertLints('lib/app/fmp_app.dart', ''' +import [!'package:flutter/material.dart'!]; +import [!"package:flutter/cupertino.dart"!] as c; +export [!'package:flutter/material.dart'!] show Colors; +'''); + + Future test_frozenLibraryInTest() => assertLints( + 'test/app/fmp_app_test.dart', + "import [!'package:flutter/material.dart'!];\n", + ); + + // 不報 + + Future test_standalonePackagesAndOtherFlutterLibraries() => + assertLints('lib/app/fmp_app.dart', ''' +import 'package:flutter/widgets.dart'; +import 'package:material_ui/material_ui.dart'; + +// import 'package:flutter/material.dart'; +/// 見 package:flutter/material.dart 的凍結版本。 +const text = "import 'package:flutter/material.dart';"; +'''); +} diff --git a/app/packages/fmp_lints/test/rules/no_empty_catch_test.dart b/app/packages/fmp_lints/test/rules/no_empty_catch_test.dart new file mode 100644 index 00000000..572a6f28 --- /dev/null +++ b/app/packages/fmp_lints/test/rules/no_empty_catch_test.dart @@ -0,0 +1,67 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:fmp_lints/src/rules/no_empty_catch.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../support/rule_test_base.dart'; + +void main() { + defineReflectiveSuite(() => defineReflectiveTests(NoEmptyCatchTest)); +} + +@reflectiveTest +class NoEmptyCatchTest extends FmpRuleTest { + @override + AnalysisRule createRule() => NoEmptyCatch(); + + // 報 + + Future test_emptyCatchWithUnderscore() => + assertLints('lib/ui/page.dart', ''' +void f() { + try { + f(); + } catch (_) [!{}!] +} +'''); + + Future test_onlyAComment() => assertLints('lib/data/database.dart', ''' +void f() { + try { + f(); + } on StateError [!{ + // 沒事 + }!] +} +'''); + + Future test_inTests() => assertLints('test/data/database_test.dart', ''' +void f() { + try { + f(); + } catch (e, s) [!{}!] +} +'''); + + // 不報 + + Future test_bodyWithAStatement() => assertLints('lib/ui/page.dart', ''' +void f() { + try { + f(); + } catch (_) { + return; + } + try { + f(); + } on StateError { + rethrow; + } +} +'''); + + Future test_mentionsInCommentsAndStrings() => + assertLints('lib/ui/page.dart', ''' +// try { f(); } catch (_) {} +const text = 'try { f(); } catch (_) {}'; +'''); +} diff --git a/app/packages/fmp_lints/test/rules/no_for_testing_test.dart b/app/packages/fmp_lints/test/rules/no_for_testing_test.dart new file mode 100644 index 00000000..741f58b9 --- /dev/null +++ b/app/packages/fmp_lints/test/rules/no_for_testing_test.dart @@ -0,0 +1,51 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:fmp_lints/src/rules/no_for_testing.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../support/rule_test_base.dart'; + +void main() { + defineReflectiveSuite(() => defineReflectiveTests(NoForTestingTest)); +} + +@reflectiveTest +class NoForTestingTest extends FmpRuleTest { + @override + AnalysisRule createRule() => NoForTesting(); + + // 報 + + Future test_members() => assertLints('lib/playback/queue.dart', ''' +int [!counterForTesting!] = 0; + +void [!resetForTesting!]() {} + +class Queue { + Queue(); + Queue.[!forTesting!](); + + static int [!sizeForTesting!] = 0; + int get [!lengthForTesting!] => 0; + void [!_clearForTesting!]() {} +} +'''); + + // 不報 + + Future test_inTests() => assertLints( + 'test/playback/queue_test.dart', + 'void resetForTesting() {}\n', + ); + + Future test_nearMisses() => assertLints('lib/playback/queue.dart', ''' +// resetForTesting 已經刪掉 +void forTestingPurposes() {} + +void f() { + final valueForTesting = 1; + void localForTesting() {} + localForTesting(); + print(valueForTesting); +} +'''); +} diff --git a/app/packages/fmp_lints/test/rules/platform_checks_test.dart b/app/packages/fmp_lints/test/rules/platform_checks_test.dart new file mode 100644 index 00000000..c6c83fa1 --- /dev/null +++ b/app/packages/fmp_lints/test/rules/platform_checks_test.dart @@ -0,0 +1,71 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:fmp_lints/src/rules/platform_checks.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../support/rule_test_base.dart'; + +void main() { + defineReflectiveSuite(() => defineReflectiveTests(PlatformChecksTest)); +} + +@reflectiveTest +class PlatformChecksTest extends FmpRuleTest { + @override + AnalysisRule createRule() => PlatformChecks(); + + @override + void addStubPackages() { + newPackage('flutter').addFile('lib/foundation.dart', ''' +enum TargetPlatform { android, windows } +TargetPlatform get defaultTargetPlatform => TargetPlatform.android; +'''); + } + + // 報 + + Future test_platformChecksOutsidePlatform() => + assertLints('lib/playback/controller.dart', ''' +import 'dart:io'; +import 'dart:io' as io; + +import 'package:flutter/foundation.dart'; + +bool a() => Platform.[!isAndroid!]; +bool b() => io.Platform.[!isWindows!]; +String c() => Platform.[!operatingSystem!]; +bool d() => [!defaultTargetPlatform!] == [!TargetPlatform!].android; +[!TargetPlatform!]? e; +'''); + + // 不報 + + Future test_insidePlatform() => + assertLints('lib/platform/audio_backend/audio_backend.dart', ''' +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +bool a() => Platform.isAndroid; +bool b() => defaultTargetPlatform == TargetPlatform.windows; +'''); + + Future test_outsideLib() => assertLints('test/platform/x_test.dart', ''' +import 'dart:io'; + +bool a() => Platform.isWindows; +'''); + + Future test_nearMisses() => + assertLints('lib/playback/controller.dart', ''' +import 'dart:io'; + +class Device { + bool isAndroid = false; +} + +// Platform.isAndroid +const text = 'defaultTargetPlatform'; +bool a(Device platform) => platform.isAndroid; +String b() => Platform.localHostname; +'''); +} diff --git a/app/packages/fmp_lints/test/rules/source_id_literal_test.dart b/app/packages/fmp_lints/test/rules/source_id_literal_test.dart new file mode 100644 index 00000000..3f61ac29 --- /dev/null +++ b/app/packages/fmp_lints/test/rules/source_id_literal_test.dart @@ -0,0 +1,46 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:fmp_lints/src/rules/source_id_literal.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../support/rule_test_base.dart'; + +void main() { + defineReflectiveSuite(() => defineReflectiveTests(SourceIdLiteralTest)); +} + +@reflectiveTest +class SourceIdLiteralTest extends FmpRuleTest { + @override + AnalysisRule createRule() => SourceIdLiteral(); + + // 報 + + Future test_idInUi() => assertLints('lib/ui/page.dart', ''' +bool isBili(String id) => id == [!'bilibili'!]; +'''); + + Future test_idOutsideLib() => assertLints('tool/demo.dart', ''' +const id = [!"bilibili"!]; +'''); + + // 不報 + + Future test_allowedDirectories() async { + await assertLints( + 'lib/legacy_import/source_map.dart', + "const id = 'bilibili';\n", + ); + await assertLints('test/ui/page_test.dart', "const id = 'bilibili';\n"); + } + + Future test_otherStrings() => assertLints('lib/ui/page.dart', ''' +const label = 'Bilibili'; +const key = 'bilibili_cookie'; +const sentence = 'from bilibili'; +'''); + + Future test_mentionInComment() => assertLints('lib/ui/page.dart', ''' +// 'bilibili' +void f() {} +'''); +} diff --git a/app/packages/fmp_lints/test/rules/test_waits_test.dart b/app/packages/fmp_lints/test/rules/test_waits_test.dart new file mode 100644 index 00000000..72af821c --- /dev/null +++ b/app/packages/fmp_lints/test/rules/test_waits_test.dart @@ -0,0 +1,42 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:fmp_lints/src/rules/test_waits.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../support/rule_test_base.dart'; + +void main() { + defineReflectiveSuite(() => defineReflectiveTests(TestWaitsTest)); +} + +@reflectiveTest +class TestWaitsTest extends FmpRuleTest { + @override + AnalysisRule createRule() => TestWaits(); + + // 報 + + Future test_directCallInTest() => + assertLints('test/playback/queue_test.dart', ''' +Future f() async { + await [!pumpEventQueue!](); + await [!pumpEventQueue!](times: 5); +} +'''); + + // 不報 + + Future test_waitHelper() => assertLints( + 'test/support/pump_until.dart', + 'Future drainEventQueue() => pumpEventQueue();\n', + ); + + Future test_nearMisses() => + assertLints('test/playback/queue_test.dart', ''' +// pumpEventQueue(); +const text = 'pumpEventQueue()'; +Future f() async { + await pumpUntil(() => true); + await drainEventQueue(); +} +'''); +} diff --git a/app/packages/fmp_lints/test/rules/toast_entry_test.dart b/app/packages/fmp_lints/test/rules/toast_entry_test.dart new file mode 100644 index 00000000..a9b84848 --- /dev/null +++ b/app/packages/fmp_lints/test/rules/toast_entry_test.dart @@ -0,0 +1,87 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:fmp_lints/src/rules/toast_entry.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../support/rule_test_base.dart'; + +void main() { + defineReflectiveSuite(() => defineReflectiveTests(ToastEntryTest)); +} + +@reflectiveTest +class ToastEntryTest extends FmpRuleTest { + @override + AnalysisRule createRule() => ToastEntry(); + + @override + void addStubPackages() { + newPackage('material_ui').addFile('lib/material_ui.dart', ''' +class SnackBar { + const SnackBar(); +} +class SnackBarTheme { + const SnackBarTheme(); +} +class ScaffoldMessengerState { + void showSnackBar(SnackBar bar) {} + void clearSnackBars() {} +} +class ScaffoldMessenger { + static ScaffoldMessengerState of(Object context) => ScaffoldMessengerState(); + static ScaffoldMessengerState? maybeOf(Object context) => null; +} +class Scaffold { + static Object? maybeOf(Object context) => null; +} +'''); + } + + // 報 + + Future test_directSnackBar() => + assertLints('lib/ui/search/page.dart', ''' +import 'package:material_ui/material_ui.dart'; + +void f(Object context) { + final messenger = ScaffoldMessenger.[!of!](context); + ScaffoldMessenger.[!maybeOf!](context)?.[!clearSnackBars!](); + messenger.[!showSnackBar!](const [!SnackBar!]()); + messenger.[!clearSnackBars!](); +} +'''); + + Future test_withImportPrefix() => + assertLints('lib/ui/search/page.dart', ''' +import 'package:material_ui/material_ui.dart' as m; + +void f(Object context) { + m.ScaffoldMessenger.[!of!](context); + m.ScaffoldMessenger.[!maybeOf!](context); + const m.[!SnackBar!](); +} +'''); + + // 不報 + + Future test_insideToast() => + assertLints('lib/ui/toast/toast_host.dart', ''' +import 'package:material_ui/material_ui.dart'; + +void f(Object context) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar()); +} +'''); + + Future test_nearMisses() => assertLints('lib/ui/search/page.dart', ''' +import 'package:material_ui/material_ui.dart'; + +// ScaffoldMessenger.of(context).showSnackBar(const SnackBar()); +const theme = SnackBarTheme(); +const text = 'showSnackBar'; +Object? of(Object o) => o; +final value = of(1); +Object? maybeOf(Object o) => o; +final scaffold = Scaffold.maybeOf(1); +final other = maybeOf(2); +'''); +} diff --git a/app/packages/fmp_lints/test/rules/url_literal_test.dart b/app/packages/fmp_lints/test/rules/url_literal_test.dart new file mode 100644 index 00000000..c9e80b4f --- /dev/null +++ b/app/packages/fmp_lints/test/rules/url_literal_test.dart @@ -0,0 +1,45 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:fmp_lints/src/rules/url_literal.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../support/rule_test_base.dart'; + +void main() { + defineReflectiveSuite(() => defineReflectiveTests(UrlLiteralTest)); +} + +@reflectiveTest +class UrlLiteralTest extends FmpRuleTest { + @override + AnalysisRule createRule() => UrlLiteral(); + + // 報 + + Future test_urlInLib() => assertLints('lib/plugins/host_api.dart', ''' +const api = [!'https://api.example.com/x'!]; +const insecure = [!'HTTP://example.com'!]; +'''); + + Future test_urlInInterpolation() => + assertLints('lib/data/sync.dart', r''' +String url(String host) => [!'https://!]$host/x'; +'''); + + // 不報 + + Future test_endpointsFile() => assertLints( + 'lib/core/endpoints.dart', + "const api = 'https://api.example.com';\n", + ); + + Future test_outsideLib() async { + await assertLints('test/data/sync_test.dart', "const u = 'https://x';\n"); + await assertLints('tool/demo.dart', "const u = 'https://x';\n"); + } + + Future test_nearMisses() => assertLints('lib/ui/page.dart', ''' +// 見 https://api.example.com +const scheme = 'https'; +const text = 'http:'; +'''); +} diff --git a/app/packages/fmp_lints/test/support/rule_test_base.dart b/app/packages/fmp_lints/test/support/rule_test_base.dart new file mode 100644 index 00000000..8a2e2f02 --- /dev/null +++ b/app/packages/fmp_lints/test/support/rule_test_base.dart @@ -0,0 +1,60 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:analyzer/diagnostic/diagnostic.dart'; +import 'package:analyzer_testing/analysis_rule/analysis_rule.dart'; + +/// 每條規則的測試基底:把程式碼寫到 test package 內指定的相對路徑,只看 +/// 受測規則的診斷(未解析的名稱等編譯錯誤不參與比對)。 +/// +/// 路徑一律寫 posix(`lib/ui/foo.dart`);`analyzer_testing` 在 Windows 上, +/// 或設了 `TEST_ANALYZER_WINDOWS_PATHS=true` 時轉成 Windows 路徑,所以同一組 +/// 測試兩種分隔符都跑得到。 +abstract class FmpRuleTest extends AnalysisRuleTest { + /// 受測規則的新實例。 + AnalysisRule createRule(); + + /// 在 `super.setUp()` 之前建立的假套件,例如 `newPackage('dio')`。 + void addStubPackages() {} + + @override + void setUp() { + rule = createRule(); + addStubPackages(); + super.setUp(); + } + + /// 把 [markedCode] 寫到 [relativePath],斷言受測規則正好在每個 + /// `[!…!]` 標記的範圍報一次;沒有標記就是斷言不報。 + Future assertLints(String relativePath, String markedCode) async { + final (code, ranges) = _stripMarkers(markedCode); + final path = '$testPackageRootPath/$relativePath'; + newFile(path, code); + result = await resolveFile(convertPath(path)); + assertDiagnosticsIn( + [ + for (final diagnostic in result.diagnostics) + if (diagnostic.diagnosticCode.lowerCaseName == rule.name) diagnostic, + ], + [for (final (offset, length) in ranges) lint(offset, length)], + ); + } +} + +(String, List<(int, int)>) _stripMarkers(String marked) { + final code = StringBuffer(); + final ranges = <(int, int)>[]; + int? start; + for (var i = 0; i < marked.length;) { + if (marked.startsWith('[!', i)) { + start = code.length; + i += 2; + } else if (marked.startsWith('!]', i)) { + ranges.add((start!, code.length - start)); + start = null; + i += 2; + } else { + code.write(marked[i]); + i++; + } + } + return (code.toString(), ranges); +} diff --git a/app/pubspec.lock b/app/pubspec.lock index 45add73b..52c273fc 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -1,6 +1,46 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "1b0e6a07425a3e460666e88bf1c949ccc7bb0116ad562ce94a1eca60fe820725" + url: "https://pub.dev" + source: hosted + version: "103.0.0" + analysis_server_plugin: + dependency: transitive + description: + name: analysis_server_plugin + sha256: c58b2a05b260a023eefd0848496ff6b113a318c6c95dad4215165a4bd54ae600 + url: "https://pub.dev" + source: hosted + version: "0.3.18" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "61c04d0c1bfed555c681ea079519933f071a5a026578ff73c4ff0df2d3462e5e" + url: "https://pub.dev" + source: hosted + version: "13.3.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: c2a75baf1657171cc7872db96a12b07e385873cf7a73f2b4f7638e9d0701ee53 + url: "https://pub.dev" + source: hosted + version: "0.14.12" + analyzer_testing: + dependency: transitive + description: + name: analyzer_testing + sha256: "33ec8a3138134e9048969a70a670ac04a37e28337982c3ed6d04a3d28ac87665" + url: "https://pub.dev" + source: hosted + version: "0.3.2" args: dependency: transitive description: @@ -33,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" clock: dependency: transitive description: @@ -57,6 +105,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.dev" + source: hosted + version: "1.15.1" crypto: dependency: transitive description: @@ -73,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "82ade9fc4273f29ed673e33166944465225b4f7fc5d4aaef48605cc751c18fc1" + url: "https://pub.dev" + source: hosted + version: "3.1.13" fake_async: dependency: transitive description: @@ -89,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -112,6 +192,22 @@ packages: description: flutter source: sdk version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" hooks: dependency: transitive description: @@ -120,6 +216,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" intl: dependency: transitive description: @@ -128,6 +240,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.3" + io: + dependency: transitive + description: + name: io + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" + url: "https://pub.dev" + source: hosted + version: "1.1.0" jni: dependency: transitive description: @@ -224,6 +344,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.3" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" objective_c: dependency: transitive description: @@ -236,10 +372,10 @@ packages: dependency: transitive description: name: package_config - sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "2.2.0" path: dependency: "direct main" description: @@ -312,6 +448,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" + url: "https://pub.dev" + source: hosted + version: "1.5.3" pub_semver: dependency: transitive description: @@ -328,11 +472,59 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898" + url: "https://pub.dev" + source: hosted + version: "0.10.14" source_span: dependency: transitive description: @@ -373,6 +565,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + url: "https://pub.dev" + source: hosted + version: "1.31.1" test_api: dependency: transitive description: @@ -381,6 +581,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" + test_core: + dependency: transitive + description: + name: test_core + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + url: "https://pub.dev" + source: hosted + version: "0.6.18" + test_reflective_loader: + dependency: transitive + description: + name: test_reflective_loader + sha256: d828d5ca15179aaac2aaf8f510cf0a52ec28e0031681b044ec5e581a4b8002e7 + url: "https://pub.dev" + source: hosted + version: "0.4.0" typed_data: dependency: transitive description: @@ -405,6 +621,46 @@ packages: url: "https://pub.dev" source: hosted version: "15.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" xdg_directories: dependency: transitive description: @@ -421,6 +677,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.4" + yaml_edit: + dependency: transitive + description: + name: yaml_edit + sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" + url: "https://pub.dev" + source: hosted + version: "2.2.4" sdks: dart: ">=3.13.4 <4.0.0" flutter: ">=3.47.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index c783050c..0d363feb 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -6,10 +6,12 @@ version: 0.1.0+1 environment: sdk: ^3.13.4 -# pub workspace 的根(ADR 0015;成員從 PR 3 的 packages/fmp_lints 起加入)。 +# pub workspace 的根(ADR 0015)。成員和 app 共用一次解析,所以 flutter_test +# 的釘版也限制成員(packages/fmp_lints/pubspec.yaml 的 analyzer 版本)。 # analysis_server_plugin 的 `plugins:` 只能寫在 package 或 workspace 根, # 所以 app/ 本身就是根,不另設外層 pubspec。 -workspace: [] +workspace: + - packages/fmp_lints dependencies: flutter: diff --git a/app/test/identity/windows_identity_test.dart b/app/test/identity/windows_identity_test.dart index 27a1a5cf..cc848a83 100644 --- a/app/test/identity/windows_identity_test.dart +++ b/app/test/identity/windows_identity_test.dart @@ -131,13 +131,8 @@ IdentityResult runIdentity(String? flavor) { final String _cmake = _findCmake(); String _findCmake() { - try { - if (Process.runSync('cmake', ['--version']).exitCode == 0) { - return 'cmake'; - } - } on ProcessException { - // PATH 上沒有,改找 Visual Studio 附的 cmake(flutter 建置 Windows 也用它)。 - } + if (_cmakeOnPath()) return 'cmake'; + // PATH 上沒有,改找 Visual Studio 附的 cmake(flutter 建置 Windows 也用它)。 const vswhere = r'C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe'; if (File(vswhere).existsSync()) { @@ -155,3 +150,11 @@ String _findCmake() { } throw StateError('cmake not found on PATH or via vswhere'); } + +bool _cmakeOnPath() { + try { + return Process.runSync('cmake', ['--version']).exitCode == 0; + } on ProcessException { + return false; + } +} diff --git a/app/test/static_rules/material_import_static_rule_test.dart b/app/test/static_rules/material_import_static_rule_test.dart deleted file mode 100644 index e5995618..00000000 --- a/app/test/static_rules/material_import_static_rule_test.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:path/path.dart' as p; - -/// `app/` 用獨立套件 `material_ui`,不 import 框架內凍結的設計系統函式庫 -/// (app/AGENTS.md § Material)。M1 PR 3 的 `fmp_lints` 接手後刪掉本檔。 -void main() { - test('lib/ and test/ import no in-framework design library', () { - final offenders = [ - for (final directory in ['lib', 'test']) - for (final file in Directory(directory).listSync(recursive: true)) - if (file is File && file.path.endsWith('.dart')) - for (final uri in frameworkDesignImports(file.readAsStringSync())) - '${p.normalize(file.path)}: $uri', - ]; - expect(offenders, isEmpty); - }); - - group('mutations', () { - test('an import or export of the frozen libraries is reported', () { - expect( - frameworkDesignImports( - "import 'package:flutter/material.dart';\n" - 'import "package:flutter/cupertino.dart" as c;\n' - " export 'package:flutter/material.dart' show Colors;\n", - ), - [ - 'package:flutter/material.dart', - 'package:flutter/cupertino.dart', - 'package:flutter/material.dart', - ], - ); - }); - - test('the standalone packages, other flutter libraries and comments ' - 'are not', () { - expect( - frameworkDesignImports( - "import 'package:material_ui/material_ui.dart';\n" - 'import "package:flutter/widgets.dart";\n' - "// import 'package:flutter/material.dart';\n" - "/// See package:flutter/material.dart for the frozen copy.\n", - ), - isEmpty, - ); - }); - }); -} - -final _directive = RegExp( - r'''^\s*(?:import|export)\s+['"](package:flutter/(?:material|cupertino)\.dart)['"]''', - multiLine: true, -); - -/// [source] 裡 import/export 框架內 Material 或 Cupertino 的 URI。 -List frameworkDesignImports(String source) => [ - for (final match in _directive.allMatches(source)) match.group(1)!, -]; diff --git a/app/tool/lint_sentinel.dart b/app/tool/lint_sentinel.dart new file mode 100644 index 00000000..e48633c1 --- /dev/null +++ b/app/tool/lint_sentinel.dart @@ -0,0 +1,147 @@ +// 接線哨兵(ADR 0015 §決定 2):證明 fmp_lints 真的接上了 app/。 +// +// 暫放違反每條規則的檔案,跑 `dart analyze --fatal-infos`,斷言分析失敗、 +// 而且暫放檔的診斷含 analysis_options.yaml 開啟的每一條 fmp_ 規則;結束時 +// (含失敗、Ctrl-C)刪除暫放檔。插件沒載入、編譯失敗或規則沒開時, +// `dart analyze` 可能照樣通過,只有這支會紅。 +// +// 在 app/ 執行:dart run tool/lint_sentinel.dart +import 'dart:async'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +/// 暫放檔:相對 app/ 的路徑 → 內容。`fmp_test_waits` 只管 `test/`, +/// `fmp_design_tokens` 只管 `lib/ui/`,所以放兩個位置。 +const _violations = { + 'lib/ui/lint_sentinel_violations.dart': r''' +// tool/lint_sentinel.dart 暫放的違規檔,結束時刪除。 +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart' as m; + +class Dio { + Dio(); +} + +int resetForTesting() => 0; + +// ignore: fmp_lints/fmp_url_literal +const harmless = 0; + +void violations(m.BuildContext context) { + try { + print('x'); + } catch (_) {} + const id = 'bilibili'; + const url = 'https://example.com'; + Dio(); + Platform.isAndroid; + m.ScaffoldMessenger.of(context); + const m.EdgeInsets.all(8); +} +''', + 'test/lint_sentinel_violations.dart': r''' +// tool/lint_sentinel.dart 暫放的違規檔,結束時刪除。 +Future wait() => pumpEventQueue(); +''', +}; + +Future main() async { + final appRoot = p.dirname(p.dirname(p.fromUri(Platform.script))); + final expected = _enabledRules( + File(p.join(appRoot, 'analysis_options.yaml')).readAsStringSync(), + ); + if (expected.isEmpty) { + _fail('analysis_options.yaml enables no fmp_lints rules.'); + } + + final files = [ + for (final relative in _violations.keys) + File(p.join(appRoot, p.joinAll(relative.split('/')))), + ]; + // 暫放檔的目錄可能還不存在(例如還沒有 lib/ui/);自己建的也自己刪。 + final createdDirectories = []; + void cleanUp() { + for (final file in files) { + if (file.existsSync()) file.deleteSync(); + } + for (final directory in createdDirectories.reversed) { + if (directory.existsSync()) directory.deleteSync(); + } + } + + final interrupted = ProcessSignal.sigint.watch().listen((_) { + cleanUp(); + exit(130); + }); + + ProcessResult result; + try { + for (final (index, file) in files.indexed) { + if (!file.parent.existsSync()) { + createdDirectories.add(file.parent..createSync()); + } + file.writeAsStringSync(_violations.values.elementAt(index)); + } + stdout.writeln('Running dart analyze --fatal-infos with violation files…'); + result = await Process.run(Platform.resolvedExecutable, [ + 'analyze', + '--fatal-infos', + ], workingDirectory: appRoot); + } finally { + cleanUp(); + await interrupted.cancel(); + } + + final output = '${result.stdout}\n${result.stderr}'; + final found = { + for (final line in output.split(RegExp(r'\r?\n'))) + if (_violations.keys.any((path) => line.contains(p.basename(path)))) + if (RegExp(r' - (fmp_\w+)\s*$').firstMatch(line) case final match?) + match.group(1)!, + }; + stdout.writeln( + 'fmp rules reported on the violation files (${found.length}):', + ); + for (final name in found.toList()..sort()) { + stdout.writeln(' $name'); + } + + final missing = expected.where((name) => !found.contains(name)).toList(); + if (result.exitCode == 0) { + _fail('dart analyze passed with the violation files in place.', output); + } + if (missing.isNotEmpty) { + _fail( + 'Enabled rules not reported: ${missing.join(', ')}. Either the plugin ' + 'is not wired, or a violation for the rule is missing in ' + 'tool/lint_sentinel.dart.', + output, + ); + } + stdout.writeln( + 'OK: dart analyze failed (exit ${result.exitCode}) and reported all ' + '${expected.length} enabled fmp rules.', + ); +} + +/// `plugins: fmp_lints: diagnostics:` 裡設成 `true` 的規則名。 +List _enabledRules(String analysisOptions) { + final yaml = loadYaml(analysisOptions) as YamlMap; + final plugin = (yaml['plugins'] as YamlMap?)?['fmp_lints'] as YamlMap?; + final diagnostics = plugin?['diagnostics'] as YamlMap?; + return [ + for (final MapEntry(:key, :value) in (diagnostics ?? YamlMap()).entries) + if (value == true) key as String, + ]; +} + +Never _fail(String message, [String? output]) { + if (output != null) stderr.writeln(output); + stderr.writeln('lint_sentinel FAILED: $message'); + exit(1); +} From 85da264ae7945310e13f7eaed62c00bd2f51c0ae Mon Sep 17 00:00:00 2001 From: 1morr Date: Tue, 29 Sep 2026 11:48:57 +0800 Subject: [PATCH 2/5] docs(agents): run dart analyze for app tasks --- .claude/agents/trellis-check.md | 2 +- .claude/agents/trellis-implement.md | 2 +- .trellis/spec/app/lints/index.md | 51 +++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 .trellis/spec/app/lints/index.md diff --git a/.claude/agents/trellis-check.md b/.claude/agents/trellis-check.md index 647a35cc..31043f2d 100644 --- a/.claude/agents/trellis-check.md +++ b/.claude/agents/trellis-check.md @@ -83,7 +83,7 @@ After finding issues: FMP is a Flutter app: "lint and typecheck" is `flutter analyze`. Read the task's `package` from `task.json` first: `legacy` verifies against `lib/AGENTS.md`, `app` against `app/AGENTS.md`. Run, in order: 1. Codegen when a model or `*.i18n.json` changed, or `*.g.dart` is missing: `dart run build_runner build`, `dart run slang`. Stale codegen fails as a missing getter that looks like a source bug. -2. Format, then analyze. `legacy`: `dart format lib test tool`, then `flutter analyze`, at the repo root. `app`: inside `app/`, `dart format --output=none --set-exit-if-changed .`, then `flutter analyze`. +2. Format, then analyze. `legacy`: `dart format lib test tool`, then `flutter analyze`, at the repo root. `app`: inside `app/`, `dart format --output=none --set-exit-if-changed .`, then `dart analyze --fatal-infos` (the only one that shows the `fmp_lints` / `riverpod_lint` plugin diagnostics) and `flutter analyze`. 3. The tests for every changed area: the matching rows of the package's `AGENTS.md` § Verification / § 驗證, plus the Quality Check section of each touched `.trellis/spec///index.md`. If anything fails, fix it and re-run. diff --git a/.claude/agents/trellis-implement.md b/.claude/agents/trellis-implement.md index 72fe0626..262fdb86 100644 --- a/.claude/agents/trellis-implement.md +++ b/.claude/agents/trellis-implement.md @@ -79,7 +79,7 @@ Read the task's prd.md, design.md if present, and implement.md if present: FMP is a Flutter app: "lint and typecheck" is `flutter analyze`. Read the task's `package` from `task.json` first: `legacy` verifies against `lib/AGENTS.md` § Verification, `app` against `app/AGENTS.md` § 驗證. 1. Codegen when a model or `*.i18n.json` changed, or `*.g.dart` is missing: `dart run build_runner build`, `dart run slang`. -2. Format, then analyze. `legacy`: `dart format lib test tool`, then `flutter analyze`, at the repo root. `app`: inside `app/`, `dart format --output=none --set-exit-if-changed .`, then `flutter analyze`. +2. Format, then analyze. `legacy`: `dart format lib test tool`, then `flutter analyze`, at the repo root. `app`: inside `app/`, `dart format --output=none --set-exit-if-changed .`, then `dart analyze --fatal-infos` (the only one that shows the `fmp_lints` / `riverpod_lint` plugin diagnostics) and `flutter analyze`. 3. The tests named by the matching rows of the package's § Verification / § 驗證, plus the tests you wrote. If the change is user-visible, say so in the report: on-device verification is the main session's job (`verify-legacy-on-device` skill for `legacy`, `verify-on-device` for `app`). diff --git a/.trellis/spec/app/lints/index.md b/.trellis/spec/app/lints/index.md new file mode 100644 index 00000000..2203f2ef --- /dev/null +++ b/.trellis/spec/app/lints/index.md @@ -0,0 +1,51 @@ +# Lint 規則(`app/packages/fmp_lints/`) + +改或加 `fmp_` 規則時適用。每條規則守什麼、允許清單在哪,見 `app/AGENTS.md` § Lint; +為什麼是這些規則,見 ADR 0015 §決定 2。這裡只寫怎麼寫。 + +## 一條規則的形狀 + +- 一條規則一個檔案:`lib/src/rules/<規則名去掉 fmp_>.dart`,裡面是 `AnalysisRule` 子類別加 + `SimpleAstVisitor`。 +- `LintCode` 放成 `static const`(唯一實例,`// ignore:` 才對得上),名稱 `fmp_…`, + `severity: DiagnosticSeverity.WARNING`。訊息用英文,比照 log 字串。 +- 允許清單、套件清單寫成同一個檔案頂端的具名常數,路徑一律相對 package 根、以 `/` + 分隔、不帶結尾斜線(`lib/ui/toast`)。 +- 判斷檔案位置只用 `PackagePath.of(context)`(`lib/src/package_path.dart`)與它的 + `isIn`/`isInLib`/`isInTest`,不自己處理分隔符。 +- 名稱比對帶 import 前綴的寫法(`m.ScaffoldMessenger`)用 `lib/src/ast_names.dart` 的 + `isNamedReference`。 +- 在 `lib/main.dart` 的 `fmpRules()` 登記,並在 `app/analysis_options.yaml` 的 + `diagnostics:` 開啟;`test/plugin_test.dart` 會比對兩邊。 + +## 測試(雙向變異) + +每條規則一個 `test/rules/<規則>_test.dart`,繼承 `test/support/rule_test_base.dart` 的 +`FmpRuleTest`: + +```dart +Future test_rawValuesInUi() => assertLints('lib/ui/search/page.dart', ''' +const a = EdgeInsets.all([!8!]); +'''); +``` + +- `[!…!]` 標出預期的診斷範圍;沒有標記就是斷言不報。只比對受測規則的診斷,未解析的 + 名稱等編譯錯誤不參與。 +- 至少一個「報」的案例,和至少一個相鄰但不該報的案例:允許目錄內、改名、改格式,或 + 在註解與字串裡提到同樣的字。 +- 依賴解析結果的寫法(`Dio()` 要解析成建構子呼叫)在 `addStubPackages` 裡用 + `newPackage(...)` 造最小的假套件。假宣告要照真套件的形狀:Flutter 的 `debugPrint` 是 + 函式型別的頂層變數,呼叫解析成 `FunctionExpressionInvocation` 而不是 + `MethodInvocation`;名稱沒解析到時兩者都是 `MethodInvocation`,測試會綠、實際卻漏報。 +- 本機在 Windows 上跑的是 Windows 路徑;CI 另外以 `TEST_ANALYZER_WINDOWS_PATHS=true` 再跑一次。 + +## 哨兵 + +在 `app/tool/lint_sentinel.dart` 的 `_violations` 加一行違反新規則的程式碼。沒加的話, +哨兵會因為「開啟的規則沒被報」而失敗。 + +## Quality Check + +- `packages/fmp_lints/` 內 `dart test` 全綠。 +- `app/` 內 `dart analyze --fatal-infos` 乾淨,`dart run tool/lint_sentinel.dart` 通過。 +- `app/AGENTS.md` § Lint 的表格有新規則的一列。 From fa84f7e9fb9bbb1c2ba09077428b86c0bd746f57 Mon Sep 17 00:00:00 2001 From: 1morr Date: Tue, 29 Sep 2026 11:48:58 +0800 Subject: [PATCH 3/5] docs(adr): note fmp_material_import in adr 0015 --- docs/adr/0015-testing-gates-and-dev-environment.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/adr/0015-testing-gates-and-dev-environment.md b/docs/adr/0015-testing-gates-and-dev-environment.md index 339aa7b4..cf831c74 100644 --- a/docs/adr/0015-testing-gates-and-dev-environment.md +++ b/docs/adr/0015-testing-gates-and-dev-environment.md @@ -49,6 +49,7 @@ log 只經門面、禁止空 catch、音源 id 不得出現在 UI 與 service、 | `fmp_platform_checks` | `Platform.isXxx`、`defaultTargetPlatform`、`Platform.operatingSystem` 只在平台層 | 後續 ADR 新增的規則:`fmp_periodic_timer_owner`(ADR 0017;ADR 0021 加入桌面歌詞查游標的允許擁有者)、`fmp_toast_entry`(ADR 0023)、`fmp_design_tokens`(ADR 0024)。 + M1 PR 3 另加 `fmp_material_import`:守 ADR 0024 §決定 1 的 import 路徑(不 import 框架內的 `material.dart`、`cupertino.dart`),接手 PR 2 的 static-rule 測試。 每條規則以官方 `analyzer_testing` 做雙向變異測試。`flutter analyze` 目前不顯示插件診斷並回報 No issues (flutter/flutter#193203),所以 CI 跑 `dart analyze --fatal-infos`,並以接線哨兵(暫放違規檔、斷言分析失敗且含規則名)證明規則接上了 `app/`。 From a927bc31bf158eb54e10e1441c50a94b86e0773e Mon Sep 17 00:00:00 2001 From: 1morr Date: Tue, 29 Sep 2026 11:48:58 +0800 Subject: [PATCH 4/5] chore(task): note m1 follow-ups from the lint pr --- .trellis/tasks/09-28-m1-skeleton-tracer/design.md | 3 ++- .trellis/tasks/09-28-m1-skeleton-tracer/implement.md | 7 ++++++- .trellis/tasks/09-28-m1-skeleton-tracer/task.json | 3 ++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/design.md b/.trellis/tasks/09-28-m1-skeleton-tracer/design.md index 820d26f0..f17a2166 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/design.md +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/design.md @@ -73,7 +73,8 @@ app/ lib/ main.dart # 由 flavor 決定身分;組 ProviderScope(retry 關閉) app/ # MaterialApp、路由、外殼、ToastHost - core/ # logging/、redaction/、errors/、network/、settings/ + core/ # logging/、redaction/、errors/、network/、endpoints.dart;不 import data/ 以上 + settings/ # 各組設定的 Notifier(讀 data/ 的 repository) data/ # drift database、tables、repositories domain/ # TrackKey 等純型別 platform/ # 每能力一目錄:<能力>.dart+<能力>_<平台>.dart diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md b/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md index bab43ee4..27232f7c 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md @@ -94,7 +94,8 @@ ### 6. 設定與 log -- [ ] 設定的 Notifier 模式(外觀組)。 +- [ ] 設定的 Notifier 模式(外觀組),放在 `lib/settings/`。 +- [ ] 第一次用 Riverpod:`main.dart` 包 `ProviderScope`,並在 `app/analysis_options.yaml` 重新開啟 `riverpod_lint` 的 `missing_provider_scope`(PR 3 暫時關閉)。 - [ ] log 門面、遮蔽函式;log 檔:JSON Lines、2MB×3、在 `logs/`。 - 測試:遮蔽,含 stackTrace(ADR 0011 §如何確認);檔案輪替;壞行略過。 @@ -201,6 +202,10 @@ - [ ] 更新 `milestones.md` 的 M1 狀態與勾選;開 Linux 平台任務。 - [ ] 本任務 `finish`、`archive`。 +## 待升級 + +- `analysis_server_plugin`、`analyzer`、`analyzer_testing` 停在 0.3.18/13.3.0/0.3.2:Flutter 3.47.5 的 `flutter_test` 釘 `test_api 0.7.12`,把 analyzer 限制在 14 以下(PR 3 發現)。Flutter 放寬後三個一起升到最新。 + ## 風險與回滾點 | 風險 | 處理 | diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/task.json b/.trellis/tasks/09-28-m1-skeleton-tracer/task.json index 1776385d..772d57f0 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/task.json +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/task.json @@ -20,7 +20,8 @@ "subtasks": [], "children": [ "09-29-split-agent-instructions", - "09-29-app-skeleton" + "09-29-app-skeleton", + "09-29-fmp-lints" ], "parent": "09-26-fmp-rewrite", "relatedFiles": [], From 767eb1ae3a6c8184e71707b973383eec22040aee Mon Sep 17 00:00:00 2001 From: 1morr Date: Tue, 29 Sep 2026 11:48:59 +0800 Subject: [PATCH 5/5] chore(task): archive fmp-lints --- .../2026-09/09-29-fmp-lints/check.jsonl | 4 + .../2026-09/09-29-fmp-lints/implement.jsonl | 4 + .../archive/2026-09/09-29-fmp-lints/prd.md | 91 +++++++++++++ .../2026-09/09-29-fmp-lints/research/notes.md | 126 ++++++++++++++++++ .../archive/2026-09/09-29-fmp-lints/task.json | 26 ++++ 5 files changed, 251 insertions(+) create mode 100644 .trellis/tasks/archive/2026-09/09-29-fmp-lints/check.jsonl create mode 100644 .trellis/tasks/archive/2026-09/09-29-fmp-lints/implement.jsonl create mode 100644 .trellis/tasks/archive/2026-09/09-29-fmp-lints/prd.md create mode 100644 .trellis/tasks/archive/2026-09/09-29-fmp-lints/research/notes.md create mode 100644 .trellis/tasks/archive/2026-09/09-29-fmp-lints/task.json diff --git a/.trellis/tasks/archive/2026-09/09-29-fmp-lints/check.jsonl b/.trellis/tasks/archive/2026-09/09-29-fmp-lints/check.jsonl new file mode 100644 index 00000000..e577d4af --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-fmp-lints/check.jsonl @@ -0,0 +1,4 @@ +{"file": ".trellis/tasks/09-28-m1-skeleton-tracer/design.md", "reason": "Directory layout the allow-lists refer to"} +{"file": ".trellis/tasks/09-28-m1-skeleton-tracer/research/m1-tooling-facts.md", "reason": "analysis_server_plugin, analyzer_testing and riverpod_lint facts"} +{"file": "docs/adr/0015-testing-gates-and-dev-environment.md", "reason": "Lint rule table and gates"} +{"file": ".trellis/spec/app/testing/index.md", "reason": "Existing app test conventions"} diff --git a/.trellis/tasks/archive/2026-09/09-29-fmp-lints/implement.jsonl b/.trellis/tasks/archive/2026-09/09-29-fmp-lints/implement.jsonl new file mode 100644 index 00000000..e577d4af --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-fmp-lints/implement.jsonl @@ -0,0 +1,4 @@ +{"file": ".trellis/tasks/09-28-m1-skeleton-tracer/design.md", "reason": "Directory layout the allow-lists refer to"} +{"file": ".trellis/tasks/09-28-m1-skeleton-tracer/research/m1-tooling-facts.md", "reason": "analysis_server_plugin, analyzer_testing and riverpod_lint facts"} +{"file": "docs/adr/0015-testing-gates-and-dev-environment.md", "reason": "Lint rule table and gates"} +{"file": ".trellis/spec/app/testing/index.md", "reason": "Existing app test conventions"} diff --git a/.trellis/tasks/archive/2026-09/09-29-fmp-lints/prd.md b/.trellis/tasks/archive/2026-09/09-29-fmp-lints/prd.md new file mode 100644 index 00000000..13e6e670 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-fmp-lints/prd.md @@ -0,0 +1,91 @@ +# fmp_lints 與接線哨兵(M1 PR 3) + +父任務:`../09-28-m1-skeleton-tracer`(design §2、§3「M1 的 lint」;implement「3.」)。 + +依據: +- ADR 0015 §決定 2(規則表、`analyzer_testing`、接線哨兵); +- 各 ADR 的「如何確認」:0008、0009、0010、0016、0018、0020、0021、0022 的 `fmp_layer_imports`;0023 `fmp_toast_entry`;0024 `fmp_design_tokens`。 + +## 做什麼 + +1. **套件**: + - `app/packages/fmp_lints/`,用官方 `analysis_server_plugin`(目前 stable,版本以 pub.dev 為準,`research/m1-tooling-facts.md` 記 0.3.23)。 + - 加進 `app/pubspec.yaml` 的 `workspace:`。 + - `app/analysis_options.yaml` 以頂層 `plugins:` 接上 `fmp_lints`,並在 `diagnostics:` 逐條開啟。 + - 寫法先以 context7 或官方文件查證,把來源記在 `research/notes.md`。 +2. **規則**:12 條。 + - ADR 0015 的十條核心規則,加上 `fmp_toast_entry`、`fmp_design_tokens`;`fmp_periodic_timer_owner` 留到 M2。 + - 允許清單用 `app/lib/` 的相對路徑,照父任務 design §2 的目錄: + + | 規則 | M1 的允許清單 | + |---|---| + | `fmp_layer_imports` | 依賴方向表見下 | + | `fmp_no_empty_catch` | 無豁免;catch 本體沒有陳述式就違規,變數名 `_` 不豁免 | + | `fmp_log_facade` | `print`、`debugPrint`、`dart:developer` 的 `log`、`package:talker*` 只准在 `lib/core/logging/` | + | `fmp_source_id_literal` | 官方插件 id 字串(M1 只有 `bilibili`;清單集中在規則內一處,M3 加 `youtube`、`netease`)只准在 `lib/legacy_import/` 與 `test/` | + | `fmp_url_literal` | `http://`、`https://` 字面值只准在一個端點檔 `lib/core/endpoints.dart`(本 PR 不建,第一個需要網址的 PR 建) | + | `fmp_no_for_testing` | `lib/` 不得宣告名稱以 `ForTesting` 結尾的成員 | + | `fmp_http_client_owner` | `Dio(` 只准在 `lib/core/network/` | + | `fmp_test_waits` | `test/` 內直接呼叫 `pumpEventQueue` 只准在 `test/support/pump_until.dart` | + | `fmp_ignore_reason` | `// ignore: fmp_…` 與 `// ignore_for_file: fmp_…` 同一行必須寫理由(規則名之後有 ` — ` 或 ` - ` 加文字) | + | `fmp_platform_checks` | `Platform.isXxx`、`Platform.operatingSystem`、`defaultTargetPlatform`、`TargetPlatform` 只准在 `lib/platform/` | + | `fmp_toast_entry` | `SnackBar(`、`ScaffoldMessenger.of`/`.maybeOf`、`showSnackBar`、`clearSnackBars` 只准在 `lib/ui/toast/` | + | `fmp_design_tokens` | `lib/ui/`(`lib/ui/theme/` 除外):`EdgeInsets.*`、`EdgeInsetsDirectional.*`、`SizedBox` 的寬高與 `SizedBox.fromSize` 的 `Size(`、`BorderRadius.circular`/`.all`、`BorderRadiusDirectional.*`、`Radius.circular`/`.elliptical`(含巢狀)、`fontSize:` 不得用數字字面值(`0` 除外);`Color(…)`、`Color.fromARGB`/`fromRGBO`/`from` 不得有數字字面值(含 `0`);不得寫 `Colors.*` | + + **`fmp_layer_imports` 的依賴方向表**: + - `app/` 不得 import 根目錄舊專案,也就是以相對路徑跳出 `app/`,或 `package:fmp/` 指向舊專案。`app/` 的 package 名也是 `fmp`,所以只能用路徑判斷。 + - `lib/legacy_import/` 不被其他目錄 import;`package:isar_community*` 只准在 `lib/legacy_import/`。 + - `package:drift*`、`package:sqlite3*` 只准在 `lib/data/`。 + - 平台套件只准在 `lib/platform/`: + - `path_provider`、`window_manager`、`tray_manager`、`hotkey_manager`、`launch_at_startup`; + - `desktop_multi_window`、`flutter_overlay_window`; + - `permission_handler`、`smtc_windows`、`audio_service`、`audio_service_mpris`; + - `connectivity_plus`、`file_picker`、`package_info_plus`; + - `flutter_inappwebview`、`flutter_secure_storage`。 + + 清單集中一處,之後的 ADR 再加。 + - `package:just_audio*`、`package:media_kit*` 只准在 `lib/playback/backends/`。 + - `package:dio*` 只准在 `lib/core/network/`。 + - `package:flutter_js*` 只准在 `lib/plugins/runtime/`。 + - `package:background_downloader*` 只准在 `lib/downloads/`(M6 才有,先列入)。 + - `lib/core/` 與 `lib/domain/` 不 import `lib/ui/`、`lib/playback/`、`lib/plugins/`、`lib/data/`、`lib/settings/`。設定的 Notifier 讀資料層,所以放在 `lib/settings/`,不在 `core/`(本 PR 同步修正父任務 design §2)。 + - `lib/data/` 不 import `lib/ui/`。 + - ADR 0018 的細部規則(結束原因型別、串流窄介面)在 PR 10 加。 +3. **`material_ui` 的閘門**: + - PR 2 的 `test/static_rules/material_import_static_rule_test.dart` 改成規則 `fmp_material_import`:`lib/` 不得 import `package:flutter/material.dart` 與 `package:flutter/cupertino.dart`。 + - 刪掉那個測試。 + - 在 ADR 0015 的「後續 ADR 新增的規則」補一句,說明它守的是 ADR 0024 §決定 1 的 import 路徑。 +4. **`riverpod_lint`**: + - 若已支援新插件系統(研究說 3.1.9 依賴 `analysis_server_plugin ^0.3.0`),一起接上 `plugins:`; + - 不行就記在 notes,留到第一個用 Riverpod 的 PR。 +5. **測試**: + - 每條規則用 `analyzer_testing` 做雙向變異: + - 至少一個違規案例會報; + - 至少一個相鄰、無關的寫法不報:允許目錄內、重新命名、改格式、註解或字串裡提到規則字樣。 + - 在 `app/packages/fmp_lints/` 內 `dart test`。 +6. **接線哨兵**:一支可在 CI 與本機跑的 Dart 腳本 `app/tool/lint_sentinel.dart`。 + - 在 `app/lib/` 暫放一個違反每條規則的檔案,跑 `dart analyze --fatal-infos`; + - 斷言失敗,而且輸出含每一條規則名; + - 最後刪掉暫放檔,失敗時也要刪。 +7. **CI**:`app` job 加三步: + - `dart analyze --fatal-infos`(在 `app/`); + - 哨兵; + - `fmp_lints` 的測試。 + + `flutter analyze` 留著,因為它有 Flutter 專屬的診斷。在註解裡寫明 flutter/flutter#187999。 +8. **文件**: + - `app/AGENTS.md` 加 lint 段,列每條規則名、它守什麼、允許清單在哪裡改(ADR 0015 §如何確認:列出的每條規則寫對應規則名); + - 原本由 static-rule 測試守的 `material_ui` 那句改指規則名; + - 建 `.trellis/spec/app/lints/index.md`(繁中),寫新規則怎麼加(雙向變異、哨兵、AGENTS.md); + - 不建 `spec/app/index.md`。 + - `trellis-check.md`、`trellis-implement.md` 的 `app` 分支加 `dart analyze --fatal-infos`。 + +## 驗收 + +- [ ] `app/`:`dart analyze --fatal-infos` 零問題;`flutter analyze`、`flutter test` 通過。 +- [ ] `app/packages/fmp_lints/`:`dart test` 全綠,12+1 條規則各有報與不報的案例。 +- [ ] 哨兵在本機會紅,輸出含 13 個規則名;暫放檔在結束後不存在。 +- [ ] 實測(§7): + - 同一個違規檔,`dart analyze` 報出插件診斷,`flutter analyze` 看不到; + - 把兩者的輸出寫進 PR 描述。 +- [ ] CI 的 `app` job 綠。 diff --git a/.trellis/tasks/archive/2026-09/09-29-fmp-lints/research/notes.md b/.trellis/tasks/archive/2026-09/09-29-fmp-lints/research/notes.md new file mode 100644 index 00000000..94c2c89e --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-fmp-lints/research/notes.md @@ -0,0 +1,126 @@ +# fmp_lints 研究筆記 + +- 日期:2026-09-29 +- 環境:Flutter 3.47.5/Dart 3.13.4,Windows 11 +- context7 在實作子代理的環境裡沒有提供,改讀官方文件(Dart SDK repo 的 + `pkg/analysis_server_plugin/doc/*.md`,main 分支)、pub.dev JSON API,與 pub cache 內 + 套件原始碼。每條後面附來源。 + +## 1. 版本:為什麼不是 pub.dev 最新的一組 + +pub.dev 當日最新:`analysis_server_plugin` 0.3.23、`analyzer_testing` 0.4.2(兩者都釘 +`analyzer` 14.4.0)、`test` 1.32.0(`https://pub.dev/api/packages/`)。 + +`fmp_lints` 是 `app/` workspace 的成員(PRD §1),和 `flutter_test` 共用一次解析: + +- Flutter 3.47.5 的 `flutter_test` 釘 `test_api` 0.7.12、`matcher` 0.12.20。 +- `test_api` 0.7.12 只配得上 `test` 1.31.1,而 `test` 1.31.1 要 `analyzer >=8.0.0 <14.0.0` + (pub cache `test-1.31.1/pubspec.yaml`;`test` 1.31.2 起改釘 `test_api` 0.7.13)。 +- `analyzer_testing` 依賴 `test ^1.25.0`,所以整個 workspace 的 `analyzer` 上限是 13.x。 +- `flutter pub get` 的實際錯誤(加 `test: any` 後): + `every version of flutter_test from sdk requires test_api 0.7.11 or 0.7.13 or 0.7.14 … + flutter_test from sdk which depends on test_api 0.7.12, version solving failed.` + +三個套件逐版互相釘死(pub.dev 版本清單): + +| analysis_server_plugin | analyzer_testing | analyzer | +|---|---|---| +| 0.3.18 | 0.3.2 | 13.3.0 | +| 0.3.19 | 0.3.3 | 14.0.0 | +| 0.3.23 | 0.4.2 | 14.4.0 | + +採用能解出來的最新一組:`analysis_server_plugin` 0.3.18、`analyzer` 13.3.0、 +`analyzer_testing` 0.3.2(`test` 解到 1.31.1)。 + +- `analyzer` 13.3.0 的 `_fe_analyzer_shared` 103.0.0 `defaultLanguageVersion` 是 3.13 + (`lib/src/experiments/flags.dart:9`),`app/` 的語言版本分析得了。 +- 插件在 analysis server 裡另外解析一個 synthetic package(見 §2),`fmp_lints` 的 + pubspec 釘 13.3.0,所以執行期也是 13.3.0,不會和測試用的版本分岔。 +- 升級條件:`flutter_test` 的 `test_api` 釘版到 0.7.13 以上(新 Flutter stable)時, + 三個一起升到當時最新,並依 analyzer 的 AST 變動改規則。 + +另一條路是把 `fmp_lints` 移出 workspace(自己一份 lock),可以用 14.4.0;沒採用,因為 +PRD §1 要求加進 workspace,而且 13.3.0 對目前的規則沒有缺的 API。 + +## 2. 插件怎麼接 + +來源:`pkg/analysis_server_plugin/doc/using_plugins.md`、`writing_a_plugin.md`、 +`writing_rules.md`、`testing_rules.md`(Dart SDK main)。 + +- 插件是一般 Dart package,入口固定是 `lib/main.dart` 的頂層變數 `plugin`(`Plugin` 子類別, + 覆寫 `register(PluginRegistry)`)。 +- `registry.registerWarningRule(rule)`:預設開啟;`registry.registerLintRule(rule)`:預設 + 關閉,要在 `diagnostics:` 開。PRD 要逐條開,所以全部用 `registerLintRule`。 +- `analysis_options.yaml` 用**頂層** `plugins:`(不是 `analyzer:` 底下),只能寫在 package 或 + workspace 根;值和 pubspec 依賴同格式(版本字串、`path:`、或 `version:` 加其他鍵的 map)。 + 實測 `path: packages/fmp_lints` 的相對路徑可用。 +- analysis server 為所有插件建一個 synthetic package,以 `dart pub upgrade` 解析,編成 AOT + snapshot 放在 `%LOCALAPPDATA%\.dartServer\.plugin_manager\`。實測插件編譯失敗時 + `dart analyze` 只印出錯誤訊息、照樣分析其他檔案 → 這就是哨兵要存在的原因。 +- 規則:`AnalysisRule` 子類別,`static const LintCode`(唯一實例,`// ignore:` 才對得上), + `registerNodeProcessors` 裡把 `SimpleAstVisitor` 註冊到 `RuleVisitorRegistry.addXxx`; + 回報用 `reportAtNode`/`reportAtToken`/`reportAtOffset`(`analyzer-13.3.0/lib/src/analysis_rule/analysis_rule.dart`)。 +- `LintCode` 的 `severity` 預設 `INFO`(`analyzer-13.3.0/lib/src/dart/error/lint_codes.dart:61`)。 + 全部設 `WARNING`:`dart analyze` 不加旗標也會失敗(exit 2),`--fatal-infos` 自然也失敗。 +- 路徑:`RuleContext.currentUnit.file` 與 `RuleContext.package.root` + (`analyzer-13.3.0/lib/analysis_rule/rule_context.dart`、`workspace/workspace.dart`);以 + `file.provider.pathContext` 算相對路徑再轉成 `/`,規則只比對 `lib/ui/...` 這種字串。 +- analyzer 13 的 AST:引數是 sealed `Argument`(`Expression` 或 `NamedArgument`), + `NamedArgument.name` 是 `Token`,登記用 `addNamedArgument`(沒有 `addNamedExpression`)。 +- 忽略語法是 `// ignore: fmp_lints/<規則名>`(`using_plugins.md` §Suppressing)。名稱清單之後 + 的文字,analyzer 解析成 `IgnoredDiagnosticComment`,不影響忽略本身 + (`analyzer-13.3.0/lib/src/ignore_comments/ignore_info.dart` 的 `ignoredElements`), + 所以 `— 理由` 可以直接接在後面。實測:`fmp_lints/lib/src/rules/source_id_literal.dart` 的 + `'bilibili'` 以帶理由的 ignore 放行,`dart analyze` 乾淨。 +- 巢狀的 `packages/fmp_lints/analysis_options.yaml`(`include: ../../analysis_options.yaml` + 再關 `non_constant_identifier_names`)不影響插件:實測插件照樣分析 `fmp_lints` 自己的檔案。 + +## 3. analyzer_testing + +來源:`testing_rules.md`;`analyzer_testing-0.3.2/lib/analysis_rule/analysis_rule.dart`、 +`src/analysis_rule/pub_package_resolution.dart`、`resource_provider_mixin.dart`。 + +- `AnalysisRuleTest`(`test_reflective_loader` 的 `@reflectiveTest`,方法名 `test_` 開頭)。 + `setUp` 設 `rule = …` 再 `super.setUp()`;它會把規則登記進 `Registry.ruleRegistry` 並寫一份 + 只開這條規則的 analysis options。 +- 記憶體檔案系統:test package 在 `/home/test`(`testPackageRootPath`),`newFile(path, …)` + 可以放在任意相對位置,例如 `$testPackageRootPath/lib/ui/page.dart`,再 + `resolveFile(convertPath(path))`。`assertDiagnosticsIn(diagnostics, [lint(offset, length)])` + 比對;失敗訊息會讀 `result` 欄位,所以要先把 `result` 設好。 +- `newPackage('dio').addFile('lib/dio.dart', …)` 造假套件,要在 `super.setUp()` 之前呼叫。 + mock SDK 有 `dart:io`(含 `Platform.isAndroid`),沒有 `dart:developer`。 +- **Windows 路徑**:`ResourceProviderMixin` 在 Windows 上,或環境變數 + `TEST_ANALYZER_WINDOWS_PATHS=true` 時,用 `path.windows` 的 `MemoryResourceProvider`。 + CI(ubuntu)因此跑兩次 `dart test`:預設 posix 一次、設這個變數一次。本機(Windows)的 + 一次跑的就是 Windows 路徑。 + +## 4. riverpod_lint + +- 3.1.9 依賴 `analysis_server_plugin ^0.3.0`、`analyzer >=13.0.0 <15.0.0` + (pub.dev API),README §Installing:`plugins: riverpod_lint: `。 +- 它的規則全部是 `registerWarningRule`(預設開啟;riverpod repo `packages/riverpod_lint/lib/main.dart`)。 +- 實測:和 `fmp_lints`(analyzer 13.3.0)一起放進 `plugins:` 能解析、能載入。 + `missing_provider_scope` 對 `lib/main.dart` 的 `runApp(FmpApp(...))` 報錯——`app/` 還沒有 + Riverpod。以 `diagnostics: missing_provider_scope: false` 暫時關掉;同一個 map 寫法把它切回 + `true` 會再報,證明插件有載入。第一個加 `ProviderScope` 的 PR 刪掉這一條。 + +## 5. `flutter analyze` 與 `dart analyze`(PRD §7 實測) + +同一個檔案 `lib/core/zz_measure.dart`(`catch (_) {}`): + +``` +$ dart analyze --fatal-infos +Analyzing app... + +warning - lib\core\zz_measure.dart:4:15 - Empty catch block. Handle the error: log it through the facade, rethrow, or map it to an AppError. - fmp_no_empty_catch + +1 issue found. +(exit 2) + +$ flutter analyze +Analyzing app... +No issues found! (ran in 8.8s) +(exit 0) +``` + +flutter/flutter#187999(open)描述的就是這個現象。 diff --git a/.trellis/tasks/archive/2026-09/09-29-fmp-lints/task.json b/.trellis/tasks/archive/2026-09/09-29-fmp-lints/task.json new file mode 100644 index 00000000..0fffbe0a --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-fmp-lints/task.json @@ -0,0 +1,26 @@ +{ + "id": "fmp-lints", + "name": "fmp-lints", + "title": "fmp_lints 與接線哨兵", + "description": "M1 PR 3: app/packages/fmp_lints with analysis_server_plugin rules, analyzer_testing two-way mutation tests, wiring sentinel and dart analyze in CI", + "status": "completed", + "dev_type": null, + "scope": null, + "package": "app", + "priority": "P2", + "creator": "1morr", + "assignee": "1morr", + "createdAt": "2026-09-29", + "completedAt": "2026-09-29", + "branch": "feat/fmp-lints", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "09-28-m1-skeleton-tracer", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file