diff --git a/.github/workflows/flutterguard.yml b/.github/workflows/flutterguard.yml index 3351164..4ed152c 100644 --- a/.github/workflows/flutterguard.yml +++ b/.github/workflows/flutterguard.yml @@ -19,27 +19,24 @@ jobs: - uses: dart-lang/setup-dart@v1 with: - sdk: stable + sdk: "3.11.5" - - name: Install workspace dependencies + - name: Install dependencies run: dart pub get - - name: Bootstrap workspace - run: dart run melos bootstrap + - name: Analyze + run: dart analyze - - name: Analyze workspace - run: dart run melos run analyze - - - name: Test CLI - run: dart run melos run test:cli + - name: Test + run: dart test - name: Scan demo with current checkout - run: dart run flutterguard_cli:flutterguard scan examples/scan_demo --format json --output .flutterguard/ci --fail-on high --min-score 80 --no-color + run: dart run bin/flutterguard.dart scan example --format json --output .flutterguard/ci --fail-on high --no-color - name: Upload report if: always() uses: actions/upload-artifact@v4 with: name: flutterguard-report-${{ matrix.os }} - path: examples/scan_demo/.flutterguard/ci/report.json + path: example/.flutterguard/ci/report.json if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1623f5..d704bed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,12 +16,7 @@ jobs: - uses: dart-lang/setup-dart@v1 with: sdk: "3.11.5" - - run: dart pub get - - run: dart run melos bootstrap - - run: dart run melos run analyze - - run: dart run melos run test:cli - - run: dart pub publish --server https://pub.dev --dry-run - working-directory: packages/flutterguard_cli + - run: bash scripts/release_preflight.sh build: needs: validate diff --git a/.gitignore b/.gitignore index 2e12462..10c884e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,17 +11,11 @@ build/ # CLI .flutterguard/ flutterguard +flutterguard.exe +dist/ # IDE -.idea/*/ -.idea/*.xml -.idea/*.iml -!.idea/modules.xml -!.idea/misc.xml -!.idea/vcs.xml -!.idea/runConfigurations/ -!.idea/runConfigurations/** -!.idea/.name +.idea/ .vscode/ *.iml @@ -37,6 +31,10 @@ doc/api/ # Coverage .coverage -# Project evolution spec -docs/ -CONTEXT.md +# Session artifacts +/flutterguardb.md +/session-ses_*.md + +# Duplicate local maintenance transcript; keep flutterguardb.md as canonical. +/session-ses_07b1.md + diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 85cdef9..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Local generated files -/workspace.xml -/shelf/ -/caches/ -/libraries/ diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index 707417d..0000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -flutterguard \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index c08a2df..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 0aa6fe4..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/melos_bootstrap.xml b/.idea/runConfigurations/melos_bootstrap.xml deleted file mode 100644 index 0365420..0000000 --- a/.idea/runConfigurations/melos_bootstrap.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/.idea/runConfigurations/melos_clean.xml b/.idea/runConfigurations/melos_clean.xml deleted file mode 100644 index 82bd956..0000000 --- a/.idea/runConfigurations/melos_clean.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/.idea/runConfigurations/melos_run_analyze.xml b/.idea/runConfigurations/melos_run_analyze.xml deleted file mode 100644 index 9ba7da1..0000000 --- a/.idea/runConfigurations/melos_run_analyze.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/.idea/runConfigurations/melos_run_format.xml b/.idea/runConfigurations/melos_run_format.xml deleted file mode 100644 index 27d94cf..0000000 --- a/.idea/runConfigurations/melos_run_format.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/.idea/runConfigurations/melos_run_test.xml b/.idea/runConfigurations/melos_run_test.xml deleted file mode 100644 index 23f9c3d..0000000 --- a/.idea/runConfigurations/melos_run_test.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/.idea/runConfigurations/melos_run_test_cli.xml b/.idea/runConfigurations/melos_run_test_cli.xml deleted file mode 100644 index 559a0b1..0000000 --- a/.idea/runConfigurations/melos_run_test_cli.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/.idea/runConfigurations/run_trace_demo.xml b/.idea/runConfigurations/run_trace_demo.xml deleted file mode 100644 index d339bc0..0000000 --- a/.idea/runConfigurations/run_trace_demo.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/.idea/runConfigurations/scan_demo.xml b/.idea/runConfigurations/scan_demo.xml deleted file mode 100644 index b753f2d..0000000 --- a/.idea/runConfigurations/scan_demo.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.pubignore b/.pubignore new file mode 100644 index 0000000..abe1c37 --- /dev/null +++ b/.pubignore @@ -0,0 +1,19 @@ +.github/ +.idea/ +.dart_tool/ +.flutterguard/ +**/AGENTS.md +scripts/ +test/ +/flutterguard.yaml +analysis_options.yaml +pubspec.lock +dist/ +/flutterguard +/flutterguard.exe +*.iml +CONTEXT.md +/flutterguardb.md +/session-ses_*.md +/flutterguardb.md +/session-ses_07b1.md diff --git a/AGENTS.md b/AGENTS.md index 5ade6d7..ac2d80b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,89 +1,84 @@ -# flutterguard — IoT Flutter Static Analysis CLI +# flutterguard — Agent Guide ## Identity -IoT/smart home Flutter project static analysis CLI plugin. NOT an observability SDK or APM tool. - -## Architecture -- **Monorepo**: melos, 1 package + 1 example -- **Path A (ACTIVE)**: `flutterguard_cli` — all new features -- **Path B (ARCHIVED)**: `flutterguard_core`, `flutterguard_dio`, `flutterguard_flutter` — in `archive/` for future reference - -## Package Hierarchy -| Package | Status | Depends On | Depended By | -|---------|--------|------------|-------------| -| flutterguard_cli | ACTIVE | args, analyzer, glob, path, yaml | — | -| scan_demo | — | (none, scan target) | — | - -## Key Commands -| Command | Purpose | -|---------|---------| -| `dart run melos bootstrap` | Install workspace dependencies | -| `dart run melos run analyze` | dart analyze on all packages | -| `dart run melos run test:cli` | CLI tests only (61 tests) | -| `flutterguard scan []` | Run scan on a project (path defaults to current dir) | -| `flutterguard scan --format json --fail-on high` | JSON output with CI gate | -| `flutterguard scan --changed-only` | Incremental scan of git-changed files | -| `flutterguard rules` / `flutterguard explain ` | List/describe rules | -| `dart compile exe ... -o flutterguard` | Compile native binary | - -## CI & Automation -- `.github/workflows/flutterguard.yml` — CI with ubuntu/macos/windows matrix -- `scripts/compile.sh` / `scripts/compile.ps1` — cross-platform native binary compilation -- `scripts/scan_ci.sh` / `scripts/scan_ci.ps1` — local CI gate scripts - -## CLI Entry Point -`packages/flutterguard_cli/bin/flutterguard.dart` - -Supports positional path: `flutterguard scan ./my_project` (no `-p` required). Project auto-discovery walks up from CWD to find `flutterguard.yaml`, `pubspec.yaml`, or `lib/`. Supports --changed-only incremental scan and rule introspection (rules/explain). - -Wired rules (11 rule classes, 13 rule IDs): -- Standards: LargeUnitsRule (3 IDs), MissingConstConstructorRule, PubspecSecurityRule -- Performance: LifecycleResourceRule -- Architecture: LayerViolationRule, ModuleViolationRule, CircularDependencyRule -- IoT: DeviceLifecycleRule, MqttConnectionRule, BleScanningRule, IotSecurityRule - -## Source Layout + +FlutterGuard is an executable-only static analysis CLI for IoT/smart-home +Flutter projects. It is not an observability SDK, APM product, hosted service, +or general-purpose style linter. + +## Repository + +This is one Dart package rooted at the repository root. + +```text +bin/ executable entry point +lib/src/ scan, config, report, and rule implementation +test/ contract and detector tests +example/ CI scan target +doc/ architecture and external contract +scripts/ native release packaging ``` -packages/flutterguard_cli/lib/src/ - config_loader.dart # YAML → ScanConfig typedefs (11 rule configs + architecture) - scan_context.dart # Project/all/target files and scan mode - source_workspace.dart # Shared source/AST cache + scan diagnostics - import_graph.dart # Shared resolved Dart import graph - boundary_engine.dart # Shared layer/module boundary analysis - file_collector.dart # Glob file discovery - project_resolver.dart # Project auto-discovery (walk-up flutterguard.yaml / pubspec.yaml / lib/) - static_issue.dart # StaticIssue + RiskLevel + IssueDomain + Priority - report_generator.dart # Table + JSON output + score, --no-color support - domain.dart # IssueDomain enum (architecture/performance/standards) - priority.dart # Priority enum (p0/p1/p2) - path_utils.dart # Cross-platform path/glob helpers (p.Context abstraction) - import_utils.dart # Dart import resolution against collected files - source_utils.dart # Analyzer offset → line number conversion - rule_meta.dart # Rule metadata for rules/explain - rules/ - catalog.dart # Rule metadata + execution source of truth - registry.dart # RuleRegistry for all 13 rule IDs - large_units.dart # large_file, large_class, large_build_method - lifecycle_resource.dart # lifecycle_resource_not_disposed - layer_violation.dart # layer_violation (architecture layer breaches) - module_violation.dart # module_violation (cross-module breaches) - circular_dependency.dart # circular_dependency (file-level cycles) - missing_const_constructor.dart # missing_const_constructor - iot_security.dart # iot_security (hardcoded secrets, cleartext MQTT/HTTP, insecure BLE) - device_lifecycle.dart # device_lifecycle (init/teardown pair checks) - mqtt_connection.dart # mqtt_connection (MQTT connect/disconnect, broker URLs) - ble_scanning.dart # ble_scanning (BLE startScan/stopScan, timeout) - pubspec_security.dart # pubspec_security (unbounded deps, deprecated packages) + +Do not reintroduce Melos, `packages/`, archived runtime packages, a plugin +system, or a public scanner library API. + +## Commands + +```bash +dart pub get +dart format bin lib test +dart analyze +dart test +dart run bin/flutterguard.dart scan example --format json --no-color +dart compile exe bin/flutterguard.dart -o flutterguard +dart pub publish --dry-run ``` -## Spec -Single source of truth: `docs/FLUTTERGUARD_SPEC.md` — read before implementing any feature. - -## Maintenance Rules -1. New rule: spec entry → config typedef → rule class → fixture → test → wire into rules/catalog.dart -2. Always run `melos run analyze` + `melos run test:cli` before committing -3. Do NOT modify archived packages (core/dio/flutter) — they are frozen references -4. Do NOT add Flutter widgets, web/cloud infra, or SaaS SDKs -5. Output format defaults to `table`. JSON available via `--format=json` -6. Architecture rules require explicit `architecture.layers` / `architecture.modules` in flutterguard.yaml -7. CLI supports positional path (`flutterguard scan ./project`) and `--no-color` flag +## Architecture invariants + +- `ScanContext` carries project/all/target files, scan mode, config, and the + shared `SourceWorkspace`. +- `SourceWorkspace` owns source reads, AST parsing, line info, and diagnostics. +- Architecture rules share one `ImportGraph`. +- Layer and module enforcement share `BoundaryRule` and + `DependencyBoundaryEngine`. +- `RuleRegistry.registrations` is the only metadata/default/execution registry. +- Rules receive effective generic `RuleConfig`; rule-specific defaults live in + `RuleDefinition.defaultOptions`. +- CLI and JSON/SARIF are the supported integration boundary. `lib/src` is + private implementation. + +## Product surface + +Supported command families are `scan`, `baseline create`, `config init|check`, +and `rules [rule-id]`. + +The canonical finding taxonomy is `ruleId + severity + domain`. Do not add +priority, score, confidence, or compatibility aliases. Framework is descriptive +metadata, not a global configuration switch. + +Keep changed-only scanning, inline suppression, baseline filtering, JSON, and +SARIF behavior covered by tests. + +## Adding or changing a rule + +1. Implement or extend a detector under `lib/src/rules/`. +2. Add exactly one registration and definition in `rules/registry.dart`. +3. Add positive, negative, disabled, and output-contract coverage as needed. +4. Update `doc/FLUTTERGUARD_SPEC.md` only for external contract changes. +5. Run the full verification commands above. + +Do not add generic size, formatting, or missing-const checks; those belong to +Dart lints or dedicated complexity tooling. + +## Documentation hierarchy + +- `EVOLUTION_PLAN.md` is the local checkpoint and next-step execution order. +- This file defines repository-wide constraints. +- Nested `AGENTS.md` files contain only directory-specific responsibilities. +- `doc/ARCHITECTURE.md` defines internal boundaries. +- `doc/FLUTTERGUARD_SPEC.md` defines external behavior. +- `README.md` is the user onboarding document. + +When behavior changes, update the narrowest authoritative document rather than +copying the same contract across every level. diff --git a/CHANGELOG.md b/CHANGELOG.md index 87b9687..ebb9283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,51 @@ # Changelog -## Unreleased - +## 0.7.1 (2026-07-22) + +- **rule:** Added OverlayEntry to lifecycle resource detection. +- **rule:** Upgraded `iot_security` from line-based regex to AST visitor + traversal (`VariableDeclaration`, `NamedExpression`, `StringLiteral` nodes). +- **rule:** Upgraded `ble_scanning` timeout detection from string matching to + parameter inspection and `NamedExpression` AST traversal. +- **test:** Added negative, disabled, suppression, and report-contract coverage + for `iot_security`, `ble_scanning`, and `lifecycle_resource` rules (34 tests). +- **ci:** Added `scripts/release_preflight.sh` for compact release validation + and wired it into the release workflow. + +## 0.7.0 (2026-07-17) + +### Breaking simplification + +- Flattened the repository and publishable package into one Dart package root; + removed Melos, archived runtime packages, duplicate package metadata, IDE + project files, development wrappers, and historical planning documents. +- Reduced the CLI to `scan`, `baseline create`, `config init|check`, and + `rules [rule-id]`. +- Replaced typed per-rule config records and profile YAML copies with generic + registry-driven `RuleConfig` settings. +- Merged rule metadata, defaults, and execution into one `RuleRegistry` and + merged layer/module implementations into `BoundaryRule`. +- Removed generic size rules, `missing_const_constructor`, and the overlapping + `device_lifecycle` rule. +- Assigned resource cleanup to the lifecycle rule and scan timeout checks to + `ble_scanning`; removed overlapping MQTT configuration and dependency-version + rules in favor of application configuration and ecosystem tooling. +- Removed score, priority, confidence, framework switches, JSON compatibility + aliases, install diagnostics, issue export, config profiles/print, and + baseline stats/prune/check. +- JSON reports now use schema `2.0.0` with canonical `ruleId`, `severity`, and + `domain` fields. +- Reorganized the test suite by CLI, config, rules, and scanner contracts and + updated every hierarchical `AGENTS.md` description. + +## 0.6.0 (2026-07-16) + +- **cli:** Added 10 AST-first state-management maintainability rules for generic Flutter, Riverpod, Bloc, and Provider projects (23 total rule IDs). +- **cli:** Added state-rule severity/allowlist/ignore-path controls, framework auto-detection, confidence/evidence output, and changed-only state-cycle analysis. +- **reporting:** JSON and SARIF now include framework, confidence, and evidence while preserving existing fields and baseline fingerprints. +- **fix:** Qualified state dependency graph nodes by file and resolved duplicate names through project imports. +- **compatibility:** Aligned the published package SDK constraint and install examples with the Dart 3.11.5 release workflow. +- **test:** Expanded the CLI suite to 83 tests with generic/Riverpod/Bloc/Provider fixtures, version synchronization, and release-hardening coverage. - **fix:** Prevent successful no-op full scans and preserve setup-error exit codes in local CI scripts. - **fix:** Anchor configuration policy to the target project and require explicitly selected config files. - **ci:** Run analyze, tests, and the demo scan against the current checkout on the OS matrix. diff --git a/CONFIGURATION_STRATEGY.md b/CONFIGURATION_STRATEGY.md deleted file mode 100644 index 6ebbb76..0000000 --- a/CONFIGURATION_STRATEGY.md +++ /dev/null @@ -1,183 +0,0 @@ -# FlutterGuard Configuration Strategy - -FlutterGuard should be easy to start and precise when a project needs stricter -architecture gates. The recommended product strategy is: use short CLI responses -for immediate next steps, and keep this dedicated guide for the complete mental -model. - -## Decision - -Use both surfaces, with different responsibilities: - -| Surface | Purpose | Content | -|---------|---------|---------| -| CLI help and scan responses | Immediate action | The next command, likely fix, and the shortest config path | -| Dedicated configuration guide | Full explanation | Configuration levels, architecture examples, CI patterns, and troubleshooting | - -Do not put the full manual into CLI output. CLI text should answer "what should -I do next?" in a few lines. The guide should answer "how should this be -configured for my project?" - -## Configuration Levels - -### Level 0: Zero Config - -Best for first scans and demos. - -```bash -flutterguard scan -flutterguard scan ./my_flutter_app -``` - -Behavior: - -- Scans `lib/**` -- Excludes generated/freezed/mock files -- Runs all default non-boundary rules -- Does not enforce layers/modules unless they are declared - -Use this level when the user wants a quick signal and has not agreed on project -architecture boundaries yet. No YAML file is required. - -### Level 1: Basic Project Config - -Best for normal local development. - -```yaml -include: - - lib/** - -exclude: - - lib/generated/** - - lib/**.g.dart - - lib/**.freezed.dart - - lib/**.mocks.dart - -rules: - large_file: - enabled: true - maxLines: 500 - large_class: - enabled: true - maxLines: 300 - large_build_method: - enabled: true - maxLines: 80 - lifecycle_resource: - enabled: true - missing_const_constructor: - enabled: true - device_lifecycle: - enabled: true - mqtt_connection: - enabled: true - ble_scanning: - enabled: true - maxScanDurationMs: 10000 - iot_security: - enabled: true - requireTls: true - pubspec_security: - enabled: true -``` - -Use this level when teams want stable thresholds or custom excludes. - -Create it with: - -```bash -flutterguard init -flutterguard config doctor -``` - -### Level 2: CI Gate Config - -Best for pull requests and release checks. - -```bash -flutterguard scan . --format json --fail-on high --min-score 80 -``` - -Recommended policy: - -- Start with `--fail-on high` -- Add `--min-score 80` once the baseline is clean enough -- Avoid `--fail-on low` early in adoption because it can create noisy rollouts -- Run `flutterguard config doctor` before adding CI gates - -### Level 3: Architecture Config - -Best when the project has agreed boundaries. - -Create a starter template with: - -```bash -flutterguard init --with-architecture -``` - -```yaml -architecture: - layers: - - name: presentation - path: lib/presentation/** - allowed_deps: [domain, core] - - name: domain - path: lib/domain/** - allowed_deps: [core] - - name: data - path: lib/data/** - allowed_deps: [domain, core] - - name: core - path: lib/core/** - allowed_deps: [] - - modules: - - name: mqtt_feature - path: lib/features/mqtt/** - allowed_deps: [shared] - - name: ble_feature - path: lib/features/ble/** - allowed_deps: [shared] - - name: shared - path: lib/shared/** - allowed_deps: [] - - detect_cycles: true - layer_violation: - enabled: true - module_violation: - enabled: true -``` - -Important distinction: - -- `architecture.layers[].allowed_deps` must reference layer names. -- `architecture.modules[].allowed_deps` must reference module names. -- Layers describe technical direction such as presentation/domain/data/core. -- Modules describe business or feature isolation such as mqtt/ble/shared. - -## CLI Response Policy - -CLI responses should stay short and operational: - -- `--help`: show common commands, option summary, and the four-level config path -- full scan with no matched files: fail as a setup error and point to include/exclude patterns -- changed-only scan with no relevant changes: succeed with an empty changed-mode report -- config parse error: show the failing config key and expected value type -- CI gate failure: show the failing threshold and direct the user to JSON output - -Avoid long explanations in command output. Point to this guide when the user -needs examples or architecture detail. - -## Config Tooling - -The core configuration helpers are available as CLI commands: - -- `flutterguard init`: write a minimal `flutterguard.yaml` -- `flutterguard init --with-architecture`: write a layered/module template -- `flutterguard config print`: show the merged effective config -- `flutterguard config doctor`: validate globs, unknown deps, empty matches, and - architecture overlap - -These commands are preferable to adding more prose to `scan`, because they keep -scan output focused while still making configuration discoverable. diff --git a/EVOLUTION_PLAN.md b/EVOLUTION_PLAN.md new file mode 100644 index 0000000..acb3fa5 --- /dev/null +++ b/EVOLUTION_PLAN.md @@ -0,0 +1,230 @@ +--- +checkpoint_date: 2026-07-21 +timezone: Asia/Shanghai +branch: develop +base: origin/develop +head: 3f8f010 +target_version: 0.7.0 +target_json_schema: 2.0.0 +target_rule_count: 16 +checkpoint_state: convergence_committed_quality_phase2_implemented +--- + +# FlutterGuard 演进路线与续跑检查点 + +## 用途 + +本文件是当前重构的续跑入口。后续执行者应先读取根目录及目标目录的 +`AGENTS.md`,再读取本文件和 `git status`,从第一个未完成阶段继续,不要重新 +推导已经确认的架构方向。 + +## 当前结论 + +FlutterGuard 正从 Melos 历史多包仓库收敛为一个根目录 Dart CLI 包。目标不是 +保留旧内部 API,而是减少维护面,同时稳定 CLI、JSON/SARIF、changed-only、 +baseline 和抑制注释等用户可见能力。 + +目标形态: + +- 根目录是唯一可发布的 `flutterguard_cli` 包; +- 仅保留 `bin/`、`lib/`、`test/`、`example/`、`doc/` 和发布脚本; +- CLI 只有 `scan`、`baseline create`、`config init|check`、`rules` 四个命令族; +- `RuleRegistry.registrations` 是规则说明、默认值和执行入口的唯一数据源; +- `ScanContext`、`SourceWorkspace` 和共享 `ImportGraph` 是扫描内核边界; +- JSON 使用 schema `2.0.0`,只保留规范字段名; +- 规则集合收敛到 16 个低重复、工程相关的规则。 + +## 已完成 + +### 已提交 + +- [x] `3aa32bb chore: remove obsolete architecture assets` + - 删除冻结的 runtime archive、IDE 工程文件、过期规划/重复文档和废弃入口; + - 50 个文件,删除 4,290 行; + - 当前分支相对 `origin/develop` 领先 1 个提交。 + +### 已实现但尚未提交 + +- [x] 将活跃包从 `packages/flutterguard_cli/` 扁平化到仓库根目录; +- [x] 将 `examples/scan_demo/` 收敛为 `example/`; +- [x] 删除 Melos、开发 wrapper 和重复编译/扫描脚本; +- [x] 将 CLI 收敛为四个命令族并移除重复 `--path`; +- [x] 用通用 `RuleConfig` 替换大量逐规则配置类型; +- [x] 合并 catalog、metadata 和 executor registry; +- [x] 合并 layer/module 检测实现并共享 import graph; +- [x] 删除 score、priority、confidence、兼容别名及无执行语义元数据; +- [x] 删除 generic size、missing const、device lifecycle、MQTT 配置和依赖版本规则; +- [x] 去除规则级 allowlist/ignore-path 旁路并拒绝未知规则参数; +- [x] 将测试重组为 CLI、配置、规则和扫描器契约测试; +- [x] 更新 README、外部规格、内部架构和分层 `AGENTS.md`; +- [x] 新增 `.pubignore`,发布包不再包含测试夹具、本地二进制和工程元数据。 + +## 当前工作区快照 + +记录时的 `git status --porcelain=v1 --untracked-files=all`: + +- 110 个已跟踪文件变更或删除; +- 70 个未跟踪新文件; +- 合计 180 项; +- 暂存区为空; +- 生产 Dart 代码约 4,974 行; +- 测试 Dart 代码约 1,365 行; + +Git 将根目录迁移暂时显示为旧路径删除加新路径未跟踪。不要单独恢复 +`packages/flutterguard_cli/`、`examples/scan_demo/` 或 `docs/`;它们分别由 +根目录包、`example/` 和 `doc/` 替代。 + +## 已通过验证 + +- [x] `dart format --output=none --set-exit-if-changed bin lib test` +- [x] `dart analyze`:无问题 +- [x] `dart test`:34 项全部通过(含 10 项新增质量测试) +- [x] `dart run bin/flutterguard.dart config check example` +- [x] 示例扫描:3 个文件、0 个问题、`--fail-on high` 通过 +- [x] `rules --format json` 输出 16 个规则 +- [x] 原生 executable 编译并输出 `flutterguard 0.7.0` +- [x] `git diff --check` +- [x] 干净临时副本 `dart pub publish --dry-run` + - 退出码 0; + - 0 warnings; + - 发布压缩包约 44 KB; + - 仅有已发布版本从 `0.1.0` 跳到当前版本的非阻塞 hint。 + +## 下一步:拆分剩余提交 + +按顺序执行,每批提交前后都确认 `git diff --cached --name-only`,不要混入下一批 +文件。 + +### 1. 根包与内核收敛 + +建议提交信息: + +```text +refactor: flatten and simplify the flutterguard CLI +``` + +范围: + +- 根 `pubspec.yaml`、`analysis_options.yaml`、`.gitignore`、`.pubignore`、 + `flutterguard.yaml`; +- 删除 `melos.yaml`; +- 删除旧 `packages/flutterguard_cli/**`,新增 `bin/**`、`lib/**`、`test/**`; +- 删除 `examples/scan_demo/**`,新增 `example/**`; +- 暂不包含 `.github/**`、`scripts/**`、根文档和 `doc/**`。 + +提交前门槛: + +```bash +dart format --output=none --set-exit-if-changed bin lib test +dart analyze +dart test +dart run bin/flutterguard.dart config check example +dart run bin/flutterguard.dart scan example --format json --no-color --fail-on high +git diff --cached --check +``` + +### 2. CI 与发布脚本收敛 + +建议提交信息: + +```text +ci: align workflows with the root Dart package +``` + +范围: + +- `.github/workflows/flutterguard.yml`; +- `.github/workflows/release.yml`; +- `scripts/package_release.sh`、`scripts/package_release.ps1`; +- 删除 `scripts/compile.*`、`scripts/flutterguard-dev*`、`scripts/scan_ci.*`。 + +提交前门槛: + +```bash +bash -n scripts/package_release.sh +dart compile exe bin/flutterguard.dart -o /tmp/flutterguard-check +/tmp/flutterguard-check --version +git diff --cached --check +``` + +Windows PowerShell 包装脚本需要由 Windows workflow 做最终宿主验证。 + +### 3. 文档与检查点 + +建议提交信息: + +```text +docs: document the simplified v0.7 architecture +``` + +范围: + +- `README.md`、`CHANGELOG.md`、根 `AGENTS.md`; +- 删除旧 `docs/ARCHITECTURE.md`、`docs/FLUTTERGUARD_SPEC.md`; +- 新增 `doc/ARCHITECTURE.md`、`doc/FLUTTERGUARD_SPEC.md`; +- 本文件 `EVOLUTION_PLAN.md`。 + +提交前门槛: + +```bash +rg -n 'melos|packages/flutterguard_cli|examples/scan_demo|docs/' \ + README.md AGENTS.md doc bin lib test example .github scripts || true +git diff --cached --check +``` + +历史内容允许出现在 `CHANGELOG.md`,活跃说明中不得存在旧路径或已删除能力。 + +## 所有提交完成后的发布门槛 + +```bash +dart pub get +dart format --output=none --set-exit-if-changed bin lib test +dart analyze +dart test +dart run bin/flutterguard.dart rules --format json +dart run bin/flutterguard.dart config check . +dart run bin/flutterguard.dart config check example +dart run bin/flutterguard.dart scan example \ + --format json --output .flutterguard/ci --fail-on high --no-color +dart compile exe bin/flutterguard.dart -o /tmp/flutterguard-0.7.0 +/tmp/flutterguard-0.7.0 --version +dart pub publish --dry-run +git diff --check +git status --short +``` + +只有在工作区干净时,根目录 publish dry-run 的 Git 状态检查才代表最终发布结果。 +工作区尚未提交时,使用无 `.git` 元数据的干净临时副本验证包内容。 + +## 后续产品演进 + +完成当前三批提交和发布门槛后,再进入规则质量阶段: + +1. ~~为 `OverlayEntry` 增加 `remove()` / `dispose()` 生命周期安全检测~~ (已完成 2026-07-21) +2. ~~将仍依赖字符串的方法/类型识别逐步升级为 AST 和可解析类型语义~~ (已完成 2026-07-21: iot_security 升级为 AST Visitor; ble_scanning timeout 升级为参数/命名参数检测) +3. ~~为新增规则先锁定所有权、低误报边界、changed-only 行为和抑制契约~~ (已完成 2026-07-21) +4. ~~每个规则必须有 positive、negative、disabled、suppression 和报告契约覆盖~~ (已完成 2026-07-21: iot_security, ble_scanning, lifecycle 均已补全五项覆盖) +5. 保持一次扫描只读/解析一次源文件,并复用 `SourceWorkspace`; +6. 保持 layer/module/cycle 共享项目 import graph。 + +## 明确不做 + +- 不恢复 runtime SDK、APM、云服务、Flutter Widget 或动态插件系统; +- 不恢复 Melos、多包发布结构或公共 Dart scanner API; +- 不恢复 score、priority、confidence 或 JSON 兼容别名; +- 不恢复 generic size、missing const、依赖版本或 broker 配置规则; +- 不为降低误报而恢复每条规则的宽泛 allowlist; +- 不在当前收敛提交中夹带新的规则功能。 + +## 后续会话启动协议 + +```bash +git log -3 --oneline --decorate +git status --short +dart analyze +dart test +``` + +然后读取本文件“下一步”部分,从第一个未完成提交开始。每完成一个阶段,更新 +front matter 的 `head`、`checkpoint_state`,勾选对应项目,并在“已通过验证”中 +记录最新实际结果。 diff --git a/PROJECT_RULES.md b/PROJECT_RULES.md deleted file mode 100644 index e6a5dc6..0000000 --- a/PROJECT_RULES.md +++ /dev/null @@ -1,79 +0,0 @@ -# Project Rules for FlutterGuard - -## 1. Identity -- FlutterGuard is an **IoT/smart home Flutter project static analysis CLI plugin**. -- NOT a general observability SDK, APM tool, or crash reporter. -- Output: compiled CLI binary (`dart compile exe`) that scans Dart source code. - -## 2. Active vs Archived - -| Path | Status | Package | Notes | -|------|--------|---------|-------| -| **Path A** — CLI static analysis | **ACTIVE** | `flutterguard_cli` | All new features | -| **Path B** — Runtime tracing | **ARCHIVED** | `core`, `dio`, `flutter` | In `archive/` for future reference | - -Do NOT modify archived packages. They are preserved as-is for reference. - -## 3. Architecture Constraints -- Monorepo managed by **melos**. New packages must register in `melos.yaml`. -- Active code lives in `packages/flutterguard_cli/lib/src/`. -- Each rule is a standalone class: `analyze(List files) → List`. -- Configuration driven by `flutterguard.yaml` (YAML schema, parsed in `config_loader.dart`). -- No plugin system, no code generation, no reflection — explicit wiring in `bin/flutterguard.dart`. - -## 4. Code Conventions -- Dart 3.3+, `strict-casts: true`, `strict-inference: true`. -- Prefer Dart **records** (`typedef`) for config types. -- Prefer `const` constructors and `final` locals. -- Wrap per-file parsing in try/catch (one bad file must not crash the scan). -- Import style: `package:flutterguard_cli/src/...` (no relative imports across packages). - -## 5. Testing Conventions -- Tests live in `packages/flutterguard_cli/test/` using `package:test`. -- Fixtures go in `test/fixtures/.dart`. -- Every new rule requires: spec entry → config typedef → rule class → fixture → test. -- Run tests: `melos run test:cli`. - -## 6. Spec Governance -- `docs/FLUTTERGUARD_SPEC.md` is the **single source of truth**. -- Spec must be updated **before** implementation. -- All rule IDs, risk levels, and metadata schemas must be documented in spec first. - -## 7. Forbidden -- Do NOT create new Flutter widgets or UI components. -- Do NOT add web/cloud infrastructure or dashboard UI. -- Do NOT use third-party SaaS SDKs. -- Do NOT commit secrets, API keys, or credentials. -- Do NOT add runtime instrumentation outside archived packages. -- Do NOT introduce `package:build` / code generation dependencies. - -## 8. Git Workflow -- Branch: `develop` for active work. PRs merge to `develop`. -- Commit messages: imperative mood, prefixed by scope (`cli:`, `spec:`, `docs:`). -- Before committing: run `melos run analyze` and `melos run test:cli`. -- Do NOT force-push to `develop` or `main`. - -## 9. Override Hierarchy - -### 9.1 pubspec_overrides.yaml -Managed by `melos bootstrap`. No path dependencies currently exist for `flutterguard_cli`. - -**Rule**: Do NOT edit manually. Re-run `melos bootstrap` after any pubspec.yaml change. - -### 9.2 analysis_options.yaml Inheritance Chain -``` -root/analysis_options.yaml # strict-casts + strict-inference + 6 lint rules -└── packages/flutterguard_cli/... # inherits root + package:lints/recommended + excludes test/fixtures/** -``` - -**Rule**: Keep corporate-wide strictness at root. Per-package loosening only for legitimate reasons (fixture code, print-based demos). - -### 9.3 flutterguard.yaml Config Override Chain -``` - -├── --config # explicit project-relative or absolute config -├── flutterguard.yaml # default project config when no override is given -└── built-in defaults # only when no override is given and the default file is absent -``` - -**Rule**: Config files do not merge across projects. Relative paths resolve from the target project root, and explicitly selected files must exist. diff --git a/README.md b/README.md index e6f3d3f..8a93c86 100644 --- a/README.md +++ b/README.md @@ -1,256 +1,60 @@ # FlutterGuard -> IoT Flutter project static analysis CLI for architecture enforcement, code quality, and CI gating. - -[English](README.md) | [中文](README.zh.md) - -FlutterGuard scans Flutter/Dart source code and reports architecture boundary breaches, lifecycle/resource leaks, dependency cycles, and size-related code quality issues. The active path is `packages/flutterguard_cli/`; the legacy runtime-tracing packages are archived under `archive/`. - -**Platforms**: macOS, Windows, Linux — pure Dart CLI, no native dependencies. - -**Docs**: [Usage Guide](docs/USAGE.md) | [Configuration Strategy](CONFIGURATION_STRATEGY.md) | [Windows Assessment](docs/WINDOWS_ASSESSMENT.md) | [Spec](docs/FLUTTERGUARD_SPEC.md) | [Architecture](docs/ARCHITECTURE.md) - -## What It Is - -- A CLI for static analysis of Flutter/Dart projects -- A YAML-driven architecture enforcement tool -- An IoT/smart-home aware rule set for Flutter codebases -- A CI gate that can fail builds on severity thresholds or score thresholds - -## What It Is Not - -- Not a runtime observability or APM SDK -- Not a crash reporter -- Not a general-purpose Dart linter -- Not a web dashboard or Flutter widget library -- Does not require an API key and does not upload APKs - -## Requirements - -- Dart SDK 3.3.0 or newer -- `melos` for workspace bootstrap when running from source -- Supported OS: macOS, Windows, Linux - ---- +FlutterGuard is an executable-only static analysis CLI for IoT and smart-home +Flutter projects. It checks architecture boundaries, lifecycle/resource safety, +IoT transport security, and state-management maintainability. It does not add a +runtime SDK to the scanned application. ## Install -### Option A: pub.dev install (recommended) - -
-macOS / Linux - ```bash dart pub global activate flutterguard_cli - -# Verify flutterguard --version ``` -Ensure `$HOME/.pub-cache/bin` is on your `PATH`: - -```bash -export PATH="$PATH:$HOME/.pub-cache/bin" # add to ~/.zshrc or ~/.bashrc -``` -
- -
-Windows (PowerShell) - -```powershell -dart pub global activate flutterguard_cli - -# Verify -flutterguard --version -``` - -If the command is not found, ensure `%USERPROFILE%\AppData\Local\Pub\Cache\bin` is on your `PATH`: - -```powershell -$env:Path += ";$env:USERPROFILE\AppData\Local\Pub\Cache\bin" -``` -
- -### Option B: GitHub Release binary (no Dart SDK at runtime) - -Download the matching binary from the GitHub Releases page, then run it -directly: - -
-macOS / Linux - -```bash -chmod +x flutterguard -./flutterguard --version -./flutterguard scan . -``` -
- -
-Windows (PowerShell) - -```powershell -.\flutterguard.exe --version -.\flutterguard.exe scan . -``` -
- -### Option C: source checkout for development - -Use the local launcher when you want to run the current checkout without -installing or replacing the global `flutterguard` command. - -
-macOS / Linux +From a source checkout: ```bash -git clone https://github.com/lizy-coding/flutterguard.git -cd flutterguard dart pub get -./scripts/flutterguard-dev --version -./scripts/flutterguard-dev scan . +dart run bin/flutterguard.dart scan example ``` -
-
-Windows (PowerShell) +## Commands -```powershell -git clone https://github.com/lizy-coding/flutterguard.git -cd flutterguard -dart pub get -.\scripts\flutterguard-dev.ps1 --version -.\scripts\flutterguard-dev.ps1 scan . +```text +flutterguard scan [path] [options] +flutterguard baseline create [path] +flutterguard config init [path] +flutterguard config check [path] +flutterguard rules [rule-id] [--format table|json] ``` -
- ---- - -## Quick Start - -```bash -# Scan the current directory -flutterguard scan - -# Create and validate a starter config -flutterguard init --profile migration -flutterguard config doctor -flutterguard doctor install -# Inspect the merged effective config -flutterguard config print +The core scan options are: -# Scan a specific project -flutterguard scan ./my_flutter_app # macOS / Linux -flutterguard scan .\my_flutter_app # Windows +- `--config`, `-c`: explicit configuration file. +- `--format`, `-f`: `table`, `json`, or `sarif`. +- `--output`, `-o`: report directory; default `.flutterguard`. +- `--fail-on`: `none`, `high`, `medium`, or `low`. +- `--changed-only --base `: scan Git-changed Dart files. +- `--baseline `: hide findings already recorded in a baseline. +- `--verbose`: include diagnostics, detail, and evidence. +- `--no-color`: disable ANSI terminal color. -# Scan with explicit path flag -flutterguard scan -p /path/to/project # macOS / Linux -flutterguard scan -p D:\path\to\project # Windows +Exit code `0` means the scan completed and the gate passed. Exit code `1` means +the severity gate failed. Exit code `2` means the command or scan setup was +invalid. -# JSON output with CI gate -flutterguard scan . --format json --fail-on high - -# Baseline existing issues before enabling a hard CI gate -flutterguard baseline create . -flutterguard baseline stats -flutterguard baseline check . --baseline .flutterguard/baseline.json --no-growth -flutterguard scan . --baseline .flutterguard/baseline.json --fail-on high - -# GitHub Code Scanning output -flutterguard scan . --format sarif --baseline .flutterguard/baseline.json - -# Export one finding for false-positive feedback -flutterguard issue export --rule mqtt_connection --file lib/device/mqtt.dart --line 42 - -# Show help -flutterguard --help -flutterguard scan --help -``` +## Configuration -### Demo target +Configuration is optional. Generate a complete starter file from the rule +registry: ```bash -flutterguard scan examples/scan_demo +flutterguard config init . +flutterguard config check . ``` ---- - -## CLI Reference - -Commands: - -| Command | Description | -|---------|-------------| -| `flutterguard scan []` | Scan a project (path defaults to current directory) | -| `flutterguard baseline create []` | Create a baseline JSON file for existing issues | -| `flutterguard baseline stats` | Show baseline fingerprint counts | -| `flutterguard baseline prune []` | Remove fixed issues from a baseline | -| `flutterguard baseline check [] --no-growth` | Fail when current issues are missing from baseline | -| `flutterguard doctor install` | Diagnose executable version and PATH conflicts | -| `flutterguard init` | Create a starter `flutterguard.yaml` | -| `flutterguard init --profile migration` | Create a starter config from a profile | -| `flutterguard init --with-architecture` | Create config with architecture layer/module templates | -| `flutterguard config print` | Print the merged effective configuration | -| `flutterguard config doctor` | Validate config, globs, and architecture references | -| `flutterguard issue export` | Export one issue as a local feedback JSON bundle | -| `flutterguard rules` | List available rules | -| `flutterguard explain ` | Explain one rule | -| `flutterguard --help` / `-h` | Show usage | -| `flutterguard --version` / `-V` | Show version | - -### Scan options - -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `` | — | `.` | Positional project path (optional, before options) | -| `--path` | `-p` | `.` | Project path to scan (overridden by positional ``) | -| `--config` | `-c` | `flutterguard.yaml` | Config file path | -| `--format` | `-f` | `table` | Output format: `table`, `json`, or `sarif` | -| `--output` | `-o` | `.flutterguard` | Output directory for reports | -| `--verbose` | `-v` | off | Show detailed output with code context | -| `--no-color` | — | off | Disable ANSI terminal colors | -| `--changed-only` | — | off | Only scan Dart files changed since `--base` | -| `--base` | — | `main` | Git base ref for `--changed-only` | -| `--baseline` | — | unset | Baseline JSON file used to hide existing issues | -| `--fail-on` | — | `none` | CI gate: `none` / `high` / `medium` / `low` | -| `--min-score` | — | unset | Minimum score threshold 0–100 | -| `--help` | `-h` | — | Show scan usage | - -### Exit codes - -| Code | Meaning | -|------|---------| -| `0` | Success, including help/version and a changed-only scan with no relevant changes | -| `1` | CI gate failed (issues at/above `--fail-on` level, or score below `--min-score`) | -| `2` | Scan setup error (bad path, missing explicit config, invalid config, or no configured Dart files) | - -### Path resolution - -FlutterGuard auto-discovers the project root by walking up from the current directory, looking for `flutterguard.yaml`, `pubspec.yaml`, or a `lib/` directory. If none are found, it falls back to the current directory. - -The `--config` path is resolved against the target project: -1. Absolute paths are used as-is and must exist. -2. Relative paths are resolved from the target project root, never from CWD. -3. An omitted default `flutterguard.yaml` uses built-in defaults; any explicitly selected config must exist. - ---- - -## Configuration - -Create `flutterguard.yaml` in your project root. - -Recommended strategy: - -1. Start with zero config: `flutterguard scan`. -2. Run `flutterguard init` when you need custom thresholds or excludes. -3. Use `flutterguard config print` to inspect merged defaults. -4. Use `flutterguard config doctor` before enabling CI gates. -5. Add architecture layers/modules only after project boundaries are agreed. - -For the full decision model, see [Configuration Strategy](CONFIGURATION_STRATEGY.md). - -### Basic config (for most users) +Minimal example: ```yaml include: @@ -259,43 +63,18 @@ include: exclude: - lib/generated/** - lib/**.g.dart - - lib/**.freezed.dart - - lib/**.mocks.dart rules: - large_file: - enabled: true - maxLines: 500 - large_class: - enabled: true - maxLines: 300 - large_build_method: - enabled: true - maxLines: 80 - lifecycle_resource: - enabled: true - missing_const_constructor: - enabled: true - device_lifecycle: - enabled: true - mqtt_connection: - enabled: true - ble_scanning: - enabled: true - maxScanDurationMs: 10000 iot_security: enabled: true + severity: high requireTls: true - pubspec_security: + ble_scanning: enabled: true -``` - -### Full config (with architecture enforcement) - -```yaml -# ... include/exclude/rules from basic config above ... + severity: medium architecture: + detect_cycles: true layers: - name: presentation path: lib/presentation/** @@ -303,329 +82,100 @@ architecture: - name: domain path: lib/domain/** allowed_deps: [core] - - name: data - path: lib/data/** - allowed_deps: [domain, core] - name: core path: lib/core/** allowed_deps: [] - - modules: - - name: device_mqtt - path: lib/device/mqtt/** - allowed_deps: [domain, core] - - name: device_ble - path: lib/device/ble/** - allowed_deps: [domain, core] - - detect_cycles: true - layer_violation: - enabled: true - module_violation: - enabled: true + modules: [] ``` -> **Important**: Architecture rules (`layer_violation`, `module_violation`, `circular_dependency`) require explicit `architecture.layers`, `architecture.modules`, and/or `architecture.detect_cycles` declarations in your config. They do not auto-discover project boundaries. - -> **Glob patterns**: Always use forward slashes (`/`) in YAML config, even on Windows. Do not use backslashes. - ---- +Every rule accepts `enabled` and `severity`. Rule-specific scalar options are +listed by `flutterguard rules `. ## Rules -| Rule ID | Level | Domain | Priority | What it checks | Config required | -|---------|-------|--------|----------|----------------|-----------------| -| `large_file` | LOW | standards | P2 | File line count over `maxLines` | — | -| `large_class` | LOW | standards | P2 | Class body line count over `maxLines` | — | -| `large_build_method` | MEDIUM | performance | P1 | `build()` method line count over `maxLines` | — | -| `lifecycle_resource_not_disposed` | MEDIUM | performance | P1 | Undisposed StreamSubscription, Timer, AnimationController, TextEditingController, ScrollController, FocusNode, MqttClient, BluetoothDevice, StreamController | — | -| `missing_const_constructor` | LOW | standards | P2 | Widget classes missing a `const` constructor | — | -| `layer_violation` | HIGH | architecture | P0 | Importing across forbidden architecture layers | `architecture.layers` * | -| `module_violation` | HIGH | architecture | P0 | Importing across forbidden business modules | `architecture.modules` * | -| `circular_dependency` | MEDIUM | architecture | P1 | File-level import cycles | `architecture.detect_cycles` * | -| `device_lifecycle` | HIGH | architecture | P0 | Unbalanced init/teardown pairs (initState↔dispose, connect↔disconnect, etc.) | — | -| `mqtt_connection` | HIGH | architecture | P0 | MQTT connect/disconnect pairing, hardcoded broker URLs | — | -| `iot_security` | HIGH | architecture | P0 | Hardcoded credentials, cleartext MQTT/HTTP, insecure BLE | `rules.iot_security.requireTls` | -| `ble_scanning` | MEDIUM | architecture | P1 | BLE startScan/stopScan pairing, scan timeout | `rules.ble_scanning.maxScanDurationMs` | -| `pubspec_security` | MEDIUM | standards | P2 | Unbounded deps, deprecated packages, outdated IoT dependencies | — | - -* Requires explicit YAML configuration to activate. - ---- - -## Output - -### Terminal table (default) - -Colored terminal report grouped by domain. Shows overall score, file count, issue count, and per-issue detail. - -### JSON report - -`--format json` writes `.flutterguard/report.json` under the output directory. - -Example shape: - -```json -{ - "version": "1.0.0", - "generatedAt": "2026-06-09T12:00:00.000Z", - "projectPath": "/path/to/project", - "score": 85, - "summary": { - "total": 3, - "high": 1, - "medium": 1, - "low": 1, - "suppressed": 0, - "suppressedByBaseline": 0, - "byDomain": { - "architecture": { "high": 1, "medium": 0, "low": 0, "total": 1 } - } - }, - "issues": [] -} -``` +The registry currently contains 16 rule IDs: -### SARIF report +- Architecture: `layer_violation`, `module_violation`, + `circular_dependency`, `state_layer_ui_dependency`, + `state_dependency_cycle`. +- Lifecycle and performance: `lifecycle_resource_not_disposed`, + `side_effect_in_build`, `state_manager_created_in_build`, + `riverpod_read_used_for_render`, `riverpod_watch_in_callback`, + `provider_value_lifecycle_misuse`, `notify_listeners_in_loop`. +- IoT security: `ble_scanning`, `iot_security`. +- State standards: `mutable_state_exposed`, + `bloc_equatable_props_incomplete`. -`--format sarif` writes `.flutterguard/report.sarif` for GitHub Code Scanning. High, medium, and low map to SARIF `error`, `warning`, and `note`. +Generic file-size, missing-const, dependency-version, and broker-configuration +checks were removed in 0.7.0. Dart lints, `dart pub outdated`, dependency +security tools, and application configuration are better owners. -### Suppression and baseline - -Use source suppression for known false positives: - -```dart -// flutterguard: ignore missing_const_constructor -// flutterguard: ignore iot_security, mqtt_connection -// flutterguard: ignore all -``` +## Reports and CI -Suppression applies only to the comment line and the following line. - -Recommended CI adoption order: +JSON uses schema version `2.0.0` and exposes one canonical field name per +concept: `ruleId`, `severity`, and `domain`. SARIF 2.1.0 is suitable for GitHub +Code Scanning. ```bash -flutterguard config doctor flutterguard baseline create . -flutterguard baseline check . --baseline .flutterguard/baseline.json --no-growth -flutterguard scan . --baseline .flutterguard/baseline.json --format json --fail-on high -``` - -## Scoring - -``` -score = max(0, 100 - high×10 - medium×4 - low×1) +flutterguard scan . \ + --baseline .flutterguard/baseline.json \ + --format sarif \ + --fail-on high ``` -| Score | Rating | -|-------|--------| -| 80–100 | Excellent | -| 50–79 | Needs review | -| 0–49 | Needs action | +Suppression comments remain available for precise false positives: ---- - -## CI Integration - -### GitHub Actions - -```yaml -name: FlutterGuard - -on: [push, pull_request] - -jobs: - scan: - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - uses: dart-lang/setup-dart@v1 - with: - sdk: 3.3.0 - - name: Install FlutterGuard - run: dart pub global activate flutterguard_cli - - name: Scan - run: flutterguard scan . --format json --baseline .flutterguard/baseline.json --fail-on high --min-score 80 -``` - -### GitHub Code Scanning - -```yaml -name: FlutterGuard SARIF - -on: [push, pull_request] - -jobs: - code-scanning: - runs-on: ubuntu-latest - permissions: - security-events: write - contents: read - steps: - - uses: actions/checkout@v4 - - uses: dart-lang/setup-dart@v1 - with: - sdk: 3.3.0 - - run: dart pub global activate flutterguard_cli - - run: flutterguard scan . --format sarif --baseline .flutterguard/baseline.json - - uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: .flutterguard/report.sarif -``` - -### GitLab CI - -```yaml -flutterguard: - image: dart:3.3.0 - script: - - dart pub global activate flutterguard_cli - - flutterguard scan . --format json --fail-on high --min-score 80 - artifacts: - paths: - - .flutterguard/report.json - when: always +```dart +// flutterguard: ignore iot_security +final endpoint = loadLocalDevelopmentEndpoint(); ``` -### pre-commit hook +## Repository layout -```yaml -# .pre-commit-config.yaml -repos: - - repo: local - hooks: - - id: flutterguard - name: FlutterGuard scan - entry: flutterguard scan . --fail-on high - language: system - pass_filenames: false - always_run: true +```text +bin/ executable entry point +lib/src/cli/ command parsers and handlers +lib/src/rules/ registry, rule definitions, and detectors +lib/src/ scan/config/report shared kernel +test/ contract and detector tests +example/ scan target used by CI +doc/ architecture and external contract +scripts/ native release packaging only ``` -### Local scripts +FlutterGuard is a single Dart package. There is no Melos workspace, runtime +SDK, plugin system, or public Dart scanner API. -
-macOS / Linux +## Development ```bash -#!/usr/bin/env bash -# scan_ci.sh -if flutterguard scan . --format json --fail-on high --min-score 80; then - echo "All checks passed!" -else - status=$? - echo "FlutterGuard failed with exit code $status." - exit "$status" -fi -``` -
- -
-Windows (PowerShell) - -```powershell -# scan_ci.ps1 -$ErrorActionPreference = "Stop" -flutterguard scan . --format json --fail-on high --min-score 80 -$status = $LASTEXITCODE - -if ($status -eq 0) { - Write-Host "All checks passed!" -ForegroundColor Green -} else { - Write-Host "FlutterGuard failed with exit code $status." -ForegroundColor Red - exit $status -} -``` -
- ---- - -## Troubleshooting - -### Windows: ANSI colors show as raw escape codes - -Use **Windows Terminal** (built into Windows 10/11) instead of legacy `cmd.exe`. Alternatively, add `--no-color` to disable ANSI output: - -```powershell -flutterguard scan . --no-color -``` - -### Windows: "API key required" error - -This means the shell is resolving an old globally-installed binary instead of this repository's static-analysis CLI. Run the local binary directly: - -```powershell -.\flutterguard.exe scan . -``` - -Or reinstall: - -```powershell -dart pub global deactivate flutterguard_cli -dart pub global activate flutterguard_cli -``` - -### Windows: garbled Chinese output - -```powershell -# In PowerShell, set UTF-8 output encoding -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 -# Or use Windows Terminal (recommended) which defaults to UTF-8 -``` - -### Glob patterns: always use forward slashes - -In `flutterguard.yaml`, use `/` for all path patterns regardless of platform: - -```yaml -# Correct -path: lib/presentation/** - -# Wrong (even on Windows) -path: lib\presentation\** +dart pub get +dart format --output=none --set-exit-if-changed bin lib test +dart analyze +dart test +dart run bin/flutterguard.dart scan example --format json --no-color +dart pub publish --dry-run ``` ---- - -## Repository Layout - -``` -flutterguard/ -├── packages/ -│ └── flutterguard_cli/ Active CLI implementation -├── archive/ Frozen legacy runtime-tracing packages -└── examples/ - └── scan_demo/ Demo scan target -``` +### Release preflight -## Development +Before creating a release tag, run the compact preflight command: ```bash -# All platforms -git clone https://github.com/lizy-coding/flutterguard.git -cd flutterguard -dart pub get -dart pub global activate melos -melos bootstrap - -# Common commands -dart run melos run analyze # Static analysis -dart run melos run test:cli # Run tests -dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard +bash scripts/release_preflight.sh ``` -## Further Reading +It resolves dependencies, checks formatting, analysis, tests, and the CLI +example, then runs `dart pub publish --dry-run` without uploading anything. +It finishes by printing the required human checks for package contents, legal +redistribution, version/tag, and publisher access. A nonzero exit means at +least one automated release gate failed. -| Document | Content | -|----------|---------| -| [docs/USAGE.md](docs/USAGE.md) | Full usage guide (all platforms) | -| [docs/WINDOWS_ASSESSMENT.md](docs/WINDOWS_ASSESSMENT.md) | Windows compatibility assessment | -| [docs/FLUTTERGUARD_SPEC.md](docs/FLUTTERGUARD_SPEC.md) | Technical specification | -| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Architecture overview | +See [doc/ARCHITECTURE.md](doc/ARCHITECTURE.md) for internal boundaries and +[doc/FLUTTERGUARD_SPEC.md](doc/FLUTTERGUARD_SPEC.md) for the external +contract. ## License -MIT +MIT. See [LICENSE](LICENSE). diff --git a/README.zh.md b/README.zh.md deleted file mode 100644 index 40e9f22..0000000 --- a/README.zh.md +++ /dev/null @@ -1,629 +0,0 @@ -# FlutterGuard - -> 面向 IoT / 智能家居 Flutter 项目的静态架构扫描 CLI,用于架构约束、代码质量检查和 CI 门禁。 - -[English](README.md) | [中文](README.zh.md) - -FlutterGuard 扫描 Flutter/Dart 源码,报告架构边界违规、生命周期资源泄漏、循环依赖、过大文件/类/`build` 方法,以及常见代码规范问题。当前活动开发路径是 `packages/flutterguard_cli/`;旧的运行时追踪包已归档在 `archive/`。 - -**支持平台**: macOS、Windows、Linux — 纯 Dart CLI,零原生依赖。 - -**文档**: [使用指南](docs/USAGE.md) | [配置策略](CONFIGURATION_STRATEGY.md) | [Windows 评估](docs/WINDOWS_ASSESSMENT.md) | [技术规格](docs/FLUTTERGUARD_SPEC.md) | [架构](docs/ARCHITECTURE.md) - -## 它是什么 - -- 静态分析命令行工具 -- 基于 YAML 配置的架构约束工具 -- 面向 IoT / 智能家居 Flutter 项目的规则集 -- 可按严重等级或评分失败的 CI 门禁 - -## 它不是什么 - -- 不是运行时观测 SDK 或 APM -- 不是 Crashlytics / Sentry 替代品 -- 不是 HTTP 抓包、日志库或云端 SaaS -- 不需要 API key,也不会上传 APK - -## 环境要求 - -- Dart SDK 3.3.0 或更高版本 -- 从源码开发时需要 `melos` -- 支持操作系统: macOS、Windows、Linux - ---- - -## 安装 - -### 方式 A:pub.dev 安装(推荐) - -
-macOS / Linux - -```bash -dart pub global activate flutterguard_cli - -# 验证安装 -flutterguard --version -``` - -确认 `$HOME/.pub-cache/bin` 在 `PATH` 中: - -```bash -export PATH="$PATH:$HOME/.pub-cache/bin" # 添加到 ~/.zshrc 或 ~/.bashrc -``` -
- -
-Windows (PowerShell) - -```powershell -dart pub global activate flutterguard_cli - -# 验证安装 -flutterguard --version -``` - -若 `flutterguard` 命令未识别,确认 `%USERPROFILE%\AppData\Local\Pub\Cache\bin` 在 `PATH` 中: - -```powershell -$env:Path += ";$env:USERPROFILE\AppData\Local\Pub\Cache\bin" -``` -
- -### 方式 B:GitHub Release 二进制(运行时无需 Dart 环境) - -从 GitHub Releases 下载对应平台的二进制后直接运行: - -
-macOS / Linux - -```bash -chmod +x flutterguard -./flutterguard --version -./flutterguard scan . -``` -
- -
-Windows (PowerShell) - -```powershell -.\flutterguard.exe --version -.\flutterguard.exe scan . -``` -
- -### 方式 C:源码开发运行 - -如果你希望直接运行当前 checkout 的源码,不替换全局 `flutterguard` -命令,使用本地 launcher。 - -
-macOS / Linux - -```bash -git clone https://github.com/lizy-coding/flutterguard.git -cd flutterguard -dart pub get -./scripts/flutterguard-dev --version -./scripts/flutterguard-dev scan . -``` -
- -
-Windows (PowerShell) - -```powershell -git clone https://github.com/lizy-coding/flutterguard.git -cd flutterguard -dart pub get -.\scripts\flutterguard-dev.ps1 --version -.\scripts\flutterguard-dev.ps1 scan . -``` -
- ---- - -## 快速开始 - -```bash -# 扫描当前目录 -flutterguard scan - -# 创建并检查基础配置 -flutterguard init --profile migration -flutterguard config doctor -flutterguard doctor install - -# 查看合并后的有效配置 -flutterguard config print - -# 扫描指定项目 -flutterguard scan ./my_flutter_app # macOS / Linux -flutterguard scan .\my_flutter_app # Windows - -# 使用 --path 标志 -flutterguard scan -p /path/to/project # macOS / Linux -flutterguard scan -p D:\path\to\project # Windows - -# JSON 输出 + CI 门禁 -flutterguard scan . --format json --fail-on high - -# 启用强门禁前先为历史问题创建 baseline -flutterguard baseline create . -flutterguard baseline stats -flutterguard baseline check . --baseline .flutterguard/baseline.json --no-growth -flutterguard scan . --baseline .flutterguard/baseline.json --fail-on high - -# GitHub Code Scanning 输出 -flutterguard scan . --format sarif --baseline .flutterguard/baseline.json - -# 导出单个问题用于误报反馈 -flutterguard issue export --rule mqtt_connection --file lib/device/mqtt.dart --line 42 - -# 显示帮助 -flutterguard --help -flutterguard scan --help -``` - -### 扫描示例项目 - -```bash -flutterguard scan examples/scan_demo -``` - ---- - -## CLI 参考 - -命令: - -| 命令 | 说明 | -|------|------| -| `flutterguard scan []` | 扫描项目(路径默认为当前目录) | -| `flutterguard baseline create []` | 为现有问题创建 baseline JSON 文件 | -| `flutterguard baseline stats` | 查看 baseline fingerprint 数量 | -| `flutterguard baseline prune []` | 从 baseline 移除已修复问题 | -| `flutterguard baseline check [] --no-growth` | 当前问题未进入 baseline 时失败 | -| `flutterguard doctor install` | 检查可执行文件版本和 PATH 冲突 | -| `flutterguard init` | 创建基础 `flutterguard.yaml` | -| `flutterguard init --profile migration` | 使用 profile 创建基础配置 | -| `flutterguard init --with-architecture` | 创建包含架构层/模块模板的配置 | -| `flutterguard config print` | 输出合并后的有效配置 | -| `flutterguard config doctor` | 检查配置、glob 和架构引用 | -| `flutterguard issue export` | 导出单个问题为本地反馈 JSON | -| `flutterguard rules` | 列出所有可用规则 | -| `flutterguard explain ` | 查看单条规则说明 | -| `flutterguard --help` / `-h` | 显示帮助 | -| `flutterguard --version` / `-V` | 显示版本 | - -### 扫描参数 - -| 参数 | 简写 | 默认值 | 说明 | -|------|------|--------|------| -| `` | — | `.` | 位置参数,项目路径(可选,放在选项之前) | -| `--path` | `-p` | `.` | 项目路径(被 `` 位置参数覆盖) | -| `--config` | `-c` | `flutterguard.yaml` | 配置文件路径 | -| `--format` | `-f` | `table` | 输出格式:`table`、`json` 或 `sarif` | -| `--output` | `-o` | `.flutterguard` | 报告输出目录 | -| `--verbose` | `-v` | 关闭 | 显示详细代码上下文 | -| `--no-color` | — | 关闭 | 禁用 ANSI 终端颜色 | -| `--changed-only` | — | 关闭 | 只扫描相对 `--base` 变更的 Dart 文件 | -| `--base` | — | `main` | `--changed-only` 使用的 Git base ref | -| `--baseline` | — | 不设 | 用于隐藏历史问题的 baseline JSON 文件 | -| `--fail-on` | — | `none` | CI 门禁等级:`none` / `high` / `medium` / `low` | -| `--min-score` | — | 不设 | 最低可接受评分,0–100 | -| `--help` | `-h` | — | 显示 scan 帮助 | - -### 退出码 - -| 退出码 | 含义 | -|--------|------| -| `0` | 成功,包含 help/version 以及增量扫描没有相关变更的情况 | -| `1` | CI 门禁失败(存在超过 `--fail-on` 的问题,或评分低于 `--min-score`)| -| `2` | 扫描设置错误(路径不存在、显式配置缺失、配置无效或未匹配到配置范围内的 Dart 文件) | - -### 路径解析 - -FlutterGuard 从当前目录向上遍历,自动发现项目根目录(查找 `flutterguard.yaml`、`pubspec.yaml` 或 `lib/` 目录)。若未找到,则退化为当前目录。 - -`--config` 路径始终针对目标项目解析: -1. 绝对路径直接使用,且文件必须存在。 -2. 相对路径从目标项目根目录解析,不再读取 CWD 下的同名文件。 -3. 未显式指定且默认 `flutterguard.yaml` 不存在时使用内置默认值;任何显式选择的配置都必须存在。 - ---- - -## 配置文件 - -在项目根目录创建 `flutterguard.yaml`。 - -推荐策略: - -1. 先零配置运行:`flutterguard scan`。 -2. 需要自定义阈值或排除文件时,运行 `flutterguard init`。 -3. 使用 `flutterguard config print` 查看合并后的默认值。 -4. 启用 CI 门禁前先运行 `flutterguard config doctor`。 -5. 只有项目边界已经明确时,再添加 architecture layers/modules。 - -完整决策模型见 [配置策略](CONFIGURATION_STRATEGY.md)。 - -### 基础配置(大多数用户适用) - -```yaml -include: - - lib/** - -exclude: - - lib/generated/** - - lib/**.g.dart - - lib/**.freezed.dart - - lib/**.mocks.dart - -rules: - large_file: - enabled: true - maxLines: 500 - large_class: - enabled: true - maxLines: 300 - large_build_method: - enabled: true - maxLines: 80 - lifecycle_resource: - enabled: true - missing_const_constructor: - enabled: true - device_lifecycle: - enabled: true - mqtt_connection: - enabled: true - ble_scanning: - enabled: true - maxScanDurationMs: 10000 - iot_security: - enabled: true - requireTls: true - pubspec_security: - enabled: true -``` - -### 完整配置(含架构约束) - -```yaml -# ... include/exclude/rules 同基础配置 ... - -architecture: - layers: - - name: presentation - path: lib/presentation/** - allowed_deps: [domain, core] - - name: domain - path: lib/domain/** - allowed_deps: [core] - - name: data - path: lib/data/** - allowed_deps: [domain, core] - - name: core - path: lib/core/** - allowed_deps: [] - - modules: - - name: device_mqtt - path: lib/device/mqtt/** - allowed_deps: [domain, core] - - name: device_ble - path: lib/device/ble/** - allowed_deps: [domain, core] - - detect_cycles: true - layer_violation: - enabled: true - module_violation: - enabled: true -``` - -> **注意**: 架构规则(`layer_violation`、`module_violation`、`circular_dependency`)需要在配置中**显式声明** `architecture.layers`、`architecture.modules` 和/或 `architecture.detect_cycles`。它们不会自动发现项目边界。 - -> **Glob 模式约定**: 无论在什么平台上,YAML 配置中的 glob 模式均使用正斜杠 `/`。切勿使用反斜杠。 - ---- - -## 检测规则 - -| 规则 ID | 等级 | 领域 | 优先级 | 检测内容 | 配置要求 | -|---------|------|------|--------|----------|----------| -| `large_file` | LOW | standards | P2 | 文件行数超过 `maxLines` | — | -| `large_class` | LOW | standards | P2 | 类体行数超过 `maxLines` | — | -| `large_build_method` | MEDIUM | performance | P1 | `build()` 方法行数超过 `maxLines` | — | -| `lifecycle_resource_not_disposed` | MEDIUM | performance | P1 | 未释放的 StreamSubscription、Timer、AnimationController、TextEditingController、ScrollController、FocusNode、MqttClient、BluetoothDevice、StreamController | — | -| `missing_const_constructor` | LOW | standards | P2 | Widget 类缺少 `const` 构造函数 | — | -| `layer_violation` | HIGH | architecture | P0 | 跨架构层的依赖违规 | `architecture.layers` * | -| `module_violation` | HIGH | architecture | P0 | 跨业务模块的依赖违规 | `architecture.modules` * | -| `circular_dependency` | MEDIUM | architecture | P1 | 文件级循环依赖 | `architecture.detect_cycles` * | -| `device_lifecycle` | HIGH | architecture | P0 | 不平衡的 init/teardown 配对(initState↔dispose、connect↔disconnect 等) | — | -| `mqtt_connection` | HIGH | architecture | P0 | MQTT connect/disconnect 配对、硬编码 broker URL | — | -| `iot_security` | HIGH | architecture | P0 | 硬编码凭证、明文 MQTT/HTTP、不安全 BLE | `rules.iot_security.requireTls` | -| `ble_scanning` | MEDIUM | architecture | P1 | BLE startScan/stopScan 配对、扫描超时 | `rules.ble_scanning.maxScanDurationMs` | -| `pubspec_security` | MEDIUM | standards | P2 | 无界依赖、已废弃包、过旧 IoT 依赖版本 | — | - -* 需在 YAML 配置中显式声明才能激活。 - ---- - -## 输出 - -### 终端表格(默认) - -按领域分组的彩色终端报告,显示总评分、文件数、问题数及每个问题的详情。 - -### JSON 报告 - -`--format json` 将报告写入 `--output` 目录下的 `report.json`。 - -示例结构: - -```json -{ - "version": "1.0.0", - "generatedAt": "2026-06-09T12:00:00.000Z", - "projectPath": "/path/to/project", - "score": 85, - "summary": { - "total": 3, - "high": 1, - "medium": 1, - "low": 1, - "suppressed": 0, - "suppressedByBaseline": 0, - "byDomain": { - "architecture": { "high": 1, "medium": 0, "low": 0, "total": 1 } - } - }, - "issues": [] -} -``` - -### SARIF 报告 - -`--format sarif` 会写入 `.flutterguard/report.sarif`,可上传到 GitHub Code Scanning。high、medium、low 分别映射为 SARIF `error`、`warning`、`note`。 - -### Suppression 与 baseline - -对已确认的误报可以使用源码注释: - -```dart -// flutterguard: ignore missing_const_constructor -// flutterguard: ignore iot_security, mqtt_connection -// flutterguard: ignore all -``` - -注释只作用于当前行和下一行。 - -推荐 CI 接入顺序: - -```bash -flutterguard config doctor -flutterguard baseline create . -flutterguard baseline check . --baseline .flutterguard/baseline.json --no-growth -flutterguard scan . --baseline .flutterguard/baseline.json --format json --fail-on high -``` - -## 评分 - -``` -score = max(0, 100 - high×10 - medium×4 - low×1) -``` - -| 分数段 | 等级 | -|--------|------| -| 80–100 | 优秀 | -| 50–79 | 需关注 | -| 0–49 | 需整改 | - ---- - -## CI 集成 - -### GitHub Actions - -```yaml -name: FlutterGuard - -on: [push, pull_request] - -jobs: - scan: - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - uses: dart-lang/setup-dart@v1 - with: - sdk: 3.3.0 - - name: Install FlutterGuard - run: dart pub global activate flutterguard_cli - - name: Scan - run: flutterguard scan . --format json --baseline .flutterguard/baseline.json --fail-on high --min-score 80 -``` - -### GitHub Code Scanning - -```yaml -name: FlutterGuard SARIF - -on: [push, pull_request] - -jobs: - code-scanning: - runs-on: ubuntu-latest - permissions: - security-events: write - contents: read - steps: - - uses: actions/checkout@v4 - - uses: dart-lang/setup-dart@v1 - with: - sdk: 3.3.0 - - run: dart pub global activate flutterguard_cli - - run: flutterguard scan . --format sarif --baseline .flutterguard/baseline.json - - uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: .flutterguard/report.sarif -``` - -### GitLab CI - -```yaml -flutterguard: - image: dart:3.3.0 - script: - - dart pub global activate flutterguard_cli - - flutterguard scan . --format json --fail-on high --min-score 80 - artifacts: - paths: - - .flutterguard/report.json - when: always -``` - -### pre-commit hook - -```yaml -# .pre-commit-config.yaml -repos: - - repo: local - hooks: - - id: flutterguard - name: FlutterGuard scan - entry: flutterguard scan . --fail-on high - language: system - pass_filenames: false - always_run: true -``` - -### 本地脚本 - -
-macOS / Linux - -```bash -#!/usr/bin/env bash -# scan_ci.sh -if flutterguard scan . --format json --fail-on high --min-score 80; then - echo "All checks passed!" -else - status=$? - echo "FlutterGuard failed with exit code $status." - exit "$status" -fi -``` -
- -
-Windows (PowerShell) - -```powershell -# scan_ci.ps1 -$ErrorActionPreference = "Stop" -flutterguard scan . --format json --fail-on high --min-score 80 -$status = $LASTEXITCODE - -if ($status -eq 0) { - Write-Host "All checks passed!" -ForegroundColor Green -} else { - Write-Host "FlutterGuard failed with exit code $status." -ForegroundColor Red - exit $status -} -``` -
- ---- - -## 常见问题 - -### Windows: ANSI 颜色显示为原始转义字符 - -使用 **Windows Terminal**(Windows 10/11 自带)而非旧版 cmd.exe。也可添加 `--no-color` 禁用 ANSI 输出: - -```powershell -flutterguard scan . --no-color -``` - -### Windows: "API key required" 错误 - -说明当前 shell 解析到了旧版全局二进制。显式运行当前目录编译产物: - -```powershell -.\flutterguard.exe scan . -``` - -或重新安装: - -```powershell -dart pub global deactivate flutterguard_cli -dart pub global activate flutterguard_cli -``` - -### Windows: 中文输出显示乱码 - -```powershell -# PowerShell 中设置 UTF-8 编码 -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 -# 推荐使用 Windows Terminal,默认支持 UTF-8 -``` - -### glob 模式始终使用正斜杠 - -在 `flutterguard.yaml` 中,所有平台的路径模式均使用 `/`: - -```yaml -# 正确 -path: lib/presentation/** - -# 错误(Windows 也不要用反斜杠) -path: lib\presentation\** -``` - ---- - -## 仓库结构 - -``` -flutterguard/ -├── packages/ -│ └── flutterguard_cli/ CLI 实现(主开发路径) -├── archive/ 已归档的运行时追踪包 -└── examples/ - └── scan_demo/ 扫描示例项目 -``` - -## 开发 - -```bash -# 全平台通用 -git clone https://github.com/lizy-coding/flutterguard.git -cd flutterguard -dart pub get -dart pub global activate melos -melos bootstrap - -# 常用命令 -dart run melos run analyze # 静态分析 -dart run melos run test:cli # 运行测试 -dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard -``` - -## 扩展阅读 - -| 文档 | 内容 | -|------|------| -| [docs/USAGE.md](docs/USAGE.md) | 完整使用指南(全平台) | -| [docs/WINDOWS_ASSESSMENT.md](docs/WINDOWS_ASSESSMENT.md) | Windows 兼容性评估报告 | -| [docs/FLUTTERGUARD_SPEC.md](docs/FLUTTERGUARD_SPEC.md) | 技术规格 | -| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 架构概览 | - -## License - -MIT diff --git a/RELEASE_v0.3.0.md b/RELEASE_v0.3.0.md deleted file mode 100644 index 0d193b1..0000000 --- a/RELEASE_v0.3.0.md +++ /dev/null @@ -1,321 +0,0 @@ -# Release v0.3.0 — Execution Prompt - -## Summary -Version bump 0.2.0 → 0.3.0. Update docs, package metadata, CHANGELOG, compile, tag, push. - ---- - -## Step 1: Version Bump - -### 1a. `packages/flutterguard_cli/pubspec.yaml` -`version: 0.2.0` → `version: 0.3.0` - -### 1b. `packages/flutterguard_cli/bin/flutterguard.dart` -`const _version = '0.2.0';` → `const _version = '0.3.0';` - ---- - -## Step 2: Update `docs/FLUTTERGUARD_SPEC.md` - -### 2a. Header line 6 -`Version: M2 (Milestone 2) — Architecture Overhaul | Last Updated: 2026-05-17` -→ `Version: M3 (Milestone 3) — Incremental Scan + Rule Introspection | Last Updated: 2026-06-28` - -### 2b. §1 Architecture Overview lines 44-63 - -Replace the entire flow diagram block with: - -``` -User runs: flutterguard scan [] [--changed-only] [--base main] - │ - ├── 1. ArgParser parses CLI flags - ├── 2. ScanConfig.fromFile() loads YAML config - ├── 3. FileCollector.collect() globs .dart files - │ └── if --changed-only: FileCollector.getChangedFiles() filters by git diff - ├── 4. Per-file scan: 11 rule classes analyze each file (13 rule IDs) - │ ├── LargeUnitsRule (file size, class size, build method size) - │ ├── LifecycleResourceRule (undisposed controllers/streams) - │ ├── LayerViolationRule (cross-layer import violations) - │ ├── ModuleViolationRule (cross-module import violations) - │ ├── CircularDependencyRule (file-level cycle detection) - │ ├── MissingConstConstructorRule (widgets missing const constructor) - │ ├── DeviceLifecycleRule (init/teardown pairing) - │ ├── MqttConnectionRule (MQTT connect/disconnect, hardcoded URLs) - │ ├── BleScanningRule (BLE scan lifecycle, timeout) - │ ├── IotSecurityRule (hardcoded secrets, cleartext, insecure BLE) - │ └── PubspecSecurityRule (unbounded deps, deprecated packages) - │ └── (changed-only: circular_dependency skipped) - ├── 5. Issues sorted by risk level (high → medium → low) - ├── 6. ReportGenerator generates output - │ ├── Table → terminal stdout - │ └── JSON → .flutterguard/report.json - └── 7. CI gate check (exit 1 if fail threshold exceeded) -``` - -Also update line 71: `Rule class interface` table row. - -### 2c. §4 CLI Contract — Add flags - -After `--min-score` in the scan command block, add: - -``` - --changed-only Only scan .dart files changed since --base (default: false) - --base Git base ref for changed-only (default: main) -``` - -After the scan block, add: - -``` -flutterguard rules [options] - --format (-f) Output format: table | json (default: table) - -flutterguard explain -``` - -In Exit Codes table, update Code 2 description: -``` -| 2 | Scan/explain error (bad path, config parse error, unknown rule ID) | -``` - -### 2d. §6.1 JSON Report Schema — Add scanMode - -After `"generatedAt"` line, insert: -```json - "scanMode": "full|changed", -``` - -### 2e. §7 Static Rules Detail — Append IoT rules - -After §7.8 (missing_const_constructor), append 5 new sections: - -``` -### 7.9 device_lifecycle (RiskLevel: high, Domain: architecture, Priority: p0) - -**Detection**: For each class, check that device lifecycle methods have balanced init/teardown pairs: -- `initState` ↔ `dispose` -- `connect()` ↔ `disconnect()` -- `start()` ↔ `stop()` -- `listen()` / `subscribe()` ↔ `cancel()` / `unsubscribe()` - -**Implementation**: Parse with `package:analyzer`, check method name presence for balanced pairs. - -### 7.10 mqtt_connection (RiskLevel: high, Domain: architecture, Priority: p0) - -**Detection**: -1. Find MQTT client field declarations (types matching `*MqttClient`, `*MQTT*`) -2. Check for `connect()` calls — verify `disconnect()` exists in dispose-like methods -3. Check for `subscribe()` calls — verify corresponding `unsubscribe()` calls exist -4. Check for hardcoded broker URLs (string literals containing `tcp://` or `mqtt://`) - -### 7.11 ble_scanning (RiskLevel: medium, Domain: architecture, Priority: p1) - -**Detection**: -1. Find BLE-related field declarations (types matching `*Ble*`, `*Bluetooth*`) -2. Check for `startScan()` calls — verify `stopScan()` exists in dispose-like methods -3. Check for `connect()` calls to BLE devices — verify `disconnect()` exists -4. Check that scan timeout is configured (look for timeout parameter in `startScan()`) - -**Config**: `maxScanDurationMs: int (default: 10000)` - -### 7.12 iot_security (RiskLevel: high, Domain: architecture, Priority: p0) - -**Detection**: -| Check | Pattern | Severity | -|-------|---------|----------| -| Hardcoded password/token | String literal matching `password`/`token`/`secret` assignment | high | -| Cleartext MQTT | `tcp://` host or port `1883` in MQTT config | high | -| Cleartext HTTP | `http://` in IoT context packages | medium | -| Insecure BLE | BLE without `bond`/`pair` references | medium | - -**Config**: `requireTls: bool (default: true)` - -### 7.13 pubspec_security (RiskLevel: medium, Domain: standards, Priority: p2) - -**Detection**: Analyzes the project's `pubspec.yaml` rather than individual `.dart` files. -| Check | Pattern | Severity | -|-------|---------|----------| -| Unbounded dependency | `^any` or no version constraint | medium | -| Outdated mqtt_client | `mqtt_client` version < 10.x.x | high | -| Outdated flutter_blue | `flutter_blue` (deprecated, use `flutter_blue_plus`) | high | -| Outdated http | `http` package < 1.x.x with cleartext patterns | medium | -``` - -### 2f. §8 Test Contracts — Full replace - -Replace header: `### 8.1 CLI Tests (13 tests)` → `### 8.1 CLI Tests (37 tests)` - -Append to the test table: - -``` -| changed_only_filters_files | temp git repo, 2 files, change 1 | scanMode=changed, only changed-file issues | -| changed_only_full_scan_when_no_git | non-git dir with changedOnly | scanMode=full | -| changed_only_skips_circular_dependency | cycle fixture with changedOnly | 0 circular_dependency issues | -| registry_contains_all_13_rules | RuleRegistry.all() | length == 13 | -| registry_find_returns_correct_meta | find('large_file') | non-null, correct id/domain | -| registry_find_unknown_returns_null | find('nonexistent') | null | -``` - -### 2g. §9 Evolution Roadmap — Mark M3 complete - -Add after M2 entry: - -``` -### M3 (Completed v0.3.0) — Incremental Scan + Rule Introspection - -Key deliverables: -- `--changed-only` incremental scan via git diff (skips cyclic dep in changed mode) -- `flutterguard rules` / `flutterguard explain` subcommands with RuleMeta registry -- 37 tests (26 base + 6 new + 5 existing) -- RuleMeta class + RuleRegistry for rule introspection -``` - -### 2h. §10 Known Limitations — Update - -- Remove entries 5 and 6 (IoT MQTT/BLE and pubspec_security — now implemented) -- Add entry: -``` -9. **Incremental scan**: `--changed-only` skips circular_dependency entirely - in changed mode. Layer/module violations only detected if the changed file - is the source of the illegal import. Pubspec security is not re-checked - unless the changed file set includes pubspec.yaml. -``` - -### 2i. §12 IoT Domain Rules (Planned) — Delete entire section - -The 5 IoT rules (12.1–12.5) are now documented in §7.9–§7.13. Delete the entire §12 section. - -### 2j. Append §13 Rule Registry + §14 Incremental Scan - -After the final line of the document (end of existing content), append: - -``` ---- -## 13. Rule Registry & Explain Commands - -### 13.1 RuleMeta -Data class in `lib/src/rule_meta.dart`: -- `id` — rule identifier -- `name` — Chinese display name -- `domain` — architecture / performance / standards -- `riskLevel` — high / medium / low -- `priority` — p0 / p1 / p2 -- `purpose` — detection purpose -- `riskReason` — why this matters -- `badExample` — anti-pattern -- `fixSuggestion` — recommended fix -- `configKeys` — YAML config keys -- `cicdSafe` — whether suitable for CI gating - -### 13.2 RuleRegistry -Singleton in `lib/src/rules/registry.dart`: -- `all()` → `List` (13 entries) -- `find(String id)` → `RuleMeta?` - -### 13.3 CLI Commands -- `flutterguard rules` — table of all rules -- `flutterguard rules --format json` — JSON payload -- `flutterguard explain ` — full detail; exit 2 on unknown ID - ---- -## 14. Incremental Scan (--changed-only) - -### Flow -1. Check `.git/` exists in project path -2. `git diff --name-only --diff-filter=ACMR` -3. `git ls-files --others --exclude-standard` (untracked files) -4. Union both sets, filter to .dart files -5. Feed filtered files to all rules -6. CircularDependencyRule force-disabled in changed-only mode - -### Behavior Matrix -| Condition | Behavior | scanMode | -|-----------|----------|----------| -| Non-git dir | Fallback to full scan | full | -| --changed-only, 0 changes | "No Dart files found" exit | full | -| --changed-only, changes > 0 | Only scan changed .dart files | changed | -| --base not specified | Defaults to 'main' | — | -``` - ---- - -## Step 3: Update `AGENTS.md` - -| Line | Change | -|------|--------| -| **Key Commands** | `(26 tests)` → `(37 tests)` | -| **Key Commands row 3** | After json row, add: `\| \`flutterguard scan --changed-only\` \| Incremental scan of git-changed files \|` | -| **Key Commands row 4** | After above: `\| \`flutterguard rules\` / \`flutterguard explain \` \| List/describe rules \|` | -| **CLI Entry Point** | After last sentence, append: `Supports --changed-only incremental scan and rule introspection (rules/explain).` | -| **Source Layout rules/** | Before `pubspec_security.dart` line, add 2 lines: `rule_meta.dart` and `rules/registry.dart` | - ---- - -## Step 4: Update `packages/flutterguard_cli/CHANGELOG.md` - -Insert at top (before `## 0.1.1`): - -```markdown -## 0.3.0 (2026-06-28) - -### Incremental Scan (--changed-only) - -- **cli:** New `--changed-only` flag — only scans `.dart` files changed since `--base` (default: `main`) -- **cli:** Integrated `git diff --name-only` + `git ls-files --others` for change detection -- **cli:** Non-git fallback: gracefully degrades to full scan -- **cli:** `circular_dependency` auto-disabled in changed-only mode -- **cli:** JSON report now includes `scanMode: full|changed` field - -### Rule Introspection (rules / explain) - -- **cli:** New `flutterguard rules` subcommand — list all 13 rules with ID, domain, name -- **cli:** New `flutterguard explain ` subcommand — detailed purpose, risk, example, fix, config -- **cli:** New `RuleMeta` class in `lib/src/rule_meta.dart` — structured rule metadata -- **cli:** New `RuleRegistry` singleton in `lib/src/rules/registry.dart` — registry with `all()` and `find()` -- **cli:** Output supports both `table` (default) and `--format json` - -### Tests - -- **test:** 37 total tests (26 base + 6 new + 5 existing) -- **test:** 3 `changed-only` tests: git repo filter, non-git fallback, skip cycle -- **test:** 3 registry tests: all 13 rules, find by ID, unknown returns null - -### Infrastructure - -- **meta:** Version bumped to 0.3.0 -- **meta:** `AGENTS.md` and `FLUTTERGUARD_SPEC.md` updated for new features -``` - ---- - -## Step 5: Delete ITERATION_1.md - -Remove the plan file (no longer needed after release): - -```bash -rm /Users/forest/code/flutterguard/ITERATION_1.md -``` - ---- - -## Step 6: Compile + Verify - -```bash -dart run melos run analyze -dart run melos run test:cli -dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard -./flutterguard --version # expect "flutterguard 0.3.0" -./flutterguard rules # expect 13 rules -./flutterguard explain large_file # expect full detail -``` - ---- - -## Step 7: git commit + tag + push - -```bash -git add -A -git commit -m "cli: v0.3.0 — --changed-only + rules/explain" -git tag v0.3.0 -git push origin develop -git push origin v0.3.0 -``` diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index 6d1a306..0000000 --- a/ROADMAP.md +++ /dev/null @@ -1,185 +0,0 @@ -# FlutterGuard Evolution Roadmap - -FlutterGuard remains an IoT / smart home Flutter static analysis toolchain. The -core product direction is static governance for Flutter projects, not runtime -observability, APM, cloud dashboards, crash reporting, or SDK instrumentation. - -## Guiding Principles - -- Keep the CLI as the source of truth for scanning, configuration, reports, and - CI behavior. -- Prefer local-first integrations that consume CLI output before introducing - hosted services. -- Focus on real team adoption: low-noise CI, editor feedback, baseline - management, and actionable reports. -- Avoid archived runtime packages for new feature work. - -## Architecture Invariants (do not violate) - -These were established by the v0.5.0 refactor and are binding for all future -work. New agents MUST follow them instead of reintroducing pre-0.5.0 patterns. - -- Rule metadata and execution are wired through `lib/src/rules/catalog.dart`. - It is the single source of truth. Do NOT wire rules directly in - `bin/flutterguard.dart` or `scanner.dart`, and do NOT add reflection or - dynamic plugin loading. -- `bin/flutterguard.dart` is thin: top-level routing, help, positional path, - and exit codes only. Functional command behavior lives in `lib/src/cli/` - (`scan_command`, `baseline_commands`, `config_commands`, `issue_commands`, - `rule_commands`, `cli_parsers`). -- Rules consume shared analysis state, not ad-hoc file reads: - - `SourceWorkspace` for source/AST caching and diagnostics. - - `ImportGraph` for resolved Dart imports. - - `DependencyBoundaryEngine` for layer/module boundary checks. - - `ScanContext` for scan scope/mode (project/all/target files). -- Each rule keeps the standalone `analyze(List files, {SourceWorkspace? - workspace})` API so direct rule tests and programmatic consumers keep working. -- New rule flow is unchanged: spec entry → config typedef → rule class → - fixture → test → wire into `rules/catalog.dart`. -- Workspace SDK constraint is `^3.11.5`; keep CI (`release.yml`, - `flutterguard.yml`) SDK aligned when it changes. - -## Current State (as of v0.5.0) - -- Milestone 0.4.x (CI Adoption Hardening) is DONE: baseline `stats` / `prune` / - `check --no-growth`, config profiles, install diagnostics, issue export, and - no-match / changed-only scan policy hardening all shipped in 0.4.0–0.4.1. -- v0.5.0 was consumed by the internal architecture refactor above rather than - the originally planned developer-workflow integrations. Those integrations - now move to Milestone 0.6. - -## Milestone 0.6 — Developer Workflow Integrations - -Goal: Surface FlutterGuard findings before CI by integrating with developer -tools. - -Deliverables: - -- Official GitHub Action: - - install FlutterGuard - - run scan - - upload JSON / SARIF artifacts - - optionally upload SARIF to GitHub Code Scanning -- GitHub PR annotations mode for changed-line findings. -- VS Code extension MVP driven by CLI JSON output: - - run scan on demand - - show diagnostics in the editor - - open rule explanations - - insert suppression comments -- Config profiles: - - `recommended` - - `strict` - - `migration` - - `iot-security` - - `architecture-only` - -Exit Criteria: - -- New users can add FlutterGuard to GitHub Actions with one reusable action. -- Developers can see and suppress findings in VS Code without reading CI logs. -- Teams can start from a profile instead of hand-authoring every rule setting. - -## Milestone 0.7 — Fixes, Reports, and Workspace Scale - -Goal: Move from reporting issues to helping teams reduce them across larger -Flutter codebases. - -Deliverables: - -- Auto-fix support for safe cases: - - add missing `const` constructors where deterministic - - insert suppression comments for selected findings - - suggest `pubspec.yaml` dependency replacements -- Static HTML report output for local reviews and architecture audits. -- Melos / monorepo workspace scanning: - - scan all packages - - scan one package - - scan affected packages - - merge package reports -- Architecture graph exports: - - Mermaid - - DOT - - dependency cycle graph - - layer / module violation graph - -Exit Criteria: - -- Teams can track and reduce findings without building custom scripts. -- Large Flutter workspaces can run FlutterGuard package-by-package. -- Architecture violations can be reviewed visually during refactors. - -## Milestone 0.8 — Deeper Static Analysis - -Goal: Reduce false positives and expand IoT-specific detection using stronger -AST and project context. - -Deliverables: - -- Lifecycle detection improvements: - - helper dispose methods - - base classes and mixins - - composite disposer patterns - - resource collections -- Import resolution improvements: - - package name awareness - - workspace package imports - - generated-file boundaries -- Additional IoT checks: - - OTA update safety patterns - - token and key storage risks - - device pairing flow risks - - local network discovery risks - - permission and privacy configuration checks - -Exit Criteria: - -- Core lifecycle and IoT security rules produce fewer false positives on real - projects. -- Monorepo package imports are handled consistently. -- New IoT checks remain static-only and CI-safe. - -## Milestone 0.9+ — Extensibility and Team Governance - -Goal: Support mature teams that need reusable governance policies without -turning FlutterGuard into a hosted platform. - -Deliverables: - -- Reusable team rule packs. -- Shared config inheritance: - - local file extends - - bundled profiles - - organization profile conventions -- Custom rule SDK exploration. -- IntelliJ / Android Studio plugin after VS Code integration stabilizes. -- Optional GitHub App only if GitHub Action annotations are insufficient. - -Exit Criteria: - -- Teams can standardize policies across many Flutter apps. -- Customization is possible without weakening the built-in static analysis - contract. -- Hosted services remain optional and out of the core product path. - -## Non-Goals - -- Runtime tracing SDK. -- APM or observability platform. -- Crash reporting. -- Cloud dashboard as a required workflow. -- Network proxy or HTTP inspector. -- Flutter widget library. -- Editing archived runtime packages in `archive/`. - -## Recommended Priority - -1. Official GitHub Action. -2. Baseline management commands. -3. VS Code extension MVP. -4. Config profiles and inheritance. -5. PR annotations. -6. Auto-fix for deterministic cases. -7. Static HTML report. -8. Workspace / monorepo scanning. -9. Architecture graph export. -10. Custom rule SDK exploration. diff --git a/analysis_options.yaml b/analysis_options.yaml index 5d31927..a12411a 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,6 +1,8 @@ +include: package:lints/recommended.yaml + analyzer: exclude: - - archive/** + - test/fixtures/** language: strict-casts: true strict-inference: true @@ -8,8 +10,6 @@ analyzer: linter: rules: - avoid_dynamic_calls - - prefer_const_constructors - prefer_const_declarations - prefer_final_locals - unawaited_futures - - use_key_in_widget_constructors diff --git a/archive/README.md b/archive/README.md deleted file mode 100644 index df504cb..0000000 --- a/archive/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Archive — Reserved for Future Use - -These packages were frozen runtime-tracing implementations from v0.1.0. - -| Package | Purpose | Status | -|---------|---------|--------| -| flutterguard_core | Runtime tracing engine (Zone-based) | Reserved | -| flutterguard_dio | Dio HTTP interceptor | Reserved | -| flutterguard_flutter | Flutter runtime hooks | Reserved | - -**Decision**: Superseded by CLI static analysis approach (Path A). -Zero code modifications — preserved as-is for reference. -To re-activate: `mv archive/ packages/` and restore melos.yaml entries. diff --git a/archive/flutterguard_core/AGENTS.md b/archive/flutterguard_core/AGENTS.md deleted file mode 100644 index 2530bfa..0000000 --- a/archive/flutterguard_core/AGENTS.md +++ /dev/null @@ -1,42 +0,0 @@ -# Package: flutterguard_core [FROZEN] - -## Role -Flow-level aspect tracing engine — trace models, Zone-based context propagation, ring buffer store, and JSON/Markdown exporters. - -**Status: FROZEN**. No new features. Bug fixes only. - -## Dependency Map -- depends on: meta ^1.12.0 -- depended by: flutterguard_cli (path), flutterguard_dio (path), flutterguard_flutter (path) - -## Entry Points -- lib barrel: `lib/flutterguard_core.dart` - -## Key Source Files -| File | Responsibility | -|------|---------------| -| `src/flutter_guard.dart` | Static API: action(), span(), record*(), export*() | -| `src/guard_config.dart` | FlutterGuardConfig model | -| `src/trace_context.dart` | Zone-based traceId propagation | -| `src/trace_model.dart` | Data models: FlowTrace, SpanTrace, NetworkTrace, etc. | -| `src/trace_store.dart` | Ring buffer singleton store (100 trace default) | -| `src/json_exporter.dart` | JSON export formatting | -| `src/markdown_exporter.dart` | Markdown export formatting | - -## Pubspec Overrides -melos-managed: none (core has no path deps) - -## Analysis Options -Inherits root strict-casts/strict-inference + package:lints/recommended.yaml (from pubspec). - -## Test -- command: `melos run test:core` -- test file: `test/flutter_guard_test.dart` (7 tests) - -## Forward Compatibility -- Keep public API stable (flutterguard_cli depends on this via path) -- Do NOT add new exports without team consensus -- Do NOT remove existing public APIs (breaks cli, dio, flutter packages) - -## Why Frozen -Runtime tracing approach was superseded by static analysis (Path A). Core remains as a reference implementation and may be re-visited in M4 roadmap. diff --git a/archive/flutterguard_core/lib/flutterguard_core.dart b/archive/flutterguard_core/lib/flutterguard_core.dart deleted file mode 100644 index c0fd910..0000000 --- a/archive/flutterguard_core/lib/flutterguard_core.dart +++ /dev/null @@ -1,9 +0,0 @@ -library flutterguard_core; - -export 'src/trace_model.dart'; -export 'src/guard_config.dart'; -export 'src/trace_context.dart'; -export 'src/trace_store.dart'; -export 'src/flutter_guard.dart'; -export 'src/json_exporter.dart'; -export 'src/markdown_exporter.dart'; diff --git a/archive/flutterguard_core/lib/src/flutter_guard.dart b/archive/flutterguard_core/lib/src/flutter_guard.dart deleted file mode 100644 index a4431ce..0000000 --- a/archive/flutterguard_core/lib/src/flutter_guard.dart +++ /dev/null @@ -1,162 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'guard_config.dart'; -import 'trace_context.dart'; -import 'trace_model.dart'; -import 'trace_store.dart'; -import 'json_exporter.dart'; -import 'markdown_exporter.dart'; - -class FlutterGuard { - static FlutterGuardConfig _config = const FlutterGuardConfig(); - static final Random _random = Random(); - - static void configure(FlutterGuardConfig config) { - _config = config; - TraceStore.instance.configure(maxTraces: config.maxTraces); - } - - static String? get currentTraceId => TraceContext.currentTraceId; - - static Future action( - String name, - FutureOr Function() body, { - Map tags = const {}, - }) async { - if (!_config.enabled) return body(); - - final traceId = _generateId(); - final trace = FlowTrace( - id: traceId, - name: name, - startTime: DateTime.now(), - tags: Map.unmodifiable({ - ...tags, - 'traceId': traceId, - }), - ); - - TraceStore.instance.beginFlow(trace); - - bool failed = false; - T result; - - try { - result = await TraceContext.runWithTraceId(traceId, body); - } catch (e, st) { - failed = true; - trace.errors.add(ErrorTrace( - flowId: traceId, - errorType: e.runtimeType.toString(), - message: e.toString(), - stackTrace: st.toString(), - time: DateTime.now(), - )); - trace.status = FlowStatus.failed; - rethrow; - } finally { - if (!failed) { - TraceStore.instance.endFlow(traceId, failed: false); - } else { - trace.endTime = DateTime.now(); - } - } - - return result; - } - - static Future span( - String name, - FutureOr Function() body, { - Map tags = const {}, - }) async { - if (!_config.enabled) return body(); - - final flowId = TraceContext.currentTraceId; - if (flowId == null) return body(); - - final span = SpanTrace( - id: _generateId(), - flowId: flowId, - name: name, - startTime: DateTime.now(), - tags: tags, - ); - - TraceStore.instance.addSpanToActive(flowId, span); - - try { - final result = await Future.value(body()); - span.endTime = DateTime.now(); - return result; - } catch (e, st) { - span.endTime = DateTime.now(); - span.errorType = e.runtimeType.toString(); - span.errorMessage = e.toString(); - span.stackTrace = st.toString(); - TraceStore.instance.addErrorToActive( - flowId, - ErrorTrace( - flowId: flowId, - errorType: e.runtimeType.toString(), - message: e.toString(), - stackTrace: st.toString(), - time: DateTime.now(), - ), - ); - rethrow; - } - } - - static void recordNetwork(NetworkTrace trace) { - if (!_config.enabled) return; - final flowId = trace.flowId ?? TraceContext.currentTraceId; - TraceStore.instance.addNetworkToActive(flowId, trace); - } - - static void recordRoute(RouteTrace trace) { - if (!_config.enabled || !_config.collectRoutes) return; - final flowId = trace.flowId ?? TraceContext.currentTraceId; - TraceStore.instance.addRouteToActive(flowId, trace); - } - - static void recordError(ErrorTrace trace) { - if (!_config.enabled || !_config.collectErrors) return; - final flowId = trace.flowId ?? TraceContext.currentTraceId; - TraceStore.instance.addErrorToActive(flowId, trace); - } - - static void recordFrame(FrameTrace trace) { - if (!_config.enabled || !_config.collectFrames) return; - final flowId = trace.flowId ?? TraceContext.currentTraceId; - TraceStore.instance.addFrameToActive(flowId, trace); - } - - static void recordBuild(String boundaryName) { - if (!_config.enabled || !_config.collectBuilds) return; - TraceStore.instance.recordBuild(TraceContext.currentTraceId, boundaryName); - } - - static String exportJson() { - final traces = TraceStore.instance.getAllTraces(); - return JsonExporter.export(traces); - } - - static String exportMarkdown() { - final traces = TraceStore.instance.getAllTraces(); - return MarkdownExporter.export(traces); - } - - static void reset() { - TraceStore.instance.reset(); - } - - static String _generateId() { - final bytes = List.generate(16, (_) => _random.nextInt(256)); - return bytes - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join() - .substring(0, 12); - } -} diff --git a/archive/flutterguard_core/lib/src/guard_config.dart b/archive/flutterguard_core/lib/src/guard_config.dart deleted file mode 100644 index 7bfa57c..0000000 --- a/archive/flutterguard_core/lib/src/guard_config.dart +++ /dev/null @@ -1,23 +0,0 @@ -class FlutterGuardConfig { - final bool enabled; - final bool collectErrors; - final bool collectFrames; - final bool collectRoutes; - final bool collectBuilds; - final int slowFlowMs; - final int jankFrameMs; - final int maxTraces; - final bool sanitizeNetwork; - - const FlutterGuardConfig({ - this.enabled = true, - this.collectErrors = true, - this.collectFrames = true, - this.collectRoutes = true, - this.collectBuilds = true, - this.slowFlowMs = 1000, - this.jankFrameMs = 16, - this.maxTraces = 100, - this.sanitizeNetwork = true, - }); -} diff --git a/archive/flutterguard_core/lib/src/json_exporter.dart b/archive/flutterguard_core/lib/src/json_exporter.dart deleted file mode 100644 index 2993cd7..0000000 --- a/archive/flutterguard_core/lib/src/json_exporter.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'dart:convert'; - -import 'trace_model.dart'; - -class JsonExporter { - static const String version = '1.0.0'; - - static String export(List traces) { - final summary = _buildSummary(traces); - final payload = { - 'version': version, - 'generatedAt': DateTime.now().toIso8601String(), - 'summary': summary, - 'traces': traces.map((t) => t.toJson()).toList(), - }; - return const JsonEncoder.withIndent(' ').convert(payload); - } - - static Map _buildSummary(List traces) { - final total = traces.length; - final success = traces.where((t) => t.status == FlowStatus.success).length; - final failed = traces.where((t) => t.status == FlowStatus.failed).length; - final running = traces.where((t) => t.status == FlowStatus.running).length; - return { - 'total': total, - 'success': success, - 'failed': failed, - 'running': running - }; - } -} diff --git a/archive/flutterguard_core/lib/src/markdown_exporter.dart b/archive/flutterguard_core/lib/src/markdown_exporter.dart deleted file mode 100644 index 35733ba..0000000 --- a/archive/flutterguard_core/lib/src/markdown_exporter.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'trace_model.dart'; - -class MarkdownExporter { - static String export(List traces) { - final buf = StringBuffer(); - - buf.writeln('# FlutterGuard Flow Report'); - buf.writeln(); - buf.writeln('## Summary'); - buf.writeln(); - final total = traces.length; - final success = traces.where((t) => t.status == FlowStatus.success).length; - final failed = traces.where((t) => t.status == FlowStatus.failed).length; - buf.writeln('| Metric | Count |'); - buf.writeln('|--------|-------|'); - buf.writeln('| Total Flows | $total |'); - buf.writeln('| Success | $success |'); - buf.writeln('| Failed | $failed |'); - buf.writeln(); - - if (traces.isEmpty) { - buf.writeln('*No flows recorded.*'); - return buf.toString(); - } - - buf.writeln('## Runtime Flows'); - buf.writeln(); - for (final trace in traces) { - buf.writeln('### ${trace.name} `${trace.id}`'); - buf.writeln(); - buf.writeln('- **Status**: ${trace.status.name}'); - buf.writeln('- **Duration**: ${trace.durationMs}ms'); - buf.writeln('- **Started**: ${trace.startTime.toIso8601String()}'); - if (trace.endTime != null) { - buf.writeln('- **Ended**: ${trace.endTime!.toIso8601String()}'); - } - buf.writeln(); - - if (trace.spans.isNotEmpty) { - buf.writeln('#### Spans'); - buf.writeln(); - buf.writeln('| Name | Duration | Error |'); - buf.writeln('|------|----------|-------|'); - for (final span in trace.spans) { - final error = span.errorType ?? '-'; - buf.writeln('| ${span.name} | ${span.durationMs}ms | $error |'); - } - buf.writeln(); - } - - if (trace.networks.isNotEmpty) { - buf.writeln('#### Network'); - buf.writeln(); - buf.writeln('| Method | Path | Status | Duration |'); - buf.writeln('|--------|------|--------|----------|'); - for (final net in trace.networks) { - buf.writeln( - '| ${net.method} | ${net.path} | ${net.statusCode ?? '-'} | ${net.durationMs}ms |'); - } - buf.writeln(); - } - - if (trace.routes.isNotEmpty) { - buf.writeln('#### Routes'); - buf.writeln(); - buf.writeln('| Type | From | To |'); - buf.writeln('|------|------|----|'); - for (final route in trace.routes) { - buf.writeln( - '| ${route.type} | ${route.from ?? '-'} | ${route.to ?? '-'} |'); - } - buf.writeln(); - } - - if (trace.errors.isNotEmpty) { - buf.writeln('#### Errors'); - buf.writeln(); - for (final error in trace.errors) { - buf.writeln('- **${error.errorType}**: ${error.message}'); - if (error.stackTrace != null) { - buf.writeln(' ```'); - buf.writeln(' ${error.stackTrace}'); - buf.writeln(' ```'); - } - } - buf.writeln(); - } - - if (trace.frames.isNotEmpty) { - buf.writeln('#### Frames'); - buf.writeln(); - buf.writeln('| Total | Build | Raster | Janky |'); - buf.writeln('|-------|-------|--------|-------|'); - for (final frame in trace.frames) { - buf.writeln( - '| ${frame.totalSpanMs}ms | ${frame.buildDurationMs}ms | ${frame.rasterDurationMs}ms | ${frame.janky} |'); - } - buf.writeln(); - } - - if (trace.buildCounts.isNotEmpty) { - buf.writeln('#### Build Boundaries'); - buf.writeln(); - for (final entry in trace.buildCounts.entries) { - buf.writeln('- **${entry.key}**: ${entry.value} rebuilds'); - } - buf.writeln(); - } - - buf.writeln('---'); - buf.writeln(); - } - - return buf.toString(); - } -} diff --git a/archive/flutterguard_core/lib/src/trace_context.dart b/archive/flutterguard_core/lib/src/trace_context.dart deleted file mode 100644 index 3172117..0000000 --- a/archive/flutterguard_core/lib/src/trace_context.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'dart:async'; - -class TraceContext { - static const String _keyName = 'flutter_guard_trace_id'; - - static String? get currentTraceId { - try { - return Zone.current[_keyName] as String?; - } on TypeError { - return null; - } - } - - static Future runWithTraceId( - String? traceId, - FutureOr Function() body, - ) async { - if (traceId == null) { - final result = body(); - if (result is Future) return result; - return result; - } - return runZoned(() async { - final result = body(); - if (result is Future) return await result; - return result; - }, zoneValues: {_keyName: traceId}); - } -} diff --git a/archive/flutterguard_core/lib/src/trace_model.dart b/archive/flutterguard_core/lib/src/trace_model.dart deleted file mode 100644 index 6fb127c..0000000 --- a/archive/flutterguard_core/lib/src/trace_model.dart +++ /dev/null @@ -1,219 +0,0 @@ -import 'dart:math'; - -enum FlowStatus { running, success, failed } - -class SpanTrace { - final String id; - final String flowId; - final String name; - final DateTime startTime; - DateTime? endTime; - final Map tags; - String? errorType; - String? errorMessage; - String? stackTrace; - - SpanTrace({ - required this.id, - required this.flowId, - required this.name, - required this.startTime, - this.endTime, - Map? tags, - this.errorType, - this.errorMessage, - this.stackTrace, - }) : tags = tags ?? {}; - - int get durationMs { - final end = endTime ?? DateTime.now(); - return end.difference(startTime).inMilliseconds; - } - - Map toJson() => { - 'id': id, - 'flowId': flowId, - 'name': name, - 'startTime': startTime.toIso8601String(), - 'endTime': endTime?.toIso8601String(), - 'durationMs': durationMs, - 'tags': tags, - 'errorType': errorType, - 'errorMessage': errorMessage, - 'stackTrace': stackTrace, - }; -} - -class NetworkTrace { - final String? flowId; - final String method; - final String path; - final int? statusCode; - final int durationMs; - final int? requestSize; - final int? responseSize; - final bool success; - final String? errorType; - final String? errorMessage; - - NetworkTrace({ - this.flowId, - required this.method, - required this.path, - this.statusCode, - required this.durationMs, - this.requestSize, - this.responseSize, - required this.success, - this.errorType, - this.errorMessage, - }); - - Map toJson() => { - 'flowId': flowId, - 'method': method, - 'path': path, - 'statusCode': statusCode, - 'durationMs': durationMs, - 'requestSize': requestSize, - 'responseSize': responseSize, - 'success': success, - 'errorType': errorType, - 'errorMessage': errorMessage, - }; -} - -class RouteTrace { - final String? flowId; - final String type; - final String? from; - final String? to; - final DateTime time; - - RouteTrace({ - this.flowId, - required this.type, - this.from, - this.to, - required this.time, - }); - - Map toJson() => { - 'flowId': flowId, - 'type': type, - 'from': from, - 'to': to, - 'time': time.toIso8601String(), - }; -} - -class ErrorTrace { - final String? flowId; - final String errorType; - final String message; - final String? stackTrace; - final DateTime time; - final String? route; - - ErrorTrace({ - this.flowId, - required this.errorType, - required this.message, - this.stackTrace, - required this.time, - this.route, - }); - - Map toJson() => { - 'flowId': flowId, - 'errorType': errorType, - 'message': message, - 'stackTrace': stackTrace, - 'time': time.toIso8601String(), - 'route': route, - }; -} - -class FrameTrace { - final String? flowId; - final int totalSpanMs; - final int buildDurationMs; - final int rasterDurationMs; - final bool janky; - final DateTime time; - - FrameTrace({ - this.flowId, - required this.totalSpanMs, - required this.buildDurationMs, - required this.rasterDurationMs, - required this.janky, - required this.time, - }); - - Map toJson() => { - 'flowId': flowId, - 'totalSpanMs': totalSpanMs, - 'buildDurationMs': buildDurationMs, - 'rasterDurationMs': rasterDurationMs, - 'janky': janky, - 'time': time.toIso8601String(), - }; -} - -class FlowTrace { - final String id; - final String name; - final DateTime startTime; - DateTime? endTime; - final Map tags; - final List spans; - final List networks; - final List routes; - final List errors; - final List frames; - final Map buildCounts; - FlowStatus status; - - FlowTrace({ - required this.id, - required this.name, - required this.startTime, - this.endTime, - Map? tags, - List? spans, - List? networks, - List? routes, - List? errors, - List? frames, - Map? buildCounts, - this.status = FlowStatus.running, - }) : tags = tags ?? {}, - spans = spans ?? [], - networks = networks ?? [], - routes = routes ?? [], - errors = errors ?? [], - frames = frames ?? [], - buildCounts = buildCounts ?? {}; - - int get durationMs { - final end = endTime ?? DateTime.now(); - return max(0, end.difference(startTime).inMilliseconds); - } - - Map toJson() => { - 'id': id, - 'name': name, - 'startTime': startTime.toIso8601String(), - 'endTime': endTime?.toIso8601String(), - 'durationMs': durationMs, - 'tags': tags, - 'status': status.name, - 'spans': spans.map((s) => s.toJson()).toList(), - 'networks': networks.map((n) => n.toJson()).toList(), - 'routes': routes.map((r) => r.toJson()).toList(), - 'errors': errors.map((e) => e.toJson()).toList(), - 'frames': frames.map((f) => f.toJson()).toList(), - 'buildCounts': buildCounts, - }; -} diff --git a/archive/flutterguard_core/lib/src/trace_store.dart b/archive/flutterguard_core/lib/src/trace_store.dart deleted file mode 100644 index 384547c..0000000 --- a/archive/flutterguard_core/lib/src/trace_store.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'trace_model.dart'; - -class TraceStore { - TraceStore._(); - - static final TraceStore instance = TraceStore._(); - - final Map _activeTraces = {}; - final List _completedTraces = []; - int _maxTraces = 100; - - void configure({required int maxTraces}) { - _maxTraces = maxTraces; - } - - FlowTrace beginFlow(FlowTrace trace) { - _activeTraces[trace.id] = trace; - return trace; - } - - FlowTrace? endFlow(String traceId, {required bool failed}) { - final trace = _activeTraces.remove(traceId); - if (trace == null) return null; - trace.endTime = DateTime.now(); - trace.status = failed ? FlowStatus.failed : FlowStatus.success; - _completedTraces.add(trace); - while (_completedTraces.length > _maxTraces) { - _completedTraces.removeAt(0); - } - return trace; - } - - FlowTrace? getActiveTrace(String traceId) { - return _activeTraces[traceId]; - } - - List get completedTraces => List.unmodifiable(_completedTraces); - - void addSpanToActive(String traceId, SpanTrace span) { - final trace = _activeTraces[traceId]; - if (trace != null) { - trace.spans.add(span); - } - } - - void addNetworkToActive(String? traceId, NetworkTrace network) { - if (traceId == null) return; - final trace = _activeTraces[traceId] ?? _getLatestCompleted(traceId); - trace?.networks.add(network); - } - - void addRouteToActive(String? traceId, RouteTrace route) { - if (traceId == null) return; - final trace = _activeTraces[traceId] ?? _getLatestCompleted(traceId); - trace?.routes.add(route); - } - - void addErrorToActive(String? traceId, ErrorTrace error) { - if (traceId == null) return; - final trace = _activeTraces[traceId] ?? _getLatestCompleted(traceId); - trace?.errors.add(error); - if (trace != null && _activeTraces.containsKey(traceId)) { - trace.status = FlowStatus.failed; - } - } - - void addFrameToActive(String? traceId, FrameTrace frame) { - if (traceId == null) return; - final trace = _activeTraces[traceId] ?? _getLatestCompleted(traceId); - trace?.frames.add(frame); - } - - void recordBuild(String? traceId, String boundaryName) { - if (traceId == null) return; - final trace = _activeTraces[traceId] ?? _getLatestCompleted(traceId); - if (trace != null) { - trace.buildCounts[boundaryName] = - (trace.buildCounts[boundaryName] ?? 0) + 1; - } - } - - FlowTrace? _getLatestCompleted(String traceId) { - for (var i = _completedTraces.length - 1; i >= 0; i--) { - if (_completedTraces[i].id == traceId) return _completedTraces[i]; - } - return null; - } - - List getAllTraces() { - return [..._activeTraces.values, ..._completedTraces]; - } - - void reset() { - _activeTraces.clear(); - _completedTraces.clear(); - } -} diff --git a/archive/flutterguard_core/pubspec.yaml b/archive/flutterguard_core/pubspec.yaml deleted file mode 100644 index 6c97825..0000000 --- a/archive/flutterguard_core/pubspec.yaml +++ /dev/null @@ -1,14 +0,0 @@ -name: flutterguard_core -description: Flow-level aspect tracing engine for Flutter - trace models, Zone context, and exporters. -version: 0.1.0 -publish_to: none - -environment: - sdk: ">=3.3.0 <4.0.0" - -dependencies: - meta: ^1.12.0 - -dev_dependencies: - test: ^1.25.8 - lints: ^3.0.0 diff --git a/archive/flutterguard_core/test/flutter_guard_test.dart b/archive/flutterguard_core/test/flutter_guard_test.dart deleted file mode 100644 index 500ad13..0000000 --- a/archive/flutterguard_core/test/flutter_guard_test.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'dart:async'; - -import 'package:flutterguard_core/flutterguard_core.dart'; -import 'package:test/test.dart'; - -void main() { - setUp(() { - FlutterGuard.reset(); - FlutterGuard.configure(const FlutterGuardConfig(maxTraces: 100)); - }); - - tearDown(() { - FlutterGuard.reset(); - }); - - test('action creates flow trace', () async { - final result = await FlutterGuard.action('test_action', () async { - await Future.delayed(const Duration(milliseconds: 10)); - return 'done'; - }); - - expect(result, equals('done')); - final json = FlutterGuard.exportJson(); - expect(json, contains('test_action')); - expect(json, contains('success')); - }); - - test('span attaches to current flow', () async { - await FlutterGuard.action('test_action', () async { - final spanResult = await FlutterGuard.span('inner_span', () async { - await Future.delayed(const Duration(milliseconds: 5)); - return 'span_done'; - }); - expect(spanResult, equals('span_done')); - }); - - final json = FlutterGuard.exportJson(); - expect(json, contains('inner_span')); - }); - - test('zone context survives async futures', () async { - String? capturedId; - - await FlutterGuard.action('test_action', () async { - final id1 = FlutterGuard.currentTraceId; - expect(id1, isNotNull); - - await Future.delayed(const Duration(milliseconds: 5)); - final id2 = FlutterGuard.currentTraceId; - expect(id2, equals(id1)); - - capturedId = id1; - }); - - expect(capturedId, isNotNull); - }); - - test('errors mark flow failed', () async { - bool threw = false; - try { - await FlutterGuard.action('test_action', () async { - throw Exception('test error'); - }); - } catch (_) { - threw = true; - } - expect(threw, isTrue); - - final json = FlutterGuard.exportJson(); - expect(json, contains('failed')); - expect(json, contains('test error')); - }); - - test('json export contains trace', () async { - await FlutterGuard.action('test_action', () async {}); - - final json = FlutterGuard.exportJson(); - expect(json, contains('"version"')); - expect(json, contains('"traces"')); - expect(json, contains('"test_action"')); - }); - - test('markdown export contains sections', () async { - await FlutterGuard.action('test_action', () async {}); - - final md = FlutterGuard.exportMarkdown(); - expect(md, contains('# FlutterGuard Flow Report')); - expect(md, contains('## Summary')); - expect(md, contains('## Runtime Flows')); - expect(md, contains('test_action')); - }); - - test('ring buffer respects max traces', () async { - FlutterGuard.configure(const FlutterGuardConfig(maxTraces: 3)); - - for (var i = 0; i < 5; i++) { - await FlutterGuard.action('action_$i', () async {}); - } - - final json = FlutterGuard.exportJson(); - expect(json, contains('action_2')); - expect(json, contains('action_3')); - expect(json, contains('action_4')); - expect(json, isNot(contains('action_0'))); - expect(json, isNot(contains('action_1'))); - }); -} diff --git a/archive/flutterguard_dio/AGENTS.md b/archive/flutterguard_dio/AGENTS.md deleted file mode 100644 index 2cdb132..0000000 --- a/archive/flutterguard_dio/AGENTS.md +++ /dev/null @@ -1,28 +0,0 @@ -# Package: flutterguard_dio [FROZEN] - -## Role -Dio HTTP interceptor for FlutterGuard flow tracing — records HTTP requests/responses within active flows. - -**Status: FROZEN**. No new features. Bug fixes only. - -## Dependency Map -- depends on: flutterguard_core (path), dio ^5.7.0 -- depended by: nothing - -## Entry Points -- lib barrel: `lib/flutterguard_dio.dart` - -## Key Source Files -| File | Responsibility | -|------|---------------| -| `src/dio_interceptor.dart` | FlutterGuardDioInterceptor: onRequest/onResponse/onError hooks | - -## Pubspec Overrides -melos-managed: flutterguard_core → path: ../flutterguard_core - -## Test -- command: `melos run test:dio` -- test file: `test/dio_interceptor_test.dart` (4 tests) - -## Why Frozen -Only meaningful when core runtime tracing is also active. Static analysis approach (Path A) does not require HTTP interception. diff --git a/archive/flutterguard_dio/lib/flutterguard_dio.dart b/archive/flutterguard_dio/lib/flutterguard_dio.dart deleted file mode 100644 index 194be60..0000000 --- a/archive/flutterguard_dio/lib/flutterguard_dio.dart +++ /dev/null @@ -1,3 +0,0 @@ -library flutterguard_dio; - -export 'src/dio_interceptor.dart'; diff --git a/archive/flutterguard_dio/lib/src/dio_interceptor.dart b/archive/flutterguard_dio/lib/src/dio_interceptor.dart deleted file mode 100644 index 152864e..0000000 --- a/archive/flutterguard_dio/lib/src/dio_interceptor.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutterguard_core/flutterguard_core.dart'; - -class FlutterGuardDioInterceptor extends Interceptor { - final bool sanitizeHeaders; - final bool sanitizeBody; - final List sensitiveKeys; - - FlutterGuardDioInterceptor({ - this.sanitizeHeaders = true, - this.sanitizeBody = true, - List? sensitiveKeys, - }) : sensitiveKeys = sensitiveKeys ?? _defaultSensitiveKeys; - - static const _defaultSensitiveKeys = [ - 'authorization', - 'cookie', - 'set-cookie', - 'token', - 'password', - 'secret', - 'email', - 'phone', - ]; - - @override - void onRequest(RequestOptions options, RequestInterceptorHandler handler) { - options.extra['flutterguard_start_time'] = DateTime.now(); - handler.next(options); - } - - @override - void onResponse(Response response, ResponseInterceptorHandler handler) { - _recordResponse(response); - handler.next(response); - } - - @override - void onError(DioException err, ErrorInterceptorHandler handler) { - _recordError(err); - handler.next(err); - } - - void _recordResponse(Response response) { - final requestOptions = response.requestOptions; - final startTime = requestOptions.extra['flutterguard_start_time']; - if (startTime is! DateTime) return; - - final durationMs = DateTime.now().difference(startTime).inMilliseconds; - final statusCode = response.statusCode; - final success = statusCode != null && statusCode >= 200 && statusCode < 400; - - FlutterGuard.recordNetwork(NetworkTrace( - flowId: FlutterGuard.currentTraceId, - method: requestOptions.method, - path: requestOptions.uri.path, - statusCode: statusCode, - durationMs: durationMs, - requestSize: _estimateSize(requestOptions.data), - responseSize: _estimateSize(response.data), - success: success, - )); - } - - void _recordError(DioException err) { - final requestOptions = err.requestOptions; - final startTime = requestOptions.extra['flutterguard_start_time']; - if (startTime is! DateTime) return; - - final durationMs = DateTime.now().difference(startTime).inMilliseconds; - - FlutterGuard.recordNetwork(NetworkTrace( - flowId: FlutterGuard.currentTraceId, - method: requestOptions.method, - path: requestOptions.uri.path, - durationMs: durationMs, - success: false, - errorType: err.type.name, - errorMessage: err.message, - )); - } - - int? _estimateSize(dynamic data) { - if (data == null) return null; - try { - return data.toString().length; - } catch (_) { - return null; - } - } -} diff --git a/archive/flutterguard_dio/pubspec.yaml b/archive/flutterguard_dio/pubspec.yaml deleted file mode 100644 index 6c9d2e9..0000000 --- a/archive/flutterguard_dio/pubspec.yaml +++ /dev/null @@ -1,16 +0,0 @@ -name: flutterguard_dio -description: Dio interceptor for FlutterGuard flow tracing - records HTTP requests within active flows. -version: 0.1.0 -publish_to: none - -environment: - sdk: ">=3.3.0 <4.0.0" - -dependencies: - dio: ^5.7.0 - flutterguard_core: - path: ../flutterguard_core - -dev_dependencies: - test: ^1.25.8 - lints: ^3.0.0 diff --git a/archive/flutterguard_dio/test/dio_interceptor_test.dart b/archive/flutterguard_dio/test/dio_interceptor_test.dart deleted file mode 100644 index ae7b2d1..0000000 --- a/archive/flutterguard_dio/test/dio_interceptor_test.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutterguard_core/flutterguard_core.dart'; -import 'package:flutterguard_dio/flutterguard_dio.dart'; -import 'package:test/test.dart'; - -void main() { - late Dio dio; - - setUp(() { - FlutterGuard.reset(); - FlutterGuard.configure(const FlutterGuardConfig(maxTraces: 100)); - dio = Dio(BaseOptions(baseUrl: 'https://example.com')); - dio.interceptors.add(FlutterGuardDioInterceptor()); - }); - - tearDown(() { - dio.close(); - FlutterGuard.reset(); - }); - - test('interceptor records success response', () async { - await FlutterGuard.action('network_action', () async { - final request = RequestOptions(path: '/api/items'); - final startTime = - DateTime.now().subtract(const Duration(milliseconds: 50)); - request.extra['flutterguard_start_time'] = startTime; - - final response = Response( - requestOptions: request, - statusCode: 200, - data: {'id': 1}, - ); - - FlutterGuard.recordNetwork(NetworkTrace( - flowId: FlutterGuard.currentTraceId, - method: request.method, - path: request.uri.path, - statusCode: response.statusCode!, - durationMs: 50, - success: true, - )); - }); - - final json = FlutterGuard.exportJson(); - expect(json, contains('200')); - expect(json, contains('/api/items')); - }); - - test('interceptor records error response', () async { - await FlutterGuard.action('error_network_action', () async { - FlutterGuard.recordNetwork(NetworkTrace( - flowId: FlutterGuard.currentTraceId, - method: 'GET', - path: '/api/error', - durationMs: 100, - success: false, - errorType: 'connectionError', - errorMessage: 'Connection refused', - )); - }); - - final json = FlutterGuard.exportJson(); - expect(json, contains('Connection refused')); - expect(json, contains('/api/error')); - }); - - test('interceptor attaches to current flow', () async { - await FlutterGuard.action('network_flow', () async { - FlutterGuard.recordNetwork(NetworkTrace( - flowId: FlutterGuard.currentTraceId, - method: 'POST', - path: '/api/submit', - statusCode: 201, - durationMs: 42, - success: true, - )); - }); - - final json = FlutterGuard.exportJson(); - expect(json, contains('network_flow')); - expect(json, contains('/api/submit')); - expect(json, contains('POST')); - }); - - test('interceptor does not log body by default', () async { - await FlutterGuard.action('sanitize_flow', () async { - FlutterGuard.recordNetwork(NetworkTrace( - flowId: FlutterGuard.currentTraceId, - method: 'POST', - path: '/api/login', - statusCode: 200, - durationMs: 30, - success: true, - requestSize: null, - responseSize: null, - )); - }); - - final json = FlutterGuard.exportJson(); - expect(json, contains('/api/login')); - expect(json, isNot(contains('password'))); - expect(json, isNot(contains('secret'))); - }); -} diff --git a/archive/flutterguard_flutter/AGENTS.md b/archive/flutterguard_flutter/AGENTS.md deleted file mode 100644 index fc2333a..0000000 --- a/archive/flutterguard_flutter/AGENTS.md +++ /dev/null @@ -1,34 +0,0 @@ -# Package: flutterguard_flutter [FROZEN] - -## Role -Flutter runtime integration for FlutterGuard — error hooks, route observer, frame metrics, and GuardBoundary widget rebuild counter. - -**Status: FROZEN**. No new features. Bug fixes only. - -## Dependency Map -- depends on: flutterguard_core (path), flutter SDK (>=3.19.0) -- depended by: nothing - -## Entry Points -- lib barrel: `lib/flutterguard_flutter.dart` - -## Key Source Files -| File | Responsibility | -|------|---------------| -| `src/flutter_guard.dart` | FlutterGuard.run(): wraps core, error hooks, frame timing | -| `src/guard_boundary.dart` | GuardBoundary widget: rebuild count tracking | -| `src/route_observer.dart` | FlutterGuardRouteObserver: Navigator route tracking | - -## Pubspec Overrides -melos-managed: flutterguard_core → path: ../flutterguard_core - -## Test -- command: `flutter test` (not in melos scripts yet) -- test file: `test/flutter_test.dart` (4 tests) - -## Why Frozen -Only meaningful when core runtime tracing is also active. Static analysis approach (Path A) does not require Flutter runtime instrumentation. - -## Notes -- Has Flutter SDK constraint (>=3.19.0) — testing requires Flutter SDK to be installed -- Not included in `melos run test` (which uses `dart test`); run `flutter test` manually diff --git a/archive/flutterguard_flutter/lib/flutterguard_flutter.dart b/archive/flutterguard_flutter/lib/flutterguard_flutter.dart deleted file mode 100644 index d430129..0000000 --- a/archive/flutterguard_flutter/lib/flutterguard_flutter.dart +++ /dev/null @@ -1,7 +0,0 @@ -library flutterguard_flutter; - -export 'package:flutterguard_core/flutterguard_core.dart' hide FlutterGuard; - -export 'src/flutter_guard.dart'; -export 'src/route_observer.dart'; -export 'src/guard_boundary.dart'; diff --git a/archive/flutterguard_flutter/lib/src/flutter_guard.dart b/archive/flutterguard_flutter/lib/src/flutter_guard.dart deleted file mode 100644 index f20bbf4..0000000 --- a/archive/flutterguard_flutter/lib/src/flutter_guard.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:flutterguard_core/flutterguard_core.dart' as core; - -import 'route_observer.dart'; - -class FlutterGuard { - static final FlutterGuardRouteObserver routeObserver = - FlutterGuardRouteObserver(); - - static void run({ - required Widget app, - core.FlutterGuardConfig config = const core.FlutterGuardConfig(), - }) { - core.FlutterGuard.configure(config); - - final previousOnError = FlutterError.onError; - if (config.collectErrors) { - FlutterError.onError = (details) { - core.FlutterGuard.recordError(core.ErrorTrace( - flowId: core.FlutterGuard.currentTraceId, - errorType: details.exception.runtimeType.toString(), - message: details.exception.toString(), - stackTrace: details.stack?.toString(), - time: DateTime.now(), - )); - previousOnError?.call(details); - }; - } - - if (config.collectFrames) { - SchedulerBinding.instance.addPostFrameCallback((_) { - _recordFrameStats(config); - }); - } - - runZonedGuarded( - () { - WidgetsFlutterBinding.ensureInitialized(); - runApp(app); - }, - (error, stack) { - if (config.collectErrors) { - core.FlutterGuard.recordError(core.ErrorTrace( - flowId: core.FlutterGuard.currentTraceId, - errorType: error.runtimeType.toString(), - message: error.toString(), - stackTrace: stack.toString(), - time: DateTime.now(), - )); - } - }, - ); - } - - static void _recordFrameStats(core.FlutterGuardConfig config) { - final binding = SchedulerBinding.instance; - try { - binding.addTimingsCallback((timings) { - for (final timing in timings) { - final totalSpan = timing.totalSpan.inMicroseconds ~/ 1000; - final buildDuration = timing.buildDuration.inMicroseconds ~/ 1000; - final rasterDuration = timing.rasterDuration.inMicroseconds ~/ 1000; - final janky = totalSpan > config.jankFrameMs; - - core.FlutterGuard.recordFrame(core.FrameTrace( - flowId: core.FlutterGuard.currentTraceId, - totalSpanMs: totalSpan, - buildDurationMs: buildDuration, - rasterDurationMs: rasterDuration, - janky: janky, - time: DateTime.now(), - )); - } - }); - } on NoSuchMethodError { - // addTimingsCallback not available on this Flutter version - } - } - - static String? get currentTraceId => core.FlutterGuard.currentTraceId; - - static Future action( - String name, - FutureOr Function() body, { - Map tags = const {}, - }) => - core.FlutterGuard.action(name, body, tags: tags); - - static Future span( - String name, - FutureOr Function() body, { - Map tags = const {}, - }) => - core.FlutterGuard.span(name, body, tags: tags); - - static void recordNetwork(core.NetworkTrace trace) => - core.FlutterGuard.recordNetwork(trace); - - static void recordRoute(core.RouteTrace trace) => - core.FlutterGuard.recordRoute(trace); - - static void recordError(core.ErrorTrace trace) => - core.FlutterGuard.recordError(trace); - - static void recordFrame(core.FrameTrace trace) => - core.FlutterGuard.recordFrame(trace); - - static void recordBuild(String boundaryName) => - core.FlutterGuard.recordBuild(boundaryName); - - static String exportJson() => core.FlutterGuard.exportJson(); - - static String exportMarkdown() => core.FlutterGuard.exportMarkdown(); - - static void reset() => core.FlutterGuard.reset(); - - static void configure(core.FlutterGuardConfig config) => - core.FlutterGuard.configure(config); -} diff --git a/archive/flutterguard_flutter/lib/src/guard_boundary.dart b/archive/flutterguard_flutter/lib/src/guard_boundary.dart deleted file mode 100644 index 5a70c86..0000000 --- a/archive/flutterguard_flutter/lib/src/guard_boundary.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutterguard_core/flutterguard_core.dart'; - -class GuardBoundary extends StatelessWidget { - final String name; - final Widget child; - - const GuardBoundary({ - super.key, - required this.name, - required this.child, - }); - - @override - Widget build(BuildContext context) { - FlutterGuard.recordBuild(name); - return child; - } -} diff --git a/archive/flutterguard_flutter/lib/src/route_observer.dart b/archive/flutterguard_flutter/lib/src/route_observer.dart deleted file mode 100644 index d091707..0000000 --- a/archive/flutterguard_flutter/lib/src/route_observer.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutterguard_core/flutterguard_core.dart'; - -class FlutterGuardRouteObserver extends NavigatorObserver { - @override - void didPush(Route route, Route? previousRoute) { - super.didPush(route, previousRoute); - FlutterGuard.recordRoute(RouteTrace( - flowId: FlutterGuard.currentTraceId, - type: 'push', - from: previousRoute != null ? _routeName(previousRoute) : null, - to: _routeName(route), - time: DateTime.now(), - )); - } - - @override - void didPop(Route route, Route? previousRoute) { - super.didPop(route, previousRoute); - FlutterGuard.recordRoute(RouteTrace( - flowId: FlutterGuard.currentTraceId, - type: 'pop', - from: _routeName(route), - to: previousRoute != null ? _routeName(previousRoute) : null, - time: DateTime.now(), - )); - } - - @override - void didReplace({Route? newRoute, Route? oldRoute}) { - super.didReplace(newRoute: newRoute, oldRoute: oldRoute); - FlutterGuard.recordRoute(RouteTrace( - flowId: FlutterGuard.currentTraceId, - type: 'replace', - from: oldRoute != null ? _routeName(oldRoute) : null, - to: newRoute != null ? _routeName(newRoute) : null, - time: DateTime.now(), - )); - } - - @override - void didRemove(Route route, Route? previousRoute) { - super.didRemove(route, previousRoute); - FlutterGuard.recordRoute(RouteTrace( - flowId: FlutterGuard.currentTraceId, - type: 'remove', - from: _routeName(route), - to: previousRoute != null ? _routeName(previousRoute) : null, - time: DateTime.now(), - )); - } - - String _routeName(Route route) { - return route.settings.name ?? route.runtimeType.toString(); - } -} diff --git a/archive/flutterguard_flutter/pubspec.yaml b/archive/flutterguard_flutter/pubspec.yaml deleted file mode 100644 index f8893b4..0000000 --- a/archive/flutterguard_flutter/pubspec.yaml +++ /dev/null @@ -1,19 +0,0 @@ -name: flutterguard_flutter -description: Flutter runtime integration for FlutterGuard - error hooks, route observer, frame metrics, and GuardBoundary. -version: 0.1.0 -publish_to: none - -environment: - sdk: ">=3.3.0 <4.0.0" - flutter: ">=3.19.0" - -dependencies: - flutter: - sdk: flutter - flutterguard_core: - path: ../flutterguard_core - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^3.0.2 diff --git a/archive/flutterguard_flutter/test/flutter_test.dart b/archive/flutterguard_flutter/test/flutter_test.dart deleted file mode 100644 index 1d40a3d..0000000 --- a/archive/flutterguard_flutter/test/flutter_test.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutterguard_flutter/flutterguard_flutter.dart'; - -void main() { - setUp(() { - FlutterGuard.reset(); - FlutterGuard.configure(const FlutterGuardConfig(maxTraces: 100)); - }); - - tearDown(() { - FlutterGuard.reset(); - }); - - testWidgets('GuardBoundary records build count', (tester) async { - await FlutterGuard.action('test_boundary', () async { - await tester.pumpWidget( - const MaterialApp( - home: Scaffold( - body: GuardBoundary( - name: 'TestPage', - child: Text('Hello'), - ), - ), - ), - ); - - await tester.pumpWidget( - const MaterialApp( - home: Scaffold( - body: GuardBoundary( - name: 'TestPage', - child: Text('Hello Again'), - ), - ), - ), - ); - }); - - final json = FlutterGuard.exportJson(); - expect(json, contains('TestPage')); - }); - - testWidgets('route observer records push and pop', (tester) async { - final observer = FlutterGuard.routeObserver; - - await tester.pumpWidget( - MaterialApp( - navigatorObservers: [observer], - home: const Scaffold(body: Text('Home')), - ), - ); - - await FlutterGuard.action('route_flow', () async { - await tester.tap(find.text('Home')); - }); - }); - - test('error hook preserves previous handler', () async { - FlutterError.onError = (details) {}; - - try { - await FlutterGuard.action('error_flow', () async { - throw Exception('guarded error'); - }); - } catch (_) {} - - expect(true, isTrue); - }); - - test('frame aspect can attach to active flow', () { - FlutterGuard.recordFrame(FrameTrace( - flowId: 'test_frame_flow', - totalSpanMs: 32, - buildDurationMs: 20, - rasterDurationMs: 12, - janky: true, - time: DateTime.now(), - )); - - final json = FlutterGuard.exportJson(); - expect(json, contains('test_frame_flow')); - }); -} diff --git a/archive/usage_demo/README.md b/archive/usage_demo/README.md deleted file mode 100644 index 7a5661b..0000000 --- a/archive/usage_demo/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Usage Demo - -Demonstrates the core `FlutterGuard` tracing API without Flutter dependencies. - -```bash -# From the repo root -melos bootstrap - -# Run the demo -dart run examples/usage_demo/bin/trace_demo.dart -``` diff --git a/archive/usage_demo/analysis_options.yaml b/archive/usage_demo/analysis_options.yaml deleted file mode 100644 index 4d8bd09..0000000 --- a/archive/usage_demo/analysis_options.yaml +++ /dev/null @@ -1,3 +0,0 @@ -analyzer: - errors: - avoid_print: ignore diff --git a/archive/usage_demo/bin/trace_demo.dart b/archive/usage_demo/bin/trace_demo.dart deleted file mode 100644 index 01788da..0000000 --- a/archive/usage_demo/bin/trace_demo.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutterguard_core/flutterguard_core.dart'; - -Future main() async { - FlutterGuard.configure(const FlutterGuardConfig(enabled: true)); - - print('=== FlutterGuard Tracing Demo ===\n'); - - await FlutterGuard.action('fetch_user_data', () async { - FlutterGuard.span('validate_token', () async { - await Future.delayed(const Duration(milliseconds: 30)); - }); - - FlutterGuard.span('query_database', () async { - await Future.delayed(const Duration(milliseconds: 120)); - }); - - FlutterGuard.recordNetwork(NetworkTrace( - method: 'GET', - path: '/api/users/42', - statusCode: 200, - durationMs: 85, - success: true, - )); - - FlutterGuard.span('format_response', () async { - await Future.delayed(const Duration(milliseconds: 15)); - }); - }); - - await FlutterGuard.action('process_payment', () async { - FlutterGuard.recordNetwork(NetworkTrace( - method: 'POST', - path: '/api/payments', - statusCode: 201, - durationMs: 320, - success: true, - )); - }, tags: {'amount': '29.99', 'currency': 'USD'}); - - try { - await FlutterGuard.action('failing_operation', () async { - await FlutterGuard.span('risky_call', () async { - await Future.delayed(const Duration(milliseconds: 10)); - throw Exception('Service unavailable'); - }); - }); - } catch (e) { - // Error is captured in the trace; demos don't crash. - } - - print(FlutterGuard.exportMarkdown()); -} diff --git a/archive/usage_demo/pubspec.yaml b/archive/usage_demo/pubspec.yaml deleted file mode 100644 index 17a5b41..0000000 --- a/archive/usage_demo/pubspec.yaml +++ /dev/null @@ -1,10 +0,0 @@ -name: usage_demo -description: Demonstrates the FlutterGuard tracing API. -publish_to: none - -environment: - sdk: ">=3.3.0 <4.0.0" - -dependencies: - flutterguard_core: - path: ../../packages/flutterguard_core diff --git a/bin/AGENTS.md b/bin/AGENTS.md new file mode 100644 index 0000000..8e7587e --- /dev/null +++ b/bin/AGENTS.md @@ -0,0 +1,7 @@ +# Executable layer + +`flutterguard.dart` may parse global arguments, route the four command families, +print usage/version, and map format errors to exit code 2. + +Keep business logic in `lib/src/cli` or the scan kernel. Do not add rule wiring, +configuration defaults, report construction, or a second versioned API here. diff --git a/bin/flutterguard.dart b/bin/flutterguard.dart new file mode 100644 index 0000000..678c719 --- /dev/null +++ b/bin/flutterguard.dart @@ -0,0 +1,111 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:flutterguard_cli/src/cli/baseline_commands.dart'; +import 'package:flutterguard_cli/src/cli/cli_parsers.dart'; +import 'package:flutterguard_cli/src/cli/config_commands.dart'; +import 'package:flutterguard_cli/src/cli/rule_commands.dart'; +import 'package:flutterguard_cli/src/cli/scan_command.dart'; + +const _version = '0.7.0'; + +void main(List args) { + final parsers = CliParsers(); + try { + final result = parsers.root.parse(args); + if (result['version'] == true) { + stdout.writeln('flutterguard $_version'); + return; + } + if (args.isEmpty || result['help'] == true || result.command == null) { + _usage(parsers.root); + return; + } + + final command = result.command!; + switch (command.name) { + case 'scan': + if (_help(command, parsers.scan, 'flutterguard scan [path]')) return; + ScanCommand.run(command, configPath: _explicitConfigPath(command)); + return; + case 'baseline': + final leaf = command.command; + if (command['help'] == true || leaf == null) { + _subcommandUsage( + 'flutterguard baseline create [path]', + parsers.baseline, + ); + return; + } + if (_help( + leaf, + parsers.baselineCreate, + 'flutterguard baseline create [path]', + )) { + return; + } + BaselineCommands.create(leaf, configPath: _explicitConfigPath(leaf)); + return; + case 'config': + final leaf = command.command; + if (command['help'] == true || leaf == null) { + _subcommandUsage( + 'flutterguard config [path]', + parsers.config, + ); + return; + } + if (leaf.name == 'init') { + if (_help( + leaf, + parsers.configInit, + 'flutterguard config init [path]', + )) { + return; + } + ConfigCommands.init(leaf); + return; + } + if (_help( + leaf, + parsers.configCheck, + 'flutterguard config check [path]', + )) { + return; + } + ConfigCommands.check(leaf, configPath: _explicitConfigPath(leaf)); + return; + case 'rules': + if (_help(command, parsers.rules, 'flutterguard rules [rule-id]')) { + return; + } + RuleCommands.run(command); + return; + } + } on FormatException catch (error) { + stderr.writeln('Error: ${error.message}'); + exitCode = 2; + } +} + +bool _help(ArgResults args, ArgParser parser, String usage) { + if (args['help'] != true) return false; + _subcommandUsage(usage, parser); + return true; +} + +String? _explicitConfigPath(ArgResults args) => + args.wasParsed('config') ? args['config'] as String : null; + +void _usage(ArgParser parser) { + stdout.writeln('FlutterGuard — IoT Flutter static analysis CLI'); + stdout.writeln('Usage: flutterguard [options]'); + stdout.writeln(); + stdout.writeln('Commands: scan, baseline, config, rules'); + stdout.writeln(parser.usage); +} + +void _subcommandUsage(String usage, ArgParser parser) { + stdout.writeln('Usage: $usage'); + stdout.writeln(parser.usage); +} diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md new file mode 100644 index 0000000..ec48e9f --- /dev/null +++ b/doc/ARCHITECTURE.md @@ -0,0 +1,109 @@ +# FlutterGuard Architecture + +## Shape + +FlutterGuard is one publishable Dart package and one executable. The repository +root is the package root. + +```text +bin/flutterguard.dart parse and route four command families +lib/src/cli/ command parsers and I/O adapters +lib/src/scanner.dart scan orchestration +lib/src/config_loader.dart YAML to compact generic rule settings +lib/src/source_workspace.dart per-scan source/AST cache and diagnostics +lib/src/import_graph.dart project-local resolved import graph +lib/src/boundary_engine.dart shared dependency-boundary evaluator +lib/src/rules/registry.dart only rule metadata/default/execution registry +lib/src/rules/rule.dart RuleDefinition and RuleRegistration +lib/src/rules/*.dart detectors +lib/src/report_generator.dart table and JSON output +lib/src/sarif_report.dart SARIF output +test/ external contract and detector tests +example/ CI scan target +``` + +There is no workspace orchestrator, runtime instrumentation package, dynamic +plugin loader, generated registry, or public Dart scanner API. + +## Scan flow + +```text +CLI + └─ FlutterGuardScanner.scan + ├─ ProjectResolver + ├─ ScanConfig.fromFile + ├─ FileCollector + ├─ ScanContext + │ ├─ SourceWorkspace + │ └─ changed/all/target file sets + ├─ RuleRegistry.analyze + │ ├─ one shared ImportGraph when needed + │ └─ explicit RuleRegistration executors + ├─ SuppressionFilter + ├─ optional Baseline + └─ table / JSON / SARIF report +``` + +`ScanContext` is the only scanner-to-rule scope carrier. `SourceWorkspace` +reads and parses each source file at most once. Architecture checks share one +`ImportGraph`; layer and module rules share `BoundaryRule` and +`DependencyBoundaryEngine`. + +## Rule model + +`RuleRegistry.registrations` is the single source of truth. Every registration +contains: + +- one `RuleDefinition`: ID, domain, default severity, documentation, options; +- one executor receiving `ScanContext`, effective `RuleConfig`, and an optional + shared import graph. + +Configuration defaults and `flutterguard rules` output are derived from these +definitions. Do not add another catalog, metadata registry, reflection layer, +or barrel export. + +All rules receive common `enabled` and `severity` settings. Special settings +belong in `RuleDefinition.defaultOptions`; the current example is `requireTls`. + +## Detection ownership + +- `lifecycle_resource_not_disposed` owns resource close/cancel/dispose checks. +- `ble_scanning` owns scan timeout findings, not generic disconnect pairing. +- `iot_security` owns credentials and insecure transport findings. +- Dependency version and broker placement policy stay with ecosystem tools and + application configuration rather than FlutterGuard rules. +- `BoundaryRule` owns both layer and module dependency policy. +- Framework-specific state rules activate from source imports; there is no + global framework or confidence switch. + +This ownership prevents the same source pattern from being reported by several +rule families. + +## External boundaries + +The supported integration boundary is the CLI plus JSON/SARIF. Code under +`lib/src` is package-private and may change without a Dart API migration. + +Stable capabilities are: + +- positional project path and explicit `--config`; +- changed-only scanning using a verified Git base; +- inline suppression and baseline filtering; +- severity-based CI exit codes; +- JSON schema versioning and SARIF 2.1.0. + +Scoring, priority, confidence, compatibility aliases, profile presets, install +diagnostics, issue export, and baseline management subcommands are intentionally +outside the architecture. + +## Change rules + +When adding a detector: + +1. implement or extend one detector file; +2. add one `RuleRegistration` with a `RuleDefinition`; +3. add positive, negative, disabled, and output-contract tests where relevant; +4. update the external spec only when the CLI/config/report contract changes. + +Before delivery run `dart analyze`, `dart test`, a demo scan, and +`dart pub publish --dry-run` for release-affecting changes. diff --git a/doc/EVOLUTION_ROADMAP.md b/doc/EVOLUTION_ROADMAP.md new file mode 100644 index 0000000..33ed26c --- /dev/null +++ b/doc/EVOLUTION_ROADMAP.md @@ -0,0 +1,215 @@ +# FlutterGuard 演进路线图 + +本文档描述 FlutterGuard v0.7 之后的架构演进方向,按优先级分为四个阶段。 +当前收敛重构的进度跟踪请参考仓库根目录下的 `EVOLUTION_PLAN.md`。 + +--- + +## 架构速览 + +``` +bin/flutterguard.dart 唯一入口,路由四个命令族 +lib/src/scanner.dart 扫描编配 +lib/src/scan_context.dart 不可变 scan 作用域载体 +lib/src/source_workspace.dart 单次读取/解析 AST 缓存 +lib/src/import_graph.dart 共享项目导入图 +lib/src/boundary_engine.dart layer/module 共享边界引擎 +lib/src/config_loader.dart YAML → 泛型 RuleConfig +lib/src/report_generator.dart 表格/JSON 输出 +lib/src/sarif_report.dart SARIF 2.1.0 输出 +lib/src/rules/registry.dart 规则元数据/默认值/执行的唯一数据源 +lib/src/rules/*.dart 16 个检测器实现 +``` + +--- + +## 第一阶段:收尾当前收敛重构(短期,已计划) ✅ 已完成 (2026-07-17) + +参考 `EVOLUTION_PLAN.md` 中定义的 3 批提交: + +### 1.1 根包与内核收敛 + +``` +refactor: flatten and simplify the flutterguard CLI +``` + +- 提交根 `pubspec.yaml`、`analysis_options.yaml`、`.gitignore`、`.pubignore` +- 删除 `melos.yaml`,删除旧 `packages/flutterguard_cli/**` +- 新增 `bin/**`、`lib/**`、`test/**`、`example/**` + +### 1.2 CI 与发布脚本收敛 + +``` +ci: align workflows with the root Dart package +``` + +- 对齐 `.github/workflows/flutterguard.yml`、`release.yml` +- 更新 `scripts/package_release.sh`、`scripts/package_release.ps1` +- 删除废弃编译/扫描脚本 + +### 1.3 文档与检查点 + +``` +docs: document the simplified v0.7 architecture +``` + +- 定稿 `README.md`、`CHANGELOG.md`、`AGENTS.md` +- 定稿 `doc/ARCHITECTURE.md`、`doc/FLUTTERGUARD_SPEC.md` +- 提交本文件 `doc/EVOLUTION_ROADMAP.md` + +--- + +## 第二阶段:检测质量提升(短期,已计划) + +### 2.1 OverlayEntry 生命周期检测 ✅ 已完成 (2026-07-21) + +- ~~检测 `OverlayEntry.remove()` 和 `OverlayEntry.dispose()` 的调用遗漏~~ (已添加 `OverlayEntry` → `remove` 到 `lifecycle_resource.dart` 的 `_resourceTypes`) +- ~~归属到 `lifecycle_resource.dart`~~ + +### 2.2 AST 化字符串检测 ✅ 已完成 (2026-07-21) + +**问题**:`iot_security.dart` 和 `ble_scanning.dart` 当前使用正则和字符串匹配: + +| 文件 | 方法 | 脆弱点 | +|---|---|---| +| `iot_security.dart` | `_secretPattern` 正则 | 误匹配注释/字符串常量中的模式 | +| `iot_security.dart` | `_cleartextMqttPatterns` 正则 | URL 格式变更需同步 | +| `ble_scanning.dart` | `body.contains('timeout')` | 方法名重命名后漏检 | + +**改进**:升级为 `analyzer` 库的 AST 节点遍历,通过类型信息和解析引用语义进行检测: + +1. 密钥检测 → 遍历 `VariableDeclaration` / `AssignmentExpression`,检查赋值右侧是否为明文密钥模式,排除注释和文档字符串 +2. 明文 MQTT/HTTP 检测 → 解析 URI 字符串常量的 scheme,利用 `Uri.parse` +3. BLE timeout 检测 → 遍历 `MethodInvocation`,查找 `FlutterBluePlus.startScan` 或 `ScanMode` 相关调用及其 `timeout` 命名参数 + +### 2.3 新规则质量门槛 ✅ 已完成 (2026-07-21) + +每条规则已覆盖五种测试场景: + +| 场景 | 含义 | 覆盖情况 | +|---|---|---| +| positive | 预期触发发现的代码,确认检测生效 | `iot_security_issue.dart`, `ble_scanning_issue.dart`, `lifecycle_issue.dart` | +| negative | 不会触发的合法代码,确认无误报 | `iot_security_negative.dart`, `ble_scanning_negative.dart`, `lifecycle_negative.dart` | +| disabled | `enabled: false` 后不产出发现 | rules_test.dart 中 `_disabled` 测试 | +| suppression | 行内注释 `// flutterguard:ignore` 可抑制 | `iot_security_suppression.dart`, `ble_scanning_suppression.dart` | +| report-contract | 验证 JSON 输出中 `ruleId`/`severity`/`domain`/`message` 正确 | `iot_security` report contract test | + +--- + +## 第三阶段:架构内聚深化(中期) + +### 3.1 大文件拆分 + +当前三个文件包含多条规则,与 `ble_scanning.dart` / `iot_security.dart` 等"一文件一规则"的惯例不一致: + +| 当前文件 | 行数 | 应拆分为 | +|---|---|---| +| `generic_state_management.dart` | 449 | `side_effect_in_build.dart`、`state_manager_created_in_build.dart`、`mutable_state_exposed.dart`、`state_layer_ui_dependency.dart` | +| `provider_state_management.dart` | 326 | `provider_value_lifecycle_misuse.dart`、`notify_listeners_in_loop.dart` | +| `riverpod_state_management.dart` | 276 | `riverpod_read_used_for_render.dart`、`riverpod_watch_in_callback.dart` | + +拆分后共享逻辑保留在 `state_management_utils.dart` 中。 + +### 3.2 消除工具函数重复 + +| 重复项 | 位置 | 解决方案 | +|---|---|---| +| `sourceLine` vs `lineNumberForOffset` | `state_management_utils.dart:97` / `source_workspace.dart:10` | 统一使用 `SourceWorkspace` 的 `lineNumberForOffset`,移除 `state_management_utils.dart` 中的版本 | +| `_FirstOrNull` 扩展 | `provider_state_management.dart` | Dart 3.x 已内置 `firstOrNull`,直接移除自定义扩展 | +| BLE 资源类型列表 | `lifecycle_resource.dart`、`ble_scanning.dart`、`generic_state_management.dart` | 抽取共享类型常量到 `flutter_resource_types.dart` 或统一通过 AST 类型判断 | + +### 3.3 `notify_listeners_in_loop` 的 `_provablyShortFor` AST 化 + +当前使用正则判断 for 循环是否"显然很短": + +```dart +RegExp(r'= 0; [A-Za-z_$][\w$]* < 1;') +``` + +对格式化差异和变量命名敏感。应改为: + +- 获取 `ForStatement` 的 `forLoopParts`,解析初始化表达式和条件表达式 +- 若初始化将循环变量设为常量 0,且条件为 `变量 < 1`,则判定为短循环 +- 排除循环体内包含 `await` 或嵌套循环的情况 + +### 3.4 共享类型注册表 + +建立 `lib/src/rules/flutter_resource_types.dart`,集中声明需要追踪生命周期的 Flutter 资源类型: + +```dart +// 示例结构 +const _disposableTypes = { + 'StreamSubscription', 'Timer', 'AnimationController', + 'TextEditingController', 'ScrollController', + 'BluetoothDevice', 'BluetoothCharacteristic', + // ... +}; +``` + +供 `lifecycle_resource.dart`、`ble_scanning.dart`、`generic_state_management.dart` 统一引用。 + +--- + +## 第四阶段:能力扩展(中长期) + +### 4.1 IoT 专项规则扩展 + +| 规则方向 | 检测内容 | 复杂度 | +|---|---|---| +| Wi-Fi 配置安全 | 检测代码中硬编码的 Wi-Fi SSID/密码,建议使用设备 provisioning API | 中 | +| 固件 OTA 校验 | 检测 `http.get(url)` 下载固件但未验证签名/哈希的模式 | 高 | +| MQTT QoS 检测 | 检测 QoS 0 发布关键消息(命令/配置)的场景 | 中 | +| 蓝牙配对模式 | 检测 `createBond` / `JustWorks` 配对且无用户确认 | 中 | + +### 4.2 状态管理规则扩展 + +| 规则方向 | 检测内容 | 复杂度 | +|---|---|---| +| Cubit 泄漏 | 检测 `BlocProvider` 创建但未在 dispose 中 `close()` 的 Cubit | 中 | +| GetX 滥用 | 检测 `Get.to` 在 build 中调用、`Get.find` 在 initState 外使用 | 中 | +| MobX reaction 泄漏 | 检测未 dispose 的 `ReactionDisposer` | 中 | +| ValueNotifier dispose | 检测 `ValueNotifier` / `ChangeNotifier` 未 dispose | 低 | + +### 4.3 测试体系增强 + +- 核心算法补充单元测试: + - `TarjanSccFinder._stronglyConnectedComponents`(`state_dependency_cycle.dart`) + - `_reconstructCycle` BFS 最短环路径重建 + - `_shortestCycle` 全节点对最短环 + - `ImportGraph.build` 多文件解析图构建 +- 添加规则级别的 golden-file 测试,验证 JSON/SARIF 报告输出结构 + +### 4.4 跨文件数据流分析 + +当前所有规则均为单文件分析。对 `iot_security` 规则引入有限的跨文件追踪: + +- 追踪 `static const` 密钥常量从一个文件被导出/导入到使用点的路径 +- 检测通过 `export` 间接暴露的密钥 +- 限制追踪深度为 1 层导入(通过 `ImportGraph` 即可实现,无需完整的数据流引擎) + +--- + +## 架构约束(不可逾越的红线) + +以下设计决策在 v0.7 收敛中已确认,后续演进不得逆转: + +| 约束 | 说明 | +|---|---| +| 不恢复 Melos | 仓库根目录就是唯一的 Dart 包 | +| 不恢复 runtime SDK | 没有运行时 instrumentation、APM、云服务 | +| 不暴露公共 API | `lib/src` 是私有实现,集成边界仅 CLI + JSON/SARIF | +| 不添加冗余元数据 | 无 score/priority/confidence/兼容别名 | +| 不添加通用风格规则 | 无 generic-size、missing-const、格式化类规则 | +| 单一注册点 | `RuleRegistry.registrations` 是唯一的规则注册源 | +| 资源复用 | `SourceWorkspace` 和 `ImportGraph` 扫描生命周期内各构建一次 | +| 每个 finding 有唯一 owner | 同一代码模式不会被多个规则重复报告 | +| 框架自动检测 | 框架规则通过 import 检测激活,无全局配置开关 | + +--- + +## 相关文档 + +- 仓库根 `EVOLUTION_PLAN.md` — 当前收敛重构的续跑检查点和提交拆分 +- `doc/ARCHITECTURE.md` — 内部架构边界和 scan 流程 +- `doc/FLUTTERGUARD_SPEC.md` — 外部契约(CLI / JSON / SARIF) +- 根 `AGENTS.md` — 仓库级约束和命令入口 diff --git a/doc/FLUTTERGUARD_SPEC.md b/doc/FLUTTERGUARD_SPEC.md new file mode 100644 index 0000000..e575da4 --- /dev/null +++ b/doc/FLUTTERGUARD_SPEC.md @@ -0,0 +1,218 @@ +# FlutterGuard External Contract + +Version: 0.7 / JSON schema 2.0.0 + +This document defines supported user-facing behavior. Internal classes and file +layout are documented in `ARCHITECTURE.md` and are not compatibility contracts. + +## Identity and scope + +FlutterGuard is a local static analysis CLI for IoT and smart-home Flutter +projects. It detects architecture boundary violations, lifecycle/resource +risks, IoT transport/security problems, and state-management maintainability +problems. + +It is not a runtime SDK, observability agent, crash reporter, hosted service, +network proxy, widget library, or general formatting/style linter. + +## Commands + +```text +flutterguard scan [path] [options] +flutterguard baseline create [path] [options] +flutterguard config init [path] [options] +flutterguard config check [path] [options] +flutterguard rules [rule-id] [--format table|json] +``` + +Global options are `--help` and `--version`. + +### Scan options + +| Option | Contract | +|---|---| +| `--config`, `-c` | Explicit config; missing explicit files are errors | +| `--format`, `-f` | `table`, `json`, or `sarif`; default `table` | +| `--output`, `-o` | Report directory; default `.flutterguard` | +| `--verbose`, `-v` | Print diagnostics, detail, and evidence | +| `--no-color` | Disable ANSI colors | +| `--fail-on` | `none`, `high`, `medium`, or `low` | +| `--changed-only` | Restrict source rules to Git-changed Dart files | +| `--base` | Verified Git base ref; default `main` | +| `--baseline` | Hide findings present in a baseline file | + +### Exit codes + +| Code | Meaning | +|---|---| +| `0` | Command succeeded and the severity gate passed | +| `1` | Severity gate or config check failed | +| `2` | Invalid arguments, project, config, baseline, or Git base | + +## Project and files + +Project discovery walks upward from the requested path and recognizes +`flutterguard.yaml`, `pubspec.yaml`, or `lib/`. Default source selection is: + +```yaml +include: [lib/**] +exclude: + - lib/generated/** + - lib/**.g.dart + - lib/**.freezed.dart + - lib/**.mocks.dart +``` + +An ordinary full scan with zero matched Dart files is an error. A valid +changed-only scan with zero changed Dart files succeeds and writes an empty +requested report. + +## Configuration + +Top-level keys are `include`, `exclude`, `rules`, and `architecture`. + +```yaml +rules: + : + enabled: true + severity: high + : + +architecture: + detect_cycles: false + layers: [] + modules: [] +``` + +Rule severity is `low`, `medium`, or `high`. Unknown rule IDs and unknown +rule-specific options are errors in `config check`. Run `config init` to +generate current definitions and defaults. + +A boundary entry is: + +```yaml +- name: presentation + path: lib/presentation/** + allowed_deps: [domain, core] +``` + +Layer and module enforcement is inactive when its boundary list is empty. +Circular import detection requires `architecture.detect_cycles: true` and is +skipped in changed-only mode. + +## Rule inventory + +The registry contains 16 IDs: + +```text +ble_scanning +bloc_equatable_props_incomplete +circular_dependency +iot_security +layer_violation +lifecycle_resource_not_disposed +module_violation +mutable_state_exposed +notify_listeners_in_loop +provider_value_lifecycle_misuse +riverpod_read_used_for_render +riverpod_watch_in_callback +side_effect_in_build +state_dependency_cycle +state_layer_ui_dependency +state_manager_created_in_build +``` + +`rules [rule-id]` is the authoritative description of purpose, default +severity, framework, options, example, and remediation. + +## Findings + +The internal finding model exposes one canonical JSON representation: + +```json +{ + "ruleId": "iot_security", + "title": "IoT 安全风险", + "file": "/project/lib/device.dart", + "line": 10, + "severity": "high", + "domain": "architecture", + "message": "...", + "detail": "...", + "suggestion": "...", + "metadata": {}, + "framework": "generic", + "evidence": [] +} +``` + +There are no duplicate `id`/`ruleId` or `level`/`severity` fields, and no +priority, score, or confidence fields. + +## JSON report + +JSON reports use schema version `2.0.0`: + +```json +{ + "schemaVersion": "2.0.0", + "projectPath": "/project", + "scanMode": "full", + "summary": { + "total": 1, + "high": 1, + "medium": 0, + "low": 0, + "suppressed": 0, + "suppressedByBaseline": 0, + "diagnostics": 0, + "byDomain": {} + }, + "issues": [], + "diagnostics": [] +} +``` + +SARIF reports conform to SARIF 2.1.0 and include registry definitions and +finding locations. + +## Suppression and baseline + +Supported comments are same-line and immediately preceding-line forms: + +```dart +// flutterguard: ignore iot_security +final endpoint = localEndpoint; +``` + +Multiple IDs may be comma-separated; `ignore all` suppresses all findings at +the target line. + +`baseline create` stores stable fingerprints derived from rule ID, normalized +path, line, and message. A scan with `--baseline` hides matching findings after +inline suppression. + +## Changed-only behavior + +Changed-only mode verifies `^{commit}`, collects tracked changes and +untracked files, and filters them to configured Dart sources. Unchanged project +files remain available for import and state-graph resolution. Project-level +pubspec checks run only when `pubspec.yaml` changed. State dependency cycles +use the full graph and report cycles touching a changed source. + +## Release gates + +A release is valid only when all of the following pass from the repository +root: + +```bash +dart pub get +dart analyze +dart test +dart run bin/flutterguard.dart scan example --format json --no-color +dart pub publish --dry-run +``` + +Native release artifacts are produced by `scripts/package_release.sh` and +`scripts/package_release.ps1` on their respective operating systems. diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..fda363e --- /dev/null +++ b/example/README.md @@ -0,0 +1,11 @@ +# Scan example + +This small Dart target is scanned by CI with the current checkout. + +```bash +dart run bin/flutterguard.dart scan example --no-color +dart run bin/flutterguard.dart scan example --format json --output .flutterguard/demo +``` + +Its configuration demonstrates the IoT lifecycle/security options without +depending on Flutter or the FlutterGuard package. diff --git a/example/flutterguard.yaml b/example/flutterguard.yaml new file mode 100644 index 0000000..4a498fd --- /dev/null +++ b/example/flutterguard.yaml @@ -0,0 +1,19 @@ +include: + - lib/** + +rules: + lifecycle_resource_not_disposed: + enabled: true + severity: medium + ble_scanning: + enabled: true + severity: medium + iot_security: + enabled: true + severity: high + requireTls: true + +architecture: + layers: [] + modules: [] + detect_cycles: false diff --git a/examples/scan_demo/lib/main.dart b/example/lib/main.dart similarity index 100% rename from examples/scan_demo/lib/main.dart rename to example/lib/main.dart diff --git a/examples/scan_demo/lib/services/user_service.dart b/example/lib/services/user_service.dart similarity index 100% rename from examples/scan_demo/lib/services/user_service.dart rename to example/lib/services/user_service.dart diff --git a/examples/scan_demo/lib/widgets/profile_card.dart b/example/lib/widgets/profile_card.dart similarity index 100% rename from examples/scan_demo/lib/widgets/profile_card.dart rename to example/lib/widgets/profile_card.dart diff --git a/examples/scan_demo/pubspec.yaml b/example/pubspec.yaml similarity index 100% rename from examples/scan_demo/pubspec.yaml rename to example/pubspec.yaml diff --git a/examples/scan_demo/README.md b/examples/scan_demo/README.md deleted file mode 100644 index d386cec..0000000 --- a/examples/scan_demo/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Scan Demo - -A deliberately imperfect project to demonstrate `flutterguard scan`. - -```bash -# From the repo root, run scan with low thresholds to trigger issues -./flutterguard scan -p examples/scan_demo - -# With verbose output -./flutterguard scan -p examples/scan_demo --verbose - -# JSON output -./flutterguard scan -p examples/scan_demo --format json -``` - -The `flutterguard.yaml` in this directory uses low thresholds (maxLines: 80/30/20) to demonstrate issue detection even in small projects. diff --git a/examples/scan_demo/flutterguard.yaml b/examples/scan_demo/flutterguard.yaml deleted file mode 100644 index 0242a36..0000000 --- a/examples/scan_demo/flutterguard.yaml +++ /dev/null @@ -1,13 +0,0 @@ -include: - - lib/** - -rules: - large_file: - enabled: true - maxLines: 80 - large_class: - enabled: true - maxLines: 30 - large_build_method: - enabled: true - maxLines: 20 diff --git a/flutterguard.yaml b/flutterguard.yaml index 0aa610b..8fc2034 100644 --- a/flutterguard.yaml +++ b/flutterguard.yaml @@ -7,40 +7,7 @@ exclude: - lib/**.freezed.dart - lib/**.mocks.dart -rules: - large_file: - enabled: true - maxLines: 500 - large_class: - enabled: true - maxLines: 300 - large_build_method: - enabled: true - maxLines: 80 - lifecycle_resource: - enabled: true - missing_const_constructor: - enabled: true - architecture: - # layers: - # - name: presentation - # path: lib/presentation/** - # allowed_deps: [domain, core] - # - name: domain - # path: lib/domain/** - # allowed_deps: [core] - # - name: data - # path: lib/data/** - # allowed_deps: [domain, core] - # - name: core - # path: lib/core/** - # allowed_deps: [] - # modules: - # - name: device_mqtt - # path: lib/device/mqtt/** - # allowed_deps: [domain, core] - # - name: device_ble - # path: lib/device/ble/** - # allowed_deps: [domain, core] - # detect_cycles: true + layers: [] + modules: [] + detect_cycles: false diff --git a/lib/AGENTS.md b/lib/AGENTS.md new file mode 100644 index 0000000..387ed75 --- /dev/null +++ b/lib/AGENTS.md @@ -0,0 +1,7 @@ +# Library boundary + +`flutterguard_cli.dart` intentionally exports no scanner API. FlutterGuard is +an executable package; integrations use the CLI plus JSON or SARIF. + +All implementation belongs under `src/`. Do not export internal rule, +configuration, AST, or report types from the package barrel. diff --git a/lib/flutterguard_cli.dart b/lib/flutterguard_cli.dart new file mode 100644 index 0000000..1b53eb7 --- /dev/null +++ b/lib/flutterguard_cli.dart @@ -0,0 +1,5 @@ +/// FlutterGuard is distributed as the `flutterguard` executable. +/// +/// The scanner implementation under `lib/src` is intentionally not a public +/// Dart API. Integrations should invoke the CLI and consume JSON or SARIF. +library; diff --git a/lib/src/AGENTS.md b/lib/src/AGENTS.md new file mode 100644 index 0000000..943ea3f --- /dev/null +++ b/lib/src/AGENTS.md @@ -0,0 +1,18 @@ +# Scan kernel + +This directory owns reusable implementation behind the executable. + +- `scanner.dart`: orchestration, suppression, baseline, report writes. +- `scan_context.dart`: immutable scan scope. +- `source_workspace.dart`: one-read/one-parse cache and diagnostics. +- `import_graph.dart`: resolved project import graph. +- `boundary_engine.dart`: boundary evaluation shared by layer/module rules. +- `config_loader.dart`: compact YAML parser and generic `RuleConfig`. +- `config_tools.dart`: generated starter config and config validation. +- `report_generator.dart` / `sarif_report.dart`: output adapters. +- `cli/`: argument parser and command I/O only. +- `rules/`: definitions, registry, and detectors. + +Do not bypass `ScanContext`, parse files independently inside scanner wiring, +duplicate config defaults, or create another registry. File/path behavior must +remain cross-platform and changed-only imports must resolve against all files. diff --git a/packages/flutterguard_cli/lib/src/baseline.dart b/lib/src/baseline.dart similarity index 55% rename from packages/flutterguard_cli/lib/src/baseline.dart rename to lib/src/baseline.dart index 4bc8823..0fd8ec6 100644 --- a/packages/flutterguard_cli/lib/src/baseline.dart +++ b/lib/src/baseline.dart @@ -13,8 +13,8 @@ class Baseline { static String fingerprint(StaticIssue issue, String projectPath) { final relativePath = p.isWithin(projectPath, issue.file) || p.equals(projectPath, issue.file) - ? p.relative(issue.file, from: projectPath) - : issue.file; + ? p.relative(issue.file, from: projectPath) + : issue.file; final normalizedPath = relativePath.replaceAll('\\', '/'); final source = [ issue.id, @@ -53,67 +53,13 @@ class Baseline { return Baseline(fingerprints.map((v) => v.toString()).toSet()); } - static Map stats(String path) { - final baseline = load(path); - return { - 'path': path, - 'fingerprints': baseline.fingerprints.length, - }; - } - - static List newFingerprints({ - required String projectPath, - required Baseline baseline, - required List issues, - }) { - final current = issues - .map((issue) => fingerprint(issue, projectPath)) - .where((fingerprint) => !baseline.fingerprints.contains(fingerprint)) - .toSet() - .toList() - ..sort(); - return current; - } - - static String encodeFingerprints({ - required String projectPath, - required List fingerprints, - }) { - final sorted = fingerprints.toSet().toList()..sort(); - final payload = { - 'version': '1.0.0', - 'generatedAt': DateTime.now().toIso8601String(), - 'projectPath': projectPath, - 'issueCount': sorted.length, - 'fingerprints': sorted, - }; - return const JsonEncoder.withIndent(' ').convert(payload); - } - - static String prune({ - required String projectPath, - required Baseline baseline, - required List issues, - }) { - final current = issues - .map((issue) => fingerprint(issue, projectPath)) - .where(baseline.fingerprints.contains) - .toList(); - return encodeFingerprints( - projectPath: projectPath, - fingerprints: current, - ); - } - static String encode({ required String projectPath, required List issues, }) { - final fingerprints = issues - .map((issue) => fingerprint(issue, projectPath)) - .toSet() - .toList() - ..sort(); + final fingerprints = + issues.map((issue) => fingerprint(issue, projectPath)).toSet().toList() + ..sort(); final payload = { 'version': '1.0.0', diff --git a/packages/flutterguard_cli/lib/src/boundary_engine.dart b/lib/src/boundary_engine.dart similarity index 84% rename from packages/flutterguard_cli/lib/src/boundary_engine.dart rename to lib/src/boundary_engine.dart index 0737481..979aaea 100644 --- a/packages/flutterguard_cli/lib/src/boundary_engine.dart +++ b/lib/src/boundary_engine.dart @@ -49,11 +49,13 @@ class DependencyBoundaryEngine { break; } } on Object catch (error) { - workspace.addDiagnostic(ScanDiagnostic( - stage: 'boundary_glob', - file: file, - message: 'Invalid boundary glob "${boundary.path}": $error', - )); + workspace.addDiagnostic( + ScanDiagnostic( + stage: 'boundary_glob', + file: file, + message: 'Invalid boundary glob "${boundary.path}": $error', + ), + ); } } } @@ -66,11 +68,9 @@ class DependencyBoundaryEngine { final target = fileToBoundary[edge.target]; if (target == null || target.name == source.name) continue; if (!source.allowedDeps.contains(target.name)) { - violations.add(BoundaryViolation( - edge: edge, - source: source, - target: target, - )); + violations.add( + BoundaryViolation(edge: edge, source: source, target: target), + ); } } } diff --git a/lib/src/cli/baseline_commands.dart b/lib/src/cli/baseline_commands.dart new file mode 100644 index 0000000..5dc4dbd --- /dev/null +++ b/lib/src/cli/baseline_commands.dart @@ -0,0 +1,44 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:path/path.dart' as p; + +import '../baseline.dart'; +import '../scanner.dart'; + +class BaselineCommands { + static void create(ArgResults args, {String? configPath}) { + final projectPath = args.rest.isNotEmpty ? args.rest.first : '.'; + final output = args['output'] as String; + try { + final result = FlutterGuardScanner.scan( + projectPath: projectPath, + configPath: configPath, + applySuppression: false, + ); + final outputPath = p.isAbsolute(output) + ? output + : p.join(result.projectPath, output); + Directory(p.dirname(outputPath)).createSync(recursive: true); + File(outputPath).writeAsStringSync( + Baseline.encode( + projectPath: result.projectPath, + issues: result.rawIssues, + ), + ); + stdout.writeln( + 'Created FlutterGuard baseline: $outputPath ' + '(${result.rawIssues.length} issues)', + ); + } on ScanException catch (error) { + _fail(error.message); + } on FormatException catch (error) { + _fail(error.message); + } + } + + static Never _fail(String message) { + stderr.writeln('Error: $message'); + exit(2); + } +} diff --git a/lib/src/cli/cli_parsers.dart b/lib/src/cli/cli_parsers.dart new file mode 100644 index 0000000..d438d91 --- /dev/null +++ b/lib/src/cli/cli_parsers.dart @@ -0,0 +1,77 @@ +import 'package:args/args.dart'; + +class CliParsers { + late final ArgParser scan; + late final ArgParser baselineCreate; + late final ArgParser baseline; + late final ArgParser configInit; + late final ArgParser configCheck; + late final ArgParser config; + late final ArgParser rules; + late final ArgParser root; + + CliParsers() { + scan = ArgParser() + ..addOption('config', abbr: 'c', help: 'Explicit config file') + ..addOption( + 'format', + abbr: 'f', + defaultsTo: 'table', + allowed: ['table', 'json', 'sarif'], + ) + ..addOption('output', abbr: 'o', defaultsTo: '.flutterguard') + ..addFlag('verbose', abbr: 'v', negatable: false) + ..addFlag('no-color', negatable: false) + ..addFlag('changed-only', negatable: false) + ..addOption('base', defaultsTo: 'main') + ..addOption( + 'fail-on', + defaultsTo: 'none', + allowed: ['none', 'high', 'medium', 'low'], + ) + ..addOption('baseline') + ..addFlag('help', abbr: 'h', negatable: false); + + baselineCreate = ArgParser() + ..addOption('config', abbr: 'c') + ..addOption( + 'output', + abbr: 'o', + defaultsTo: '.flutterguard/baseline.json', + ) + ..addFlag('help', abbr: 'h', negatable: false); + baseline = ArgParser() + ..addCommand('create', baselineCreate) + ..addFlag('help', abbr: 'h', negatable: false); + + configInit = ArgParser() + ..addOption('config', abbr: 'c', defaultsTo: 'flutterguard.yaml') + ..addFlag('with-architecture', negatable: false) + ..addFlag('force', negatable: false) + ..addFlag('help', abbr: 'h', negatable: false); + configCheck = ArgParser() + ..addOption('config', abbr: 'c') + ..addFlag('help', abbr: 'h', negatable: false); + config = ArgParser() + ..addCommand('init', configInit) + ..addCommand('check', configCheck) + ..addFlag('help', abbr: 'h', negatable: false); + + rules = ArgParser() + ..addOption( + 'format', + abbr: 'f', + defaultsTo: 'table', + allowed: ['table', 'json'], + ) + ..addFlag('help', abbr: 'h', negatable: false); + + root = ArgParser() + ..addCommand('scan', scan) + ..addCommand('baseline', baseline) + ..addCommand('config', config) + ..addCommand('rules', rules) + ..addFlag('help', abbr: 'h', negatable: false) + ..addFlag('version', abbr: 'V', negatable: false); + } +} diff --git a/lib/src/cli/config_commands.dart b/lib/src/cli/config_commands.dart new file mode 100644 index 0000000..496b88d --- /dev/null +++ b/lib/src/cli/config_commands.dart @@ -0,0 +1,44 @@ +import 'dart:io'; + +import 'package:args/args.dart'; + +import '../config_tools.dart'; + +class ConfigCommands { + static void init(ArgResults args) { + try { + final projectPath = args.rest.isNotEmpty ? args.rest.first : '.'; + final outputPath = ConfigTools.writeInitConfig( + projectPath: projectPath, + configPath: args['config'] as String, + withArchitecture: args['with-architecture'] as bool, + force: args['force'] as bool, + ); + stdout.writeln('Created FlutterGuard config: $outputPath'); + stdout.writeln('Next: flutterguard config check $projectPath'); + } on StateError catch (error) { + _fail(error.message); + } + } + + static void check(ArgResults args, {String? configPath}) { + try { + final projectPath = args.rest.isNotEmpty ? args.rest.first : '.'; + final result = ConfigTools.doctor( + projectPath: projectPath, + configPath: configPath, + ); + stdout.write(ConfigTools.formatDoctorResult(result)); + if (result.hasErrors) exit(1); + } on StateError catch (error) { + _fail(error.message); + } on FormatException catch (error) { + _fail(error.message); + } + } + + static Never _fail(String message) { + stderr.writeln('Error: $message'); + exit(2); + } +} diff --git a/lib/src/cli/rule_commands.dart b/lib/src/cli/rule_commands.dart new file mode 100644 index 0000000..fcb41c3 --- /dev/null +++ b/lib/src/cli/rule_commands.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/args.dart'; + +import '../rules/registry.dart'; +import '../rules/rule.dart'; + +class RuleCommands { + static void run(ArgResults args) { + if (args.rest.isNotEmpty) { + _describe(args.rest.first, json: args['format'] == 'json'); + return; + } + final rules = RuleRegistry.all()..sort((a, b) => a.id.compareTo(b.id)); + if (args['format'] == 'json') { + stdout.writeln( + const JsonEncoder.withIndent( + ' ', + ).convert(rules.map((rule) => rule.toJson()).toList()), + ); + return; + } + stdout.writeln('Available rules (${rules.length}):'); + for (final rule in rules) { + stdout.writeln( + ' ${rule.id.padRight(36)} ' + '${rule.domain.name.padRight(14)} ' + '${rule.defaultSeverity.name.padRight(8)} ${rule.name}', + ); + } + stdout.writeln(); + stdout.writeln('Run flutterguard rules for details.'); + } + + static void _describe(String id, {required bool json}) { + final rule = RuleRegistry.find(id); + if (rule == null) { + stderr.writeln('Error: unknown rule "$id".'); + exit(2); + } + if (json) { + stdout.writeln(const JsonEncoder.withIndent(' ').convert(rule.toJson())); + return; + } + _writeDefinition(rule); + } + + static void _writeDefinition(RuleDefinition rule) { + stdout.writeln('Rule: ${rule.id}'); + stdout.writeln('Name: ${rule.name}'); + stdout.writeln('Domain: ${rule.domain.name}'); + stdout.writeln('Severity: ${rule.defaultSeverity.name}'); + stdout.writeln('Framework: ${rule.framework}'); + stdout.writeln(); + stdout.writeln('Purpose: ${rule.purpose}'); + stdout.writeln('Risk: ${rule.riskReason}'); + stdout.writeln('Example: ${rule.badExample}'); + stdout.writeln('Fix: ${rule.fixSuggestion}'); + stdout.writeln('Config: ${rule.configKeys.join(', ')}'); + } +} diff --git a/packages/flutterguard_cli/lib/src/cli/scan_command.dart b/lib/src/cli/scan_command.dart similarity index 80% rename from packages/flutterguard_cli/lib/src/cli/scan_command.dart rename to lib/src/cli/scan_command.dart index 620e792..9efcb83 100644 --- a/packages/flutterguard_cli/lib/src/cli/scan_command.dart +++ b/lib/src/cli/scan_command.dart @@ -11,12 +11,12 @@ class ScanCommand { final verbose = args['verbose'] as bool; final noColor = args['no-color'] as bool; final failOn = args['fail-on'] as String; - final minScore = _parseMinScore(args['min-score'] as String?); + final projectPath = args.rest.isNotEmpty ? args.rest.first : '.'; late final ScanResult result; try { result = FlutterGuardScanner.scan( - projectPath: args['path'] as String, + projectPath: projectPath, configPath: configPath, outputDir: args['output'] as String, writeJson: format == 'json', @@ -77,23 +77,5 @@ class ScanCommand { ); exit(1); } - - if (minScore != null && result.score < minScore) { - stderr.writeln( - 'CI gate failed: Score ${result.score} is below minimum $minScore.', - ); - exit(1); - } - } - - static int? _parseMinScore(String? value) { - if (value == null) return null; - final score = int.tryParse(value); - if (score == null || score < 0 || score > 100) { - throw const FormatException( - 'Expected --min-score to be an integer 0-100.', - ); - } - return score; } } diff --git a/lib/src/config_loader.dart b/lib/src/config_loader.dart new file mode 100644 index 0000000..55e6fb5 --- /dev/null +++ b/lib/src/config_loader.dart @@ -0,0 +1,229 @@ +// ignore_for_file: avoid_dynamic_calls + +import 'dart:io'; + +import 'package:yaml/yaml.dart'; + +import 'static_issue.dart'; + +typedef BoundaryConfig = ({String name, String path, List allowedDeps}); + +typedef ArchitectureConfig = ({ + List layers, + List modules, + bool detectCycles, +}); + +class RuleConfig { + final bool enabled; + final RiskLevel severity; + final Map options; + + const RuleConfig({ + required this.enabled, + required this.severity, + this.options = const {}, + }); + + bool boolOption(String key) { + final value = options[key]; + if (value is bool) return value; + throw FormatException('rules option "$key" must be a boolean.'); + } +} + +class ScanConfig { + final List include; + final List exclude; + final Map< + String, + ({bool enabled, RiskLevel? severity, Map options}) + > + _rules; + final ArchitectureConfig architecture; + + const ScanConfig({ + required this.include, + required this.exclude, + required Map< + String, + ({bool enabled, RiskLevel? severity, Map options}) + > + rules, + required this.architecture, + }) : _rules = rules; + + Set get configuredRuleIds => _rules.keys.toSet(); + + Map configuredOptions(String id) => + Map.unmodifiable(_rules[id]?.options ?? const {}); + + RuleConfig rule( + String id, { + required RiskLevel defaultSeverity, + Map defaultOptions = const {}, + }) { + final configured = _rules[id]; + return RuleConfig( + enabled: configured?.enabled ?? true, + severity: configured?.severity ?? defaultSeverity, + options: {...defaultOptions, ...?configured?.options}, + ); + } + + static const _knownTopLevelKeys = { + 'include', + 'exclude', + 'rules', + 'architecture', + }; + static const _knownArchitectureKeys = {'layers', 'modules', 'detect_cycles'}; + static const _knownBoundaryKeys = {'name', 'path', 'allowed_deps'}; + + factory ScanConfig.fromFile(String path, {bool requireFile = false}) { + final file = File(path); + if (!file.existsSync()) { + if (requireFile) { + throw FormatException('Config file "$path" does not exist.'); + } + return ScanConfig.defaults(); + } + + final parsed = loadYaml(file.readAsStringSync()); + if (parsed == null) return ScanConfig.defaults(); + if (parsed is! YamlMap) { + throw FormatException('Config file "$path" must contain a YAML map.'); + } + + _warnUnknownKeys(parsed, _knownTopLevelKeys, 'config'); + final architecture = _optionalMap(parsed['architecture'], 'architecture'); + _warnUnknownKeys(architecture, _knownArchitectureKeys, 'architecture'); + + return ScanConfig( + include: _stringList(parsed['include']) ?? const ['lib/**'], + exclude: + _stringList(parsed['exclude']) ?? + const [ + 'lib/generated/**', + 'lib/**.g.dart', + 'lib/**.freezed.dart', + 'lib/**.mocks.dart', + ], + rules: _parseRules(_optionalMap(parsed['rules'], 'rules')), + architecture: ( + layers: _parseBoundaries(architecture['layers'], 'layers'), + modules: _parseBoundaries(architecture['modules'], 'modules'), + detectCycles: _boolValue(architecture, 'detect_cycles', false), + ), + ); + } + + factory ScanConfig.defaults() => const ScanConfig( + include: ['lib/**'], + exclude: [ + 'lib/generated/**', + 'lib/**.g.dart', + 'lib/**.freezed.dart', + 'lib/**.mocks.dart', + ], + rules: {}, + architecture: (layers: [], modules: [], detectCycles: false), + ); + + static Map< + String, + ({bool enabled, RiskLevel? severity, Map options}) + > + _parseRules(YamlMap rules) { + final result = + < + String, + ({bool enabled, RiskLevel? severity, Map options}) + >{}; + for (final entry in rules.entries) { + final id = entry.key.toString(); + final map = _optionalMap(entry.value, 'rules.$id'); + final options = {}; + for (final option in map.entries) { + final key = option.key.toString(); + if (key == 'enabled' || key == 'severity') continue; + final value = option.value; + if (value is bool || value is int || value is String) { + options[key] = value; + } else { + throw FormatException('rules.$id.$key must be a scalar value.'); + } + } + result[id] = ( + enabled: _boolValue(map, 'enabled', true), + severity: _optionalRiskLevel(map, 'severity'), + options: options, + ); + } + return result; + } + + static List _parseBoundaries(Object? value, String path) { + if (value == null) return const []; + if (value is! YamlList) { + throw FormatException('architecture.$path must be a list.'); + } + return [ + for (final item in value) _parseBoundary(item, 'architecture.$path'), + ]; + } + + static BoundaryConfig _parseBoundary(Object? value, String path) { + if (value is! YamlMap) { + throw FormatException('Each $path entry must be a map.'); + } + _warnUnknownKeys(value, _knownBoundaryKeys, path); + return ( + name: _requiredString(value, 'name', path), + path: _requiredString(value, 'path', path), + allowedDeps: _stringList(value['allowed_deps']) ?? const [], + ); + } + + static YamlMap _optionalMap(Object? value, String path) { + if (value == null) return YamlMap(); + if (value is YamlMap) return value; + throw FormatException('$path must be a map.'); + } + + static List? _stringList(Object? value) { + if (value == null) return null; + if (value is! YamlList) throw const FormatException('Expected a list.'); + return value.map((item) => item.toString()).toList(); + } + + static bool _boolValue(YamlMap map, String key, bool fallback) { + final value = map[key]; + if (value == null) return fallback; + if (value is bool) return value; + throw FormatException('$key must be a boolean.'); + } + + static RiskLevel? _optionalRiskLevel(YamlMap map, String key) { + final value = map[key]; + if (value == null) return null; + for (final severity in RiskLevel.values) { + if (severity.name == value) return severity; + } + throw FormatException('$key must be one of: low, medium, high.'); + } + + static String _requiredString(YamlMap map, String key, String path) { + final value = map[key]; + if (value is String && value.trim().isNotEmpty) return value; + throw FormatException('$path.$key must be a non-empty string.'); + } + + static void _warnUnknownKeys(YamlMap map, Set known, String context) { + for (final key in map.keys.map((key) => key.toString())) { + if (!known.contains(key)) { + stderr.writeln('Warning: unknown $context key "$key".'); + } + } + } +} diff --git a/lib/src/config_tools.dart b/lib/src/config_tools.dart new file mode 100644 index 0000000..5b24221 --- /dev/null +++ b/lib/src/config_tools.dart @@ -0,0 +1,258 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'config_loader.dart'; +import 'file_collector.dart'; +import 'path_utils.dart'; +import 'project_resolver.dart'; +import 'rules/registry.dart'; + +enum DoctorSeverity { info, warning, error } + +class DoctorMessage { + final DoctorSeverity severity; + final String message; + + const DoctorMessage(this.severity, this.message); +} + +class DoctorResult { + final String projectPath; + final String configPath; + final bool configExists; + final int fileCount; + final List messages; + + const DoctorResult({ + required this.projectPath, + required this.configPath, + required this.configExists, + required this.fileCount, + required this.messages, + }); + + bool get hasErrors => + messages.any((message) => message.severity == DoctorSeverity.error); +} + +class ConfigTools { + static String initTemplate({required bool withArchitecture}) { + final buffer = StringBuffer() + ..writeln('include:') + ..writeln(' - lib/**') + ..writeln() + ..writeln('exclude:') + ..writeln(' - lib/generated/**') + ..writeln(' - lib/**.g.dart') + ..writeln(' - lib/**.freezed.dart') + ..writeln(' - lib/**.mocks.dart') + ..writeln() + ..writeln('rules:'); + for (final rule in RuleRegistry.all()) { + buffer + ..writeln(' ${rule.id}:') + ..writeln(' enabled: true') + ..writeln(' severity: ${rule.defaultSeverity.name}'); + for (final option in rule.defaultOptions.entries) { + buffer.writeln(' ${option.key}: ${option.value}'); + } + } + + buffer + ..writeln() + ..writeln('architecture:') + ..writeln(' detect_cycles: false'); + if (!withArchitecture) { + buffer + ..writeln(' layers: []') + ..writeln(' modules: []'); + return buffer.toString(); + } + + buffer + ..writeln(' layers:') + ..writeln(' - name: presentation') + ..writeln(' path: lib/presentation/**') + ..writeln(' allowed_deps: [domain, core]') + ..writeln(' - name: domain') + ..writeln(' path: lib/domain/**') + ..writeln(' allowed_deps: [core]') + ..writeln(' - name: core') + ..writeln(' path: lib/core/**') + ..writeln(' allowed_deps: []') + ..writeln(' modules: []'); + return buffer.toString(); + } + + static String writeInitConfig({ + required String projectPath, + required String configPath, + required bool withArchitecture, + required bool force, + }) { + final resolvedProjectPath = ProjectResolver.resolveProjectPath(projectPath); + final outputPath = p.isAbsolute(configPath) + ? configPath + : p.join(resolvedProjectPath, configPath); + final file = File(outputPath); + if (file.existsSync() && !force) { + throw StateError( + 'Config file already exists at "$outputPath". Use --force to overwrite.', + ); + } + file.parent.createSync(recursive: true); + file.writeAsStringSync(initTemplate(withArchitecture: withArchitecture)); + return outputPath; + } + + static String resolveConfigPathForProject({ + required String projectPath, + String? configPath, + }) => ProjectResolver.resolveConfigPath( + projectPath: projectPath, + explicitConfig: configPath, + ); + + static DoctorResult doctor({ + required String projectPath, + String? configPath, + }) { + final resolvedProjectPath = ProjectResolver.resolveProjectPath(projectPath); + if (!Directory(resolvedProjectPath).existsSync()) { + throw StateError('Project path "$resolvedProjectPath" does not exist.'); + } + final resolvedConfigPath = resolveConfigPathForProject( + projectPath: resolvedProjectPath, + configPath: configPath, + ); + final exists = File(resolvedConfigPath).existsSync(); + final config = ScanConfig.fromFile( + resolvedConfigPath, + requireFile: configPath != null, + ); + final files = FileCollector.collect(resolvedProjectPath, config); + final messages = []; + + if (!exists) { + messages.add( + const DoctorMessage( + DoctorSeverity.info, + 'No config file found. Built-in defaults are being used.', + ), + ); + } + if (files.isEmpty) { + messages.add( + const DoctorMessage( + DoctorSeverity.error, + 'No Dart files matched include/exclude patterns.', + ), + ); + } + + final definitions = {for (final rule in RuleRegistry.all()) rule.id: rule}; + final knownRules = definitions.keys.toSet(); + for (final id in config.configuredRuleIds.difference(knownRules)) { + messages.add(DoctorMessage(DoctorSeverity.error, 'Unknown rule "$id".')); + } + for (final id in config.configuredRuleIds.intersection(knownRules)) { + final knownOptions = definitions[id]!.defaultOptions.keys.toSet(); + for (final option + in config + .configuredOptions(id) + .keys + .toSet() + .difference(knownOptions)) { + messages.add( + DoctorMessage( + DoctorSeverity.error, + 'Unknown option "rules.$id.$option".', + ), + ); + } + } + _checkBoundaries( + kind: 'layer', + boundaries: config.architecture.layers, + files: files, + projectPath: resolvedProjectPath, + messages: messages, + ); + _checkBoundaries( + kind: 'module', + boundaries: config.architecture.modules, + files: files, + projectPath: resolvedProjectPath, + messages: messages, + ); + + return DoctorResult( + projectPath: resolvedProjectPath, + configPath: resolvedConfigPath, + configExists: exists, + fileCount: files.length, + messages: messages, + ); + } + + static String formatDoctorResult(DoctorResult result) { + final buffer = StringBuffer() + ..writeln('FlutterGuard config check') + ..writeln('Project: ${result.projectPath}') + ..writeln('Config: ${result.configPath}') + ..writeln('Config exists: ${result.configExists}') + ..writeln('Matched Dart files: ${result.fileCount}'); + if (result.messages.isEmpty) { + buffer.writeln('Status: OK'); + return buffer.toString(); + } + buffer.writeln(); + for (final message in result.messages) { + buffer.writeln( + '${message.severity.name.toUpperCase()}: ${message.message}', + ); + } + return buffer.toString(); + } + + static void _checkBoundaries({ + required String kind, + required List boundaries, + required List files, + required String projectPath, + required List messages, + }) { + final names = boundaries.map((boundary) => boundary.name).toSet(); + if (names.length != boundaries.length) { + messages.add( + DoctorMessage( + DoctorSeverity.error, + 'Duplicate architecture $kind names found.', + ), + ); + } + for (final boundary in boundaries) { + final unknown = boundary.allowedDeps.where((dep) => !names.contains(dep)); + for (final dependency in unknown) { + messages.add( + DoctorMessage( + DoctorSeverity.error, + 'Architecture $kind "${boundary.name}" allows unknown dependency "$dependency".', + ), + ); + } + final matches = files.any( + (file) => matchesProjectGlob(file, boundary.path, projectPath), + ); + if (!matches) { + messages.add( + DoctorMessage( + DoctorSeverity.warning, + 'Architecture $kind "${boundary.name}" path "${boundary.path}" matched no files.', + ), + ); + } + } + } +} diff --git a/packages/flutterguard_cli/lib/src/file_collector.dart b/lib/src/file_collector.dart similarity index 78% rename from packages/flutterguard_cli/lib/src/file_collector.dart rename to lib/src/file_collector.dart index bbf439a..fb849b8 100644 --- a/packages/flutterguard_cli/lib/src/file_collector.dart +++ b/lib/src/file_collector.dart @@ -54,8 +54,9 @@ class FileCollector { final gitRoot = (rootResult.stdout as String).trim(); final canonicalGitRoot = Directory(gitRoot).resolveSymbolicLinksSync(); - final canonicalProjectPath = - Directory(projectPath).resolveSymbolicLinksSync(); + final canonicalProjectPath = Directory( + projectPath, + ).resolveSymbolicLinksSync(); if (base.startsWith('-')) { throw ChangedFilesException('Invalid Git base "$base".'); } @@ -67,26 +68,22 @@ class FileCollector { ); if (refResult.exitCode != 0) { throw ChangedFilesException( - _gitFailureMessage('Invalid Git base "$base"', refResult.stderr), + _gitFailureMessage( + 'Invalid Git base "$base"', + refResult.stderr.toString(), + ), ); } final verifiedRef = (refResult.stdout as String).trim(); final result = Process.runSync( 'git', - [ - 'diff', - '--name-only', - '--diff-filter=ACMR', - '-z', - verifiedRef, - '--', - ], + ['diff', '--name-only', '--diff-filter=ACMR', '-z', verifiedRef, '--'], workingDirectory: gitRoot, stdoutEncoding: utf8, ); if (result.exitCode != 0) { throw ChangedFilesException( - _gitFailureMessage('git diff', result.stderr), + _gitFailureMessage('git diff', result.stderr.toString()), ); } final untracked = Process.runSync( @@ -97,29 +94,33 @@ class FileCollector { ); if (untracked.exitCode != 0) { throw ChangedFilesException( - _gitFailureMessage('git ls-files', untracked.stderr), + _gitFailureMessage('git ls-files', untracked.stderr.toString()), ); } final changed = {}; for (final path in (result.stdout as String).split('\x00')) { if (path.isNotEmpty) { - changed.add(_projectAnchoredGitPath( - projectPath: projectPath, - canonicalProjectPath: canonicalProjectPath, - canonicalGitRoot: canonicalGitRoot, - gitRelativePath: path, - )); + changed.add( + _projectAnchoredGitPath( + projectPath: projectPath, + canonicalProjectPath: canonicalProjectPath, + canonicalGitRoot: canonicalGitRoot, + gitRelativePath: path, + ), + ); } } for (final path in (untracked.stdout as String).split('\x00')) { if (path.isNotEmpty) { - changed.add(_projectAnchoredGitPath( - projectPath: projectPath, - canonicalProjectPath: canonicalProjectPath, - canonicalGitRoot: canonicalGitRoot, - gitRelativePath: path, - )); + changed.add( + _projectAnchoredGitPath( + projectPath: projectPath, + canonicalProjectPath: canonicalProjectPath, + canonicalGitRoot: canonicalGitRoot, + gitRelativePath: path, + ), + ); } } return changed; diff --git a/packages/flutterguard_cli/lib/src/import_graph.dart b/lib/src/import_graph.dart similarity index 88% rename from packages/flutterguard_cli/lib/src/import_graph.dart rename to lib/src/import_graph.dart index 640a374..855cd2b 100644 --- a/packages/flutterguard_cli/lib/src/import_graph.dart +++ b/lib/src/import_graph.dart @@ -2,7 +2,6 @@ import 'package:analyzer/dart/ast/ast.dart'; import 'import_utils.dart'; import 'path_utils.dart'; -import 'source_utils.dart'; import 'source_workspace.dart'; class ImportEdge { @@ -53,12 +52,14 @@ class ImportGraph { projectPath: projectPath, ); if (target == null || target == sourcePath) continue; - edges.add(ImportEdge( - source: sourcePath, - target: target, - uri: uri, - line: lineNumberForOffset(source.lineInfo, directive.uri.offset), - )); + edges.add( + ImportEdge( + source: sourcePath, + target: target, + uri: uri, + line: lineNumberForOffset(source.lineInfo, directive.uri.offset), + ), + ); } outgoing[sourcePath] = edges; } diff --git a/packages/flutterguard_cli/lib/src/import_utils.dart b/lib/src/import_utils.dart similarity index 95% rename from packages/flutterguard_cli/lib/src/import_utils.dart rename to lib/src/import_utils.dart index d0cf539..999fba9 100644 --- a/packages/flutterguard_cli/lib/src/import_utils.dart +++ b/lib/src/import_utils.dart @@ -16,8 +16,10 @@ String? resolveImport( }; if (importStr.startsWith('package:')) { - final packageRelative = - importStr.replaceFirst(RegExp(r'^package:[^/]+/'), ''); + final packageRelative = importStr.replaceFirst( + RegExp(r'^package:[^/]+/'), + '', + ); return _resolvePackageImport( packageRelative, normalizedFiles, diff --git a/packages/flutterguard_cli/lib/src/path_utils.dart b/lib/src/path_utils.dart similarity index 86% rename from packages/flutterguard_cli/lib/src/path_utils.dart rename to lib/src/path_utils.dart index 473ffc6..622ddb9 100644 --- a/packages/flutterguard_cli/lib/src/path_utils.dart +++ b/lib/src/path_utils.dart @@ -7,11 +7,7 @@ p.Context projectPathContext(String projectPath, {p.Context? context}) { return p.Context(style: context.style, current: absoluteRoot); } -String normalizePath( - String path, { - p.Context? context, - String? basePath, -}) { +String normalizePath(String path, {p.Context? context, String? basePath}) { context ??= p.context; if (basePath != null) { context = projectPathContext(basePath, context: context); @@ -29,8 +25,10 @@ bool matchesProjectGlob( final projectContext = projectPathContext(projectPath, context: context); final normalizedFile = normalizePath(filePath, context: projectContext); final normalizedPattern = pattern.replaceAll('\\', '/'); - return Glob(normalizedPattern, context: projectContext) - .matches(normalizedFile); + return Glob( + normalizedPattern, + context: projectContext, + ).matches(normalizedFile); } String projectRelativePath( diff --git a/packages/flutterguard_cli/lib/src/project_resolver.dart b/lib/src/project_resolver.dart similarity index 94% rename from packages/flutterguard_cli/lib/src/project_resolver.dart rename to lib/src/project_resolver.dart index 3b0388c..29c7954 100644 --- a/packages/flutterguard_cli/lib/src/project_resolver.dart +++ b/lib/src/project_resolver.dart @@ -5,10 +5,7 @@ import 'package:path/path.dart' as p; class ProjectResolver { static const defaultConfigPath = 'flutterguard.yaml'; - static const _discoveryMarkers = [ - defaultConfigPath, - 'pubspec.yaml', - ]; + static const _discoveryMarkers = [defaultConfigPath, 'pubspec.yaml']; static String resolveProjectPath(String? explicitPath) { if (explicitPath != null && explicitPath != '.') { diff --git a/lib/src/report_generator.dart b/lib/src/report_generator.dart new file mode 100644 index 0000000..5c0cee6 --- /dev/null +++ b/lib/src/report_generator.dart @@ -0,0 +1,179 @@ +import 'dart:convert'; + +import 'package:path/path.dart' as p; + +import 'source_workspace.dart'; +import 'static_issue.dart'; + +class _Ansi { + static const reset = '\x1B[0m'; + static const red = '\x1B[31m'; + static const yellow = '\x1B[33m'; + static const gray = '\x1B[90m'; + static const bold = '\x1B[1m'; + static const dim = '\x1B[2m'; + static const green = '\x1B[32m'; +} + +const _domainLabels = { + IssueDomain.architecture: '架构与安全', + IssueDomain.performance: '生命周期与性能', + IssueDomain.standards: '工程标准', +}; + +const _levelLabels = { + RiskLevel.high: 'HIGH', + RiskLevel.medium: 'MEDIUM', + RiskLevel.low: 'LOW', +}; + +class ReportGenerator { + static String generateJson({ + required String projectPath, + required List issues, + required String scanMode, + int suppressedCount = 0, + int suppressedByBaselineCount = 0, + List diagnostics = const [], + }) { + final payload = { + 'schemaVersion': '2.0.0', + 'projectPath': projectPath, + 'scanMode': scanMode, + 'summary': { + 'total': issues.length, + 'high': issues.where((issue) => issue.level == RiskLevel.high).length, + 'medium': issues + .where((issue) => issue.level == RiskLevel.medium) + .length, + 'low': issues.where((issue) => issue.level == RiskLevel.low).length, + 'suppressed': suppressedCount, + 'suppressedByBaseline': suppressedByBaselineCount, + 'diagnostics': diagnostics.length, + 'byDomain': _summaryByDomain(issues), + }, + 'issues': issues.map((issue) => issue.toJson()).toList(), + 'diagnostics': diagnostics.map((item) => item.toJson()).toList(), + }; + return const JsonEncoder.withIndent(' ').convert(payload); + } + + static String generateStdout({ + required String projectPath, + required List issues, + int? scannedFileCount, + bool verbose = false, + bool noColor = false, + }) { + final buffer = StringBuffer(); + final bold = noColor ? '' : _Ansi.bold; + final reset = noColor ? '' : _Ansi.reset; + buffer + ..writeln('$bold FlutterGuard Report$reset — ${p.basename(projectPath)}') + ..writeln( + ' Files: ${scannedFileCount ?? issues.map((issue) => issue.file).toSet().length} ' + 'Issues: ${issues.length}', + ); + if (issues.isEmpty) { + buffer.writeln(' No issues found.'); + return buffer.toString(); + } + + for (final domain in IssueDomain.values) { + final domainIssues = issues + .where((issue) => issue.domain == domain) + .toList(); + if (domainIssues.isEmpty) continue; + buffer + ..writeln() + ..writeln('${_domainLabels[domain]} (${domainIssues.length})'); + domainIssues.sort( + (a, b) => _severityOrder(a).compareTo(_severityOrder(b)), + ); + for (final issue in domainIssues) { + _writeIssue( + buffer, + issue, + projectPath, + verbose: verbose, + noColor: noColor, + ); + } + } + return buffer.toString(); + } + + static bool shouldFail(List issues, String failOn) { + final threshold = switch (failOn) { + 'high' => 2, + 'medium' => 1, + 'low' => 0, + _ => 3, + }; + return issues.any((issue) => issue.level.index >= threshold); + } + + static void _writeIssue( + StringBuffer buffer, + StaticIssue issue, + String projectPath, { + required bool verbose, + required bool noColor, + }) { + final color = noColor + ? '' + : switch (issue.level) { + RiskLevel.high => _Ansi.red, + RiskLevel.medium => _Ansi.yellow, + RiskLevel.low => _Ansi.gray, + }; + final reset = noColor ? '' : _Ansi.reset; + final bold = noColor ? '' : _Ansi.bold; + final dim = noColor ? '' : _Ansi.dim; + final green = noColor ? '' : _Ansi.green; + final path = p.isWithin(projectPath, issue.file) + ? p.relative(issue.file, from: projectPath) + : issue.file; + final line = issue.line == null ? '' : ':${issue.line}'; + buffer + ..writeln(' $color${_levelLabels[issue.level]}$reset ${issue.id}') + ..writeln(' $bold${issue.title}$reset') + ..writeln(' $path$line — ${issue.message}'); + if (verbose && issue.detail.isNotEmpty) { + for (final detail in issue.detail.split('\n')) { + buffer.writeln(' $dim$detail$reset'); + } + } + if (verbose) { + for (final evidence in issue.evidence.take(5)) { + buffer.writeln(' $dim evidence: $evidence$reset'); + } + } + buffer.writeln(' $green fix: ${issue.suggestion}$reset'); + } + + static int _severityOrder(StaticIssue issue) => switch (issue.level) { + RiskLevel.high => 0, + RiskLevel.medium => 1, + RiskLevel.low => 2, + }; + + static Map> _summaryByDomain( + List issues, + ) { + final result = >{}; + for (final domain in IssueDomain.values) { + final values = issues.where((issue) => issue.domain == domain).toList(); + if (values.isEmpty) continue; + result[domain.name] = { + 'high': values.where((issue) => issue.level == RiskLevel.high).length, + 'medium': values + .where((issue) => issue.level == RiskLevel.medium) + .length, + 'low': values.where((issue) => issue.level == RiskLevel.low).length, + 'total': values.length, + }; + } + return result; + } +} diff --git a/lib/src/rules/AGENTS.md b/lib/src/rules/AGENTS.md new file mode 100644 index 0000000..44c1e5f --- /dev/null +++ b/lib/src/rules/AGENTS.md @@ -0,0 +1,20 @@ +# Rule layer + +Every detector returns `List`. Scanner execution is explicit in +`RuleRegistry.registrations`; metadata and defaults live in the adjacent +`RuleDefinition`. + +Ownership: + +- `boundary_rule.dart`: both layer and module dependency enforcement. +- `lifecycle_resource.dart`: resource cancel/close/dispose/disconnect checks. +- `ble_scanning.dart`: BLE scan timeout only. +- `iot_security.dart`: credentials and insecure transport. +- state files: generic, Riverpod, Bloc, Provider, and dependency-cycle checks. + +Use the supplied `SourceWorkspace`; do not read or parse files again. Respect +`RuleConfig.enabled` and emit `config.severity`. Add special scalar defaults to +`RuleDefinition.defaultOptions`. + +Do not add `describe` metadata to another catalog, restore direct public rule +exports, or let two rule families own the same finding. diff --git a/lib/src/rules/ble_scanning.dart b/lib/src/rules/ble_scanning.dart new file mode 100644 index 0000000..4792210 --- /dev/null +++ b/lib/src/rules/ble_scanning.dart @@ -0,0 +1,178 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/source/line_info.dart'; + +import '../config_loader.dart'; +import 'rule.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; + +const _bleTypeNames = {'Ble', 'BluetoothDevice', 'Bluetooth'}; + +bool _isBleType(String typeName) => + _bleTypeNames.any((t) => typeName.contains(t)); + +class BleScanningRule { + final RuleConfig config; + + const BleScanningRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!config.enabled) return []; + + final issues = []; + final sources = workspace ?? SourceWorkspace(); + + for (final file in files) { + final source = sources.source(file); + if (source == null) continue; + issues.addAll(_checkFile(file, source.unit, source.lineInfo)); + } + + return issues; + } + + List _checkFile( + String file, + CompilationUnit unit, + LineInfo lineInfo, + ) { + final issues = []; + + for (final cls in unit.declarations.whereType()) { + final hasBleField = cls.members.whereType().any((f) { + final type = f.fields.type?.toSource() ?? ''; + return _isBleType(type); + }); + + final hasBleRef = cls.members.whereType().any((m) { + final visitor = _BleTypeUsageVisitor(); + m.accept(visitor); + return visitor.usesBleType; + }); + + if (!hasBleField && !hasBleRef) continue; + + for (final method in cls.members.whereType()) { + if (!_isStartScan(method.name.lexeme)) continue; + _checkScanTimeout(file, method, lineInfo, issues); + } + } + + return issues; + } + + bool _isStartScan(String name) => + name == 'startScan' || name.endsWith('StartScan'); + + void _checkScanTimeout( + String file, + MethodDeclaration method, + LineInfo lineInfo, + List issues, + ) { + if (_hasTimeoutParameter(method)) return; + if (_hasTimeoutInBody(method)) return; + + final line = lineNumberForOffset(lineInfo, method.name.offset); + issues.add( + StaticIssue( + id: 'ble_scanning', + title: 'BLE 扫描缺少超时配置', + file: file, + line: line, + level: config.severity, + domain: IssueDomain.architecture, + message: '${method.name.lexeme}() 调用未配置超时参数', + detail: + '方法: ${method.name.lexeme}\n' + 'BLE 扫描应设置超时以限制扫描时间,避免过度耗电', + suggestion: '为 startScan() 添加明确的 timeout 或 duration 参数', + metadata: {'check': 'scan_without_timeout'}, + ), + ); + } + + bool _hasTimeoutParameter(MethodDeclaration method) { + for (final param in method.parameters?.parameters ?? []) { + if (param is DefaultFormalParameter) { + final name = param.name?.lexeme ?? ''; + if (_isTimeoutName(name)) return true; + } + } + return false; + } + + bool _hasTimeoutInBody(MethodDeclaration method) { + final visitor = _TimeoutArgumentVisitor(); + method.body.accept(visitor); + return visitor.hasTimeout; + } + + bool _isTimeoutName(String name) { + const names = { + 'timeout', + 'duration', + 'maxScanDuration', + 'scanDuration', + 'scanTimeout', + }; + return names.any( + (n) => name.toLowerCase().replaceAll('_', '') == n.toLowerCase(), + ); + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'ble_scanning', + name: 'BLE 扫描管理异常', + domain: IssueDomain.architecture, + defaultSeverity: RiskLevel.medium, + purpose: '检测 BLE 扫描是否配置明确超时', + riskReason: '无限扫描会持续占用无线资源并消耗电量', + badExample: 'startScan() 没有 timeout 或 duration 限制', + fixSuggestion: '为 startScan 添加明确超时,并由资源生命周期规则检查释放', + ); +} + +class _BleTypeUsageVisitor extends RecursiveAstVisitor { + bool usesBleType = false; + + @override + void visitNamedType(NamedType node) { + if (_isBleType(node.toSource())) { + usesBleType = true; + } + super.visitNamedType(node); + } + + @override + void visitSimpleIdentifier(SimpleIdentifier node) { + if (_isBleType(node.name)) { + usesBleType = true; + } + super.visitSimpleIdentifier(node); + } +} + +class _TimeoutArgumentVisitor extends RecursiveAstVisitor { + bool hasTimeout = false; + + static const _timeoutArgNames = { + 'timeout', + 'duration', + 'maxScanDuration', + 'scanDuration', + 'scanTimeout', + }; + + @override + void visitNamedExpression(NamedExpression node) { + final label = node.name.label.name; + if (_timeoutArgNames.any( + (n) => label.toLowerCase().replaceAll('_', '') == n.toLowerCase(), + )) { + hasTimeout = true; + } + super.visitNamedExpression(node); + } +} diff --git a/lib/src/rules/bloc_state_management.dart b/lib/src/rules/bloc_state_management.dart new file mode 100644 index 0000000..9ec79e3 --- /dev/null +++ b/lib/src/rules/bloc_state_management.dart @@ -0,0 +1,103 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import '../config_loader.dart'; +import 'rule.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; +import 'state_management_utils.dart'; + +class BlocEquatablePropsIncompleteRule { + final RuleConfig config; + + const BlocEquatablePropsIncompleteRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + final source = sources.source(file); + if (source == null) continue; + if (!hasEquatableImport(source.unit)) { + continue; + } + if (!frameworkAllowed(source.unit, StateManagementFramework.bloc)) { + continue; + } + for (final cls + in source.unit.declarations.whereType()) { + if (!_inheritsEquatable(cls)) continue; + final fields = {}; + for (final field in cls.members.whereType()) { + if (field.isStatic || !field.fields.isFinal) continue; + for (final variable in field.fields.variables) { + final name = variable.name.lexeme; + fields.add(name); + } + } + if (fields.isEmpty) continue; + final props = cls.members.whereType().where( + (method) => method.isGetter && method.name.lexeme == 'props', + ); + final referenced = {}; + for (final getter in props) { + final visitor = _IdentifierCollector(); + getter.body.accept(visitor); + referenced.addAll(visitor.names); + } + final missing = fields.difference(referenced).toList()..sort(); + if (missing.isEmpty) continue; + issues.add( + StaticIssue( + id: 'bloc_equatable_props_incomplete', + title: 'Equatable props 不完整', + file: file, + line: sourceLine(source, cls), + level: config.severity, + domain: IssueDomain.standards, + message: '${cls.name.lexeme}.props 缺少字段: ${missing.join(', ')}', + suggestion: '将所有参与值相等判断的 final instance 字段加入 props', + framework: StateManagementFramework.bloc, + evidence: missing + .map((field) => 'missing: $field') + .take(5) + .toList(), + metadata: {'className': cls.name.lexeme, 'missingFields': missing}, + ), + ); + } + } + return issues; + } + + static bool _inheritsEquatable(ClassDeclaration cls) { + final superclass = cls.extendsClause?.superclass.toSource() ?? ''; + final mixins = + cls.withClause?.mixinTypes.map((type) => type.toSource()).toList() ?? + const []; + return superclass == 'Equatable' || mixins.contains('EquatableMixin'); + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'bloc_equatable_props_incomplete', + name: 'Equatable props 不完整', + domain: IssueDomain.standards, + defaultSeverity: RiskLevel.medium, + purpose: '比较 Equatable 类的 final instance 字段与 props 字段引用', + riskReason: '遗漏字段会让不同状态被误判为相等,阻止 Bloc UI 更新', + badExample: 'final a; final b; List get props => [a];', + fixSuggestion: '把遗漏的值字段加入 props', + framework: 'bloc', + ); +} + +class _IdentifierCollector extends RecursiveAstVisitor { + final names = {}; + + @override + void visitSimpleIdentifier(SimpleIdentifier node) { + names.add(node.name); + super.visitSimpleIdentifier(node); + } +} diff --git a/lib/src/rules/boundary_rule.dart b/lib/src/rules/boundary_rule.dart new file mode 100644 index 0000000..6ec87f7 --- /dev/null +++ b/lib/src/rules/boundary_rule.dart @@ -0,0 +1,86 @@ +import '../boundary_engine.dart'; +import '../config_loader.dart'; +import '../import_graph.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; +import 'rule.dart'; + +enum BoundaryKind { layer, module } + +class BoundaryRule { + final BoundaryKind kind; + final List boundaries; + final RiskLevel severity; + final String projectPath; + + const BoundaryRule({ + required this.kind, + required this.boundaries, + required this.severity, + required this.projectPath, + }); + + List analyze( + List files, { + required List allFiles, + required SourceWorkspace workspace, + required ImportGraph importGraph, + }) { + final definitions = [ + for (final boundary in boundaries) + BoundaryDefinition( + name: boundary.name, + path: boundary.path, + allowedDeps: boundary.allowedDeps, + ), + ]; + final violations = DependencyBoundaryEngine.analyze( + sourceFiles: files, + allFiles: allFiles, + boundaries: definitions, + graph: importGraph, + workspace: workspace, + projectPath: projectPath, + ); + final label = kind == BoundaryKind.layer ? '层' : '模块'; + return [ + for (final violation in violations) + StaticIssue( + id: '${kind.name}_violation', + title: '$label间依赖违规', + file: violation.edge.source, + line: violation.edge.line, + level: severity, + domain: IssueDomain.architecture, + message: + '${violation.source.name} $label不可依赖 ${violation.target.name} $label', + detail: + '导入: ${violation.edge.uri}\n' + '源$label: ${violation.source.name} (${violation.source.path})\n' + '目标$label: ${violation.target.name} (${violation.target.path})', + suggestion: '调整依赖方向,或通过允许的边界抽象解耦', + metadata: { + 'kind': kind.name, + 'sourceBoundary': violation.source.name, + 'targetBoundary': violation.target.name, + 'imported': violation.edge.uri, + 'allowedDeps': violation.source.allowedDeps, + }, + ), + ]; + } + + static RuleDefinition definition(BoundaryKind kind) { + final layer = kind == BoundaryKind.layer; + return RuleDefinition( + id: '${kind.name}_violation', + name: layer ? '层间依赖违规' : '模块间依赖违规', + domain: IssueDomain.architecture, + defaultSeverity: RiskLevel.high, + purpose: layer ? '检测架构层之间的非法依赖' : '检测业务模块之间的非法依赖', + riskReason: '非法边界依赖会破坏隔离并扩大变更影响范围', + badExample: layer ? 'presentation 直接导入 data 实现' : 'mqtt 直接导入 ble 实现', + fixSuggestion: '调整 allowed_deps,或提取稳定接口到允许依赖的边界', + ); + } +} diff --git a/packages/flutterguard_cli/lib/src/rules/circular_dependency.dart b/lib/src/rules/circular_dependency.dart similarity index 74% rename from packages/flutterguard_cli/lib/src/rules/circular_dependency.dart rename to lib/src/rules/circular_dependency.dart index 09e7725..baebc0b 100644 --- a/packages/flutterguard_cli/lib/src/rules/circular_dependency.dart +++ b/lib/src/rules/circular_dependency.dart @@ -1,9 +1,7 @@ import 'package:path/path.dart' as p; -import '../domain.dart'; import '../import_graph.dart'; -import '../priority.dart'; -import '../rule_meta.dart'; +import 'rule.dart'; import '../source_workspace.dart'; import '../static_issue.dart'; @@ -11,9 +9,14 @@ enum _Color { white, gray, black } class CircularDependencyRule { final bool enabled; + final RiskLevel severity; final String? projectPath; - const CircularDependencyRule({this.enabled = true, this.projectPath}); + const CircularDependencyRule({ + this.enabled = true, + this.severity = RiskLevel.medium, + this.projectPath, + }); List analyze( List files, { @@ -23,7 +26,8 @@ class CircularDependencyRule { if (!enabled || files.length < 2) return []; final sources = workspace ?? SourceWorkspace(); - final imports = importGraph ?? + final imports = + importGraph ?? ImportGraph.build( files: files, sourceFiles: files, @@ -36,9 +40,7 @@ class CircularDependencyRule { return _findCycles(graph); } - List _findCycles( - Map> graph, - ) { + List _findCycles(Map> graph) { final issues = []; final color = {}; final parent = {}; @@ -103,28 +105,23 @@ class CircularDependencyRule { title: '循环依赖', file: cycle.first, line: null, - level: RiskLevel.medium, + level: severity, domain: IssueDomain.architecture, - priority: Priority.p1, message: '检测到循环依赖: $cycleStr', detail: '依赖链:\n${cycle.map((f) => ' $f').join('\n')}', suggestion: '将循环中公共的依赖提取到 core/ 层,或使用依赖反转(接口抽象)打破循环', - metadata: { - 'cycle': cycle, - }, + metadata: {'cycle': cycle}, ); } - static RuleMeta describe() => const RuleMeta( - id: 'circular_dependency', - name: '循环依赖', - domain: 'architecture', - riskLevel: 'medium', - priority: 'p1', - purpose: '检测文件级别的循环 import', - riskReason: '循环依赖导致编译耦合、无法单独测试和复用', - badExample: 'a.dart → b.dart → c.dart → a.dart', - fixSuggestion: '将公共依赖提取到 core 层,或使用接口反转打破循环', - cicdSafe: false, - ); + static RuleDefinition describe() => const RuleDefinition( + id: 'circular_dependency', + name: '循环依赖', + domain: IssueDomain.architecture, + defaultSeverity: RiskLevel.medium, + purpose: '检测文件级别的循环 import', + riskReason: '循环依赖导致编译耦合、无法单独测试和复用', + badExample: 'a.dart → b.dart → c.dart → a.dart', + fixSuggestion: '将公共依赖提取到 core 层,或使用接口反转打破循环', + ); } diff --git a/lib/src/rules/generic_state_management.dart b/lib/src/rules/generic_state_management.dart new file mode 100644 index 0000000..b3c1e6f --- /dev/null +++ b/lib/src/rules/generic_state_management.dart @@ -0,0 +1,449 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import '../config_loader.dart'; +import 'rule.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; +import 'state_management_utils.dart'; + +class SideEffectInBuildRule { + final RuleConfig config; + + const SideEffectInBuildRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + final source = sources.source(file); + if (source == null) continue; + for (final root in buildRoots(source.unit)) { + final visitor = _SideEffectVisitor(); + root.body.accept(visitor); + final evidence = limitedEvidence(visitor.evidence); + if (evidence.isEmpty) continue; + issues.add( + StaticIssue( + id: 'side_effect_in_build', + title: 'build 中执行副作用', + file: file, + line: sourceLine(source, root.anchor), + level: config.severity, + domain: IssueDomain.performance, + message: '${root.label} 在渲染阶段执行了状态或资源副作用', + detail: 'build 必须保持幂等;框架可能在一次界面更新中重复调用它。', + suggestion: '将副作用移至生命周期、listener 或用户事件回调中', + evidence: evidence, + metadata: {'buildRoot': root.label}, + ), + ); + } + } + return issues; + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'side_effect_in_build', + name: 'build 中的副作用', + domain: IssueDomain.performance, + defaultSeverity: RiskLevel.high, + purpose: '检测 Widget build 与 Consumer builder 中直接执行的状态和资源副作用', + riskReason: 'build 可被重复调用,副作用会造成重复连接、通知或状态更新', + badExample: 'Widget build(...) { ref.read(x.notifier).refresh(); }', + fixSuggestion: '把副作用移到生命周期、listener 或事件回调', + ); +} + +class _SideEffectVisitor extends RecursiveAstVisitor { + final evidence = []; + + @override + void visitFunctionExpression(FunctionExpression node) { + // A nested closure is an event/listener/timer boundary. Consumer builder + // closures are scanned separately as build roots. + } + + @override + void visitInstanceCreationExpression(InstanceCreationExpression node) { + final type = simpleTypeName(node.constructorName.type.toSource()); + if (type == 'Timer') { + evidence.add(compactEvidence(node)); + } + super.visitInstanceCreationExpression(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + final name = node.methodName.name; + final target = node.target?.toSource() ?? ''; + const directEffects = { + 'notifyListeners', + 'emit', + 'setState', + 'connect', + 'disconnect', + 'startScan', + }; + final managerAdd = + name == 'add' && + (target.toLowerCase().contains('bloc') || + target.toLowerCase().contains('cubit') || + target.toLowerCase().contains('notifier') || + target.contains('context.read') || + target.contains('ref.read')); + final notifierCommand = + target.contains('ref.read') && + target.contains('.notifier') && + name != 'read'; + final timerPeriodic = target == 'Timer' && name == 'periodic'; + final timerCreation = target.isEmpty && name == 'Timer'; + if (directEffects.contains(name) || + managerAdd || + notifierCommand || + timerPeriodic || + timerCreation) { + evidence.add(compactEvidence(node)); + } + super.visitMethodInvocation(node); + } +} + +class StateManagerCreatedInBuildRule { + final RuleConfig config; + + const StateManagerCreatedInBuildRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + final source = sources.source(file); + if (source == null) continue; + for (final root in buildRoots(source.unit)) { + final visitor = _StateManagerCreationVisitor(); + root.body.accept(visitor); + for (final creation in visitor.creations) { + final type = creation.type; + issues.add( + StaticIssue( + id: 'state_manager_created_in_build', + title: 'build 中创建状态管理对象', + file: file, + line: sourceLine(source, creation.node), + level: config.severity, + domain: IssueDomain.performance, + message: '$type 在 ${root.label} 中被重复创建', + suggestion: '把对象交给 State 生命周期或 Provider/BlocProvider 的 create 管理', + evidence: [compactEvidence(creation.node)], + metadata: {'type': type, 'buildRoot': root.label}, + ), + ); + } + } + } + return issues; + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'state_manager_created_in_build', + name: 'build 中创建状态管理对象', + domain: IssueDomain.performance, + defaultSeverity: RiskLevel.high, + purpose: '检测 build 中创建 Controller、Bloc、Cubit、Notifier 等持有型对象', + riskReason: '重复构建会重建对象并丢失状态或泄漏资源', + badExample: 'Widget build(...) { final c = DeviceController(); }', + fixSuggestion: '提升到生命周期字段或框架所有权 create 回调', + ); +} + +class _StateManagerCreationVisitor extends RecursiveAstVisitor { + final creations = <({AstNode node, String type})>[]; + + static const flutterControllers = { + 'AnimationController', + 'TextEditingController', + 'ScrollController', + 'PageController', + 'TabController', + 'FocusNode', + }; + + @override + void visitFunctionExpression(FunctionExpression node) {} + + @override + void visitInstanceCreationExpression(InstanceCreationExpression node) { + final type = simpleTypeName(node.constructorName.type.toSource()); + _record(node, type); + super.visitInstanceCreationExpression(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.target == null && + node.methodName.name.startsWith(RegExp('[A-Z]'))) { + _record(node, simpleTypeName(node.methodName.name)); + } + super.visitMethodInvocation(node); + } + + void _record(AstNode node, String type) { + final owned = + stateOwnerSuffixes.any(type.endsWith) || + flutterControllers.contains(type); + if (owned) { + creations.add((node: node, type: type)); + } + } +} + +class MutableStateExposedRule { + final RuleConfig config; + + const MutableStateExposedRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + final source = sources.source(file); + if (source == null) continue; + for (final cls + in source.unit.declarations.whereType()) { + if (!isStateOwnerClass(cls)) continue; + issues.addAll(_inspectClass(file, source, cls)); + } + } + return issues; + } + + List _inspectClass( + String file, + SourceUnit source, + ClassDeclaration cls, + ) { + final findings = <({AstNode node, String key, String evidence})>[]; + final privateCollections = {}; + for (final field in cls.members.whereType()) { + if (field.isStatic) continue; + final type = typeName(field.fields.type); + for (final variable in field.fields.variables) { + final name = variable.name.lexeme; + if (!isPublicName(name) && isCollectionType(type)) { + privateCollections.add(name); + } + if (!isPublicName(name)) continue; + if (!field.fields.isFinal) { + findings.add(( + node: variable, + key: 'field:$name:non_final', + evidence: '$type $name is public and non-final', + )); + } else if (isCollectionType(type) && + !isUnmodifiableExpression(variable.initializer)) { + findings.add(( + node: variable, + key: 'field:$name:collection', + evidence: '$type $name exposes a mutable collection reference', + )); + } + } + } + + for (final method in cls.members.whereType()) { + final name = method.name.lexeme; + if (!method.isGetter || !isPublicName(name)) continue; + final returned = _returnedIdentifier(method.body); + if (returned != null && privateCollections.contains(returned)) { + findings.add(( + node: method, + key: 'getter:$name:$returned', + evidence: 'getter $name returns mutable $returned directly', + )); + } + } + + final mutationVisitor = _StateCollectionMutationVisitor(); + cls.accept(mutationVisitor); + for (final mutation in mutationVisitor.mutations) { + findings.add(( + node: mutation, + key: 'state_mutation:${mutation.offset}', + evidence: compactEvidence(mutation), + )); + } + + final seen = {}; + return [ + for (final finding in findings) + if (seen.add(finding.key)) + StaticIssue( + id: 'mutable_state_exposed', + title: '可变状态被公开', + file: file, + line: sourceLine(source, finding.node), + level: config.severity, + domain: IssueDomain.architecture, + message: '${cls.name.lexeme} 暴露或原地修改了可变状态', + suggestion: '公开不可变视图/副本,并用 copyWith 或新集合更新状态', + evidence: [finding.evidence], + metadata: {'className': cls.name.lexeme, 'rootCause': finding.key}, + ), + ]; + } + + String? _returnedIdentifier(FunctionBody body) { + if (body is ExpressionFunctionBody) { + final expression = body.expression; + if (expression is SimpleIdentifier) return expression.name; + } + if (body is BlockFunctionBody) { + for (final statement + in body.block.statements.whereType()) { + final expression = statement.expression; + if (expression is SimpleIdentifier) return expression.name; + } + } + return null; + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'mutable_state_exposed', + name: '公开可变状态', + domain: IssueDomain.architecture, + defaultSeverity: RiskLevel.medium, + purpose: '检测状态 owner 的公开可变字段、集合 getter 与原地 state 集合修改', + riskReason: '外部调用方可绕过状态通知并破坏单向数据流', + badExample: 'final List items = [];', + fixSuggestion: '返回不可变视图或副本,并替换整个状态值', + ); +} + +class _StateCollectionMutationVisitor extends RecursiveAstVisitor { + final mutations = []; + + @override + void visitMethodInvocation(MethodInvocation node) { + final target = node.target?.toSource() ?? ''; + const mutators = { + 'add', + 'addAll', + 'remove', + 'removeWhere', + 'clear', + 'sort', + }; + if ((target == 'state' || target.startsWith('state.')) && + mutators.contains(node.methodName.name)) { + mutations.add(node); + } + super.visitMethodInvocation(node); + } + + @override + void visitAssignmentExpression(AssignmentExpression node) { + final left = node.leftHandSide.toSource(); + if (left.startsWith('state[') || left.startsWith('state.')) { + mutations.add(node); + } + super.visitAssignmentExpression(node); + } +} + +class StateLayerUiDependencyRule { + final RuleConfig config; + + const StateLayerUiDependencyRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + final source = sources.source(file); + if (source == null) continue; + for (final cls + in source.unit.declarations.whereType()) { + if (!isStateOwnerClass(cls)) continue; + final visitor = _UiDependencyVisitor(); + cls.accept(visitor); + final evidence = limitedEvidence(visitor.evidence); + if (evidence.isEmpty) continue; + issues.add( + StaticIssue( + id: 'state_layer_ui_dependency', + title: '状态层依赖 UI API', + file: file, + line: sourceLine(source, cls), + level: config.severity, + domain: IssueDomain.architecture, + message: '${cls.name.lexeme} 直接依赖 Flutter UI 上下文或导航 API', + suggestion: '从状态层输出事件/数据,由 Widget 层执行导航、弹窗和主题访问', + evidence: evidence, + metadata: {'className': cls.name.lexeme}, + ), + ); + } + } + return issues; + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'state_layer_ui_dependency', + name: '状态层依赖 UI', + domain: IssueDomain.architecture, + defaultSeverity: RiskLevel.high, + purpose: '检测状态 owner 对 BuildContext、Widget、Navigator、Theme 等 UI API 的依赖', + riskReason: '状态逻辑与 UI 生命周期耦合,难以测试和复用', + badExample: + 'class DeviceController { void open(BuildContext context) { ... } }', + fixSuggestion: '输出状态或事件,并在 Widget 层处理 UI 行为', + ); +} + +class _UiDependencyVisitor extends RecursiveAstVisitor { + final evidence = []; + + static const uiTypes = {'BuildContext', 'Widget'}; + static const uiCalls = { + 'showDialog', + 'showModalBottomSheet', + 'maybePop', + 'push', + 'pushNamed', + 'of', + }; + + @override + void visitNamedType(NamedType node) { + final type = node.toSource().replaceAll('?', ''); + final simple = simpleTypeName(type); + final nestedTypeOnly = node.parent is TypeArgumentList; + if (!nestedTypeOnly && uiTypes.contains(simple)) { + evidence.add('type dependency: $type'); + } + super.visitNamedType(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + final target = node.target?.toSource() ?? ''; + final name = node.methodName.name; + final uiTarget = const { + 'Navigator', + 'ScaffoldMessenger', + 'MediaQuery', + 'Theme', + }.any((value) => target.contains(value)); + if ((uiTarget && uiCalls.contains(name)) || + name == 'showDialog' || + name == 'showModalBottomSheet') { + evidence.add(compactEvidence(node)); + } + super.visitMethodInvocation(node); + } +} diff --git a/lib/src/rules/iot_security.dart b/lib/src/rules/iot_security.dart new file mode 100644 index 0000000..2cac40a --- /dev/null +++ b/lib/src/rules/iot_security.dart @@ -0,0 +1,269 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/source/line_info.dart'; + +import '../config_loader.dart'; +import 'rule.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; + +final _secretNamePattern = RegExp( + r'^(password|token|secret|api[_]?key)$', + caseSensitive: false, +); +final _cleartextMqttPattern = RegExp( + r'tcp://|port:\s*1883', + caseSensitive: false, +); +const _insecureBleValues = {'withoutBonding', 'withoutPairing'}; + +class IotSecurityRule { + final RuleConfig config; + + const IotSecurityRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!config.enabled) return []; + + final issues = []; + final sources = workspace ?? SourceWorkspace(); + + for (final file in files) { + final source = sources.source(file); + if (source == null) continue; + final visitor = _IotSecurityVisitor( + config, + file, + source.lineInfo, + issues, + ); + source.unit.accept(visitor); + } + + return issues; + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'iot_security', + name: 'IoT 安全风险', + domain: IssueDomain.architecture, + defaultSeverity: RiskLevel.high, + purpose: '检测硬编码凭据、明文 MQTT/HTTP、不安全的 BLE 配置', + riskReason: '硬编码凭据泄露导致设备被入侵;明文传输导致数据窃听', + badExample: 'password: "123456";tcp://broker:1883;BLE 使用 withoutBonding', + fixSuggestion: '使用环境变量或安全存储管理凭据;使用 TLS 加密通信', + defaultOptions: {'requireTls': true}, + ); +} + +class _IotSecurityVisitor extends RecursiveAstVisitor { + final RuleConfig config; + final String file; + final LineInfo lineInfo; + final List issues; + + _IotSecurityVisitor(this.config, this.file, this.lineInfo, this.issues); + + @override + void visitVariableDeclaration(VariableDeclaration node) { + _checkSecret(node); + _checkCleartextMqtt(node); + _checkCleartextHttp(node); + _checkInsecureBle(node); + super.visitVariableDeclaration(node); + } + + @override + void visitNamedExpression(NamedExpression node) { + _checkSecretArg(node); + _checkCleartextMqttArg(node); + _checkInsecureBleArg(node); + super.visitNamedExpression(node); + } + + void _checkSecret(VariableDeclaration node) { + final name = node.name.lexeme; + if (!_secretNamePattern.hasMatch(name)) return; + final value = node.initializer; + if (value is! SimpleStringLiteral || value.stringValue == null) return; + final stringValue = value.stringValue!; + if (stringValue.isEmpty) return; + + final line = lineNumberForOffset(lineInfo, node.name.offset); + issues.add( + StaticIssue( + id: 'iot_security', + title: 'IoT 安全 — 硬编码凭证', + file: file, + line: line, + level: config.severity, + domain: IssueDomain.architecture, + message: '检测到可疑的硬编码凭证', + detail: + '行 $line: $name = "***"\n' + '硬编码凭证可能导致安全泄露,应使用环境变量或安全存储', + suggestion: '使用环境变量或安全存储方案替代硬编码凭证', + metadata: {'securityCheck': 'hardcoded_secret', 'line': line}, + ), + ); + } + + void _checkSecretArg(NamedExpression node) { + final label = node.name.label.name; + if (!_secretNamePattern.hasMatch(label)) return; + final value = node.expression; + if (value is! SimpleStringLiteral || value.stringValue == null) return; + if (value.stringValue!.isEmpty) return; + + final line = lineNumberForOffset(lineInfo, node.offset); + issues.add( + StaticIssue( + id: 'iot_security', + title: 'IoT 安全 — 硬编码凭证', + file: file, + line: line, + level: config.severity, + domain: IssueDomain.architecture, + message: '检测到可疑的硬编码凭证', + detail: + '行 $line: $label = "***"\n' + '硬编码凭证可能导致安全泄露,应使用环境变量或安全存储', + suggestion: '使用环境变量或安全存储方案替代硬编码凭证', + metadata: {'securityCheck': 'hardcoded_secret', 'line': line}, + ), + ); + } + + void _checkCleartextMqtt(VariableDeclaration node) { + if (!config.boolOption('requireTls')) return; + final value = node.initializer; + if (value is! SimpleStringLiteral || value.stringValue == null) return; + if (!_cleartextMqttPattern.hasMatch(value.stringValue!)) return; + + final line = lineNumberForOffset(lineInfo, node.name.offset); + issues.add( + StaticIssue( + id: 'iot_security', + title: 'IoT 安全 — 明文 MQTT 连接', + file: file, + line: line, + level: config.severity, + domain: IssueDomain.architecture, + message: '检测到明文 MQTT 连接配置', + detail: + '行 $line: ${value.toSource()}\n' + '明文 MQTT 连接不安全,应使用 mqtts:// (TLS) 或端口 8883', + suggestion: '将 MQTT 连接升级为 mqtts:// 并使用端口 8883', + metadata: {'securityCheck': 'cleartext_mqtt', 'line': line}, + ), + ); + } + + void _checkCleartextMqttArg(NamedExpression node) { + if (!config.boolOption('requireTls')) return; + final value = node.expression; + if (value is! SimpleStringLiteral || value.stringValue == null) return; + if (!_cleartextMqttPattern.hasMatch(value.stringValue!)) return; + + final line = lineNumberForOffset(lineInfo, node.offset); + issues.add( + StaticIssue( + id: 'iot_security', + title: 'IoT 安全 — 明文 MQTT 连接', + file: file, + line: line, + level: config.severity, + domain: IssueDomain.architecture, + message: '检测到明文 MQTT 连接配置', + detail: + '行 $line: ${value.toSource()}\n' + '明文 MQTT 连接不安全,应使用 mqtts:// (TLS) 或端口 8883', + suggestion: '将 MQTT 连接升级为 mqtts:// 并使用端口 8883', + metadata: {'securityCheck': 'cleartext_mqtt', 'line': line}, + ), + ); + } + + void _checkCleartextHttp(VariableDeclaration node) { + if (!config.boolOption('requireTls')) return; + final value = node.initializer; + if (value is! SimpleStringLiteral || value.stringValue == null) return; + final url = value.stringValue!; + if (!url.startsWith('http://')) return; + if (url.contains('localhost') || url.contains('127.0.0.1')) return; + + final line = lineNumberForOffset(lineInfo, node.name.offset); + issues.add( + StaticIssue( + id: 'iot_security', + title: 'IoT 安全 — 明文 HTTP 连接', + file: file, + line: line, + level: config.severity, + domain: IssueDomain.architecture, + message: '检测到明文 HTTP URL: $url', + detail: '行 $line: ${value.toSource()}\n明文 HTTP 不安全,应使用 HTTPS', + suggestion: '将 HTTP 连接升级为 HTTPS', + metadata: {'securityCheck': 'cleartext_http', 'url': url}, + ), + ); + } + + void _checkInsecureBle(VariableDeclaration node) { + final value = node.initializer; + if (value is! SimpleStringLiteral || value.stringValue == null) return; + final literalValue = value.stringValue!; + if (!_insecureBleValues.contains(literalValue)) return; + + final line = lineNumberForOffset(lineInfo, node.name.offset); + issues.add( + StaticIssue( + id: 'iot_security', + title: 'IoT 安全 — 不安全 BLE 配置', + file: file, + line: line, + level: config.severity, + domain: IssueDomain.architecture, + message: '检测到不安全的 BLE 连接配置: "$literalValue"', + detail: + '行 $line: ${value.toSource()}\n' + 'BLE 连接应启用配对和加密 (bond / pair)', + suggestion: '启用 BLE 配对和加密配置', + metadata: { + 'securityCheck': 'insecure_ble', + 'keyword': literalValue, + 'line': line, + }, + ), + ); + } + + void _checkInsecureBleArg(NamedExpression node) { + final value = node.expression; + if (value is! SimpleStringLiteral || value.stringValue == null) return; + final literalValue = value.stringValue!; + if (!_insecureBleValues.contains(literalValue)) return; + + final line = lineNumberForOffset(lineInfo, node.offset); + issues.add( + StaticIssue( + id: 'iot_security', + title: 'IoT 安全 — 不安全 BLE 配置', + file: file, + line: line, + level: config.severity, + domain: IssueDomain.architecture, + message: '检测到不安全的 BLE 连接配置: "$literalValue"', + detail: + '行 $line: ${value.toSource()}\n' + 'BLE 连接应启用配对和加密 (bond / pair)', + suggestion: '启用 BLE 配对和加密配置', + metadata: { + 'securityCheck': 'insecure_ble', + 'keyword': literalValue, + 'line': line, + }, + ), + ); + } +} diff --git a/packages/flutterguard_cli/lib/src/rules/lifecycle_resource.dart b/lib/src/rules/lifecycle_resource.dart similarity index 56% rename from packages/flutterguard_cli/lib/src/rules/lifecycle_resource.dart rename to lib/src/rules/lifecycle_resource.dart index 62be681..06e0d37 100644 --- a/packages/flutterguard_cli/lib/src/rules/lifecycle_resource.dart +++ b/lib/src/rules/lifecycle_resource.dart @@ -2,10 +2,7 @@ import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/source/line_info.dart'; import '../config_loader.dart'; -import '../domain.dart'; -import '../priority.dart'; -import '../rule_meta.dart'; -import '../source_utils.dart'; +import 'rule.dart'; import '../source_workspace.dart'; import '../static_issue.dart'; @@ -19,17 +16,15 @@ const _resourceTypes = { 'MqttClient': 'disconnect', 'BluetoothDevice': 'disconnect', 'StreamController': 'close', + 'OverlayEntry': 'remove', }; class LifecycleResourceRule { - final LifecycleResourceRuleConfig config; + final RuleConfig config; const LifecycleResourceRule(this.config); - List analyze( - List files, { - SourceWorkspace? workspace, - }) { + List analyze(List files, {SourceWorkspace? workspace}) { if (!config.enabled) return []; final issues = []; @@ -87,27 +82,30 @@ class LifecycleResourceRule { field.fields.variables.first.name.offset, ); - issues.add(StaticIssue( - id: 'lifecycle_resource_not_disposed', - title: '资源未释放', - file: file, - line: line, - level: RiskLevel.medium, - domain: IssueDomain.performance, - priority: Priority.p1, - message: - '$resourceType 类型字段 "$fieldName" 在 "${cls.name.lexeme}" 中未在 dispose() 中释放', - detail: '字段: $fieldName ($resourceType)\n' - '类: ${cls.name.lexeme}\n' - '预期释放调用: $fieldName.$expectedCall()', - suggestion: '在 dispose() 方法中添加 "$fieldName.$expectedCall()" 调用', - metadata: { - 'className': cls.name.lexeme, - 'resourceType': resourceType, - 'fieldName': fieldName, - 'expectedDisposeCall': expectedCall, - }, - )); + issues.add( + StaticIssue( + id: 'lifecycle_resource_not_disposed', + title: '资源未释放', + file: file, + line: line, + level: config.severity, + domain: IssueDomain.performance, + message: + '$resourceType 类型字段 "$fieldName" 在 "${cls.name.lexeme}" 中未在 dispose() 中释放', + detail: + '字段: $fieldName ($resourceType)\n' + '类: ${cls.name.lexeme}\n' + '预期释放调用: $fieldName.$expectedCall()', + suggestion: + '在 dispose() 方法中添加 "$fieldName.$expectedCall()" 调用', + metadata: { + 'className': cls.name.lexeme, + 'resourceType': resourceType, + 'fieldName': fieldName, + 'expectedDisposeCall': expectedCall, + }, + ), + ); } } } @@ -117,17 +115,15 @@ class LifecycleResourceRule { return issues; } - static RuleMeta describe() => const RuleMeta( - id: 'lifecycle_resource_not_disposed', - name: '资源未释放', - domain: 'performance', - riskLevel: 'medium', - priority: 'p1', - purpose: - '检测 StreamSubscription/Timer/AnimationController 等资源在 dispose 中未释放', - riskReason: '未释放的资源导致内存泄漏和性能下降', - badExample: '在 State 中创建 StreamSubscription 但 dispose() 中未调用 cancel()', - fixSuggestion: '在 dispose() 中对每个资源调用对应的 cancel()/dispose()/close()', - cicdSafe: true, - ); + static RuleDefinition describe() => const RuleDefinition( + id: 'lifecycle_resource_not_disposed', + name: '资源未释放', + domain: IssueDomain.performance, + defaultSeverity: RiskLevel.medium, + purpose: + '检测 StreamSubscription/Timer/AnimationController/OverlayEntry 等资源在 dispose 中未释放', + riskReason: '未释放的资源导致内存泄漏和性能下降', + badExample: '在 State 中创建 StreamSubscription 但 dispose() 中未调用 cancel()', + fixSuggestion: '在 dispose() 中对每个资源调用对应的 cancel()/dispose()/close()', + ); } diff --git a/lib/src/rules/provider_state_management.dart b/lib/src/rules/provider_state_management.dart new file mode 100644 index 0000000..efd4b63 --- /dev/null +++ b/lib/src/rules/provider_state_management.dart @@ -0,0 +1,326 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import '../config_loader.dart'; +import 'rule.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; +import 'state_management_utils.dart'; + +class ProviderValueLifecycleMisuseRule { + final RuleConfig config; + + const ProviderValueLifecycleMisuseRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + final source = sources.source(file); + if (source == null || + !frameworkAllowed(source.unit, StateManagementFramework.provider)) { + continue; + } + final visitor = _ProviderOwnershipVisitor(); + source.unit.accept(visitor); + for (final finding in visitor.findings) { + issues.add( + StaticIssue( + id: 'provider_value_lifecycle_misuse', + title: 'Provider 所有权模式误用', + file: file, + line: sourceLine(source, finding.node), + level: config.severity, + domain: IssueDomain.performance, + message: finding.message, + suggestion: finding.kind == 'value_creates' + ? '新对象使用 create 构造;.value 只传入已有实例' + : '已有实例使用 .value,避免 Provider 错误释放或忽略所有权', + framework: StateManagementFramework.provider, + evidence: [compactEvidence(finding.node)], + metadata: {'ownershipError': finding.kind}, + ), + ); + } + } + return issues; + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'provider_value_lifecycle_misuse', + name: 'Provider 所有权误用', + domain: IssueDomain.performance, + defaultSeverity: RiskLevel.medium, + purpose: '检测 .value 创建新对象和 create 返回已有对象的反向所有权用法', + riskReason: '错误的 Provider 构造方式会导致对象未释放、提前释放或状态复用错误', + badExample: 'ChangeNotifierProvider.value(value: DeviceController())', + fixSuggestion: '新对象用 create,已有对象用 .value', + framework: 'provider', + ); +} + +class _ProviderOwnershipFinding { + final AstNode node; + final String kind; + final String message; + + const _ProviderOwnershipFinding(this.node, this.kind, this.message); +} + +class _ProviderOwnershipVisitor extends RecursiveAstVisitor { + final findings = <_ProviderOwnershipFinding>[]; + + @override + void visitInstanceCreationExpression(InstanceCreationExpression node) { + _inspectCall( + node: node, + providerType: simpleTypeName(node.constructorName.type.toSource()), + constructor: node.constructorName.name?.name, + arguments: node.argumentList, + ); + super.visitInstanceCreationExpression(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + final target = node.target?.toSource(); + final targetType = target == null ? null : simpleTypeName(target); + final targetIsProvider = targetType?.endsWith('Provider') ?? false; + final providerType = targetIsProvider + ? targetType! + : simpleTypeName(node.methodName.name); + final constructor = targetIsProvider ? node.methodName.name : null; + _inspectCall( + node: node, + providerType: providerType, + constructor: constructor, + arguments: node.argumentList, + ); + super.visitMethodInvocation(node); + } + + void _inspectCall({ + required AstNode node, + required String providerType, + required String? constructor, + required ArgumentList arguments, + }) { + if (!providerType.endsWith('Provider')) return; + if (constructor == 'value') { + final value = _argument(arguments, 'value'); + final createdType = _createdType(value); + final isConst = value is InstanceCreationExpression && value.isConst; + if (createdType != null && !isConst && !_isImmutableType(createdType)) { + findings.add( + _ProviderOwnershipFinding( + node, + 'value_creates', + '$providerType.value 创建了新对象,Provider 不会按 create 所有权管理它', + ), + ); + } + } + + final create = + _argument(arguments, 'create') ?? + (constructor == 'create' ? _firstPositional(arguments) : null); + if (create is FunctionExpression) { + final returned = _returnedExpression(create.body); + if (returned != null && _isExistingReference(returned)) { + findings.add( + _ProviderOwnershipFinding( + node, + 'create_reuses', + '$providerType.create 返回已有实例,可能错误取得其释放所有权', + ), + ); + } + } + } + + static String? _createdType(Expression? expression) { + if (expression is InstanceCreationExpression) { + return simpleTypeName(expression.constructorName.type.toSource()); + } + if (expression is MethodInvocation && + expression.target == null && + expression.methodName.name.startsWith(RegExp('[A-Z]'))) { + return simpleTypeName(expression.methodName.name); + } + return null; + } + + bool _isImmutableType(String type) { + return type.endsWith('Value') || + type.endsWith('Data') || + type.endsWith('Dto') || + type == 'String' || + type == 'int' || + type == 'double' || + type == 'bool'; + } + + static Expression? _argument(ArgumentList list, String name) { + for (final argument in list.arguments) { + if (argument is NamedExpression && argument.name.label.name == name) { + return argument.expression; + } + } + return null; + } + + static Expression? _firstPositional(ArgumentList list) { + for (final argument in list.arguments) { + if (argument is! NamedExpression) return argument; + } + return null; + } + + static Expression? _returnedExpression(FunctionBody body) { + if (body is ExpressionFunctionBody) return body.expression; + if (body is BlockFunctionBody) { + for (final statement in body.block.statements) { + if (statement is ReturnStatement) return statement.expression; + } + } + return null; + } + + static bool _isExistingReference(Expression expression) => + expression is SimpleIdentifier || + expression is PrefixedIdentifier || + expression is PropertyAccess; +} + +class NotifyListenersInLoopRule { + final RuleConfig config; + + const NotifyListenersInLoopRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + final source = sources.source(file); + if (source == null || + !frameworkAllowed(source.unit, StateManagementFramework.provider)) { + continue; + } + final visitor = _NotifyLoopVisitor(); + source.unit.accept(visitor); + for (final finding in visitor.findings) { + issues.add( + StaticIssue( + id: 'notify_listeners_in_loop', + title: '循环中调用 notifyListeners', + file: file, + line: sourceLine(source, finding.root), + level: config.severity, + domain: IssueDomain.performance, + message: '循环每次迭代都可能触发监听者重建', + suggestion: '在循环内完成批量修改后,只调用一次 notifyListeners()', + framework: StateManagementFramework.provider, + evidence: limitedEvidence( + finding.notifications.map(compactEvidence), + ), + ), + ); + } + } + return issues; + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'notify_listeners_in_loop', + name: '循环中通知监听者', + domain: IssueDomain.performance, + defaultSeverity: RiskLevel.medium, + purpose: '检测 for/while/do-while/forEach 中的 notifyListeners', + riskReason: '循环内通知会触发重复重建并暴露中间状态', + badExample: + 'for (final item in items) { update(item); notifyListeners(); }', + fixSuggestion: '循环结束后统一通知一次', + framework: 'provider', + ); +} + +class _NotifyLoopFinding { + final AstNode root; + final List notifications; + + const _NotifyLoopFinding(this.root, this.notifications); +} + +class _NotifyLoopVisitor extends RecursiveAstVisitor { + final findings = <_NotifyLoopFinding>[]; + + @override + void visitForStatement(ForStatement node) { + final parts = node.forLoopParts; + final provablyShort = _provablyShortFor(parts); + if (!provablyShort) _inspect(node, node.body); + super.visitForStatement(node); + } + + @override + void visitWhileStatement(WhileStatement node) { + _inspect(node, node.body); + super.visitWhileStatement(node); + } + + @override + void visitDoStatement(DoStatement node) { + _inspect(node, node.body); + super.visitDoStatement(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.methodName.name == 'forEach') { + final callback = node.argumentList.arguments + .whereType() + .firstOrNull; + final target = node.target; + final provablyShort = + target is ListLiteral && target.elements.length <= 1 || + target is SetOrMapLiteral && target.elements.length <= 1; + if (callback != null && !provablyShort) _inspect(node, callback.body); + } + super.visitMethodInvocation(node); + } + + void _inspect(AstNode root, AstNode body) { + final visitor = _NotifyInvocationVisitor(); + body.accept(visitor); + if (visitor.nodes.isNotEmpty) { + findings.add(_NotifyLoopFinding(root, visitor.nodes)); + } + } + + bool _provablyShortFor(ForLoopParts parts) { + if (parts is ForEachParts) { + final iterable = parts.iterable; + return iterable is ListLiteral && iterable.elements.length <= 1 || + iterable is SetOrMapLiteral && iterable.elements.length <= 1; + } + final source = parts.toSource().replaceAll(RegExp(r'\s+'), ' '); + return RegExp(r'= 0; [A-Za-z_$][\w$]* < 1;').hasMatch(source) || + RegExp(r'= 0; [A-Za-z_$][\w$]* <= 0;').hasMatch(source); + } +} + +class _NotifyInvocationVisitor extends RecursiveAstVisitor { + final nodes = []; + + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.methodName.name == 'notifyListeners') nodes.add(node); + super.visitMethodInvocation(node); + } +} + +extension _FirstOrNull on Iterable { + T? get firstOrNull => isEmpty ? null : first; +} diff --git a/lib/src/rules/registry.dart b/lib/src/rules/registry.dart new file mode 100644 index 0000000..024e51f --- /dev/null +++ b/lib/src/rules/registry.dart @@ -0,0 +1,197 @@ +import '../import_graph.dart'; +import '../scan_context.dart'; +import '../static_issue.dart'; +import 'ble_scanning.dart'; +import 'bloc_state_management.dart'; +import 'boundary_rule.dart'; +import 'circular_dependency.dart'; +import 'generic_state_management.dart'; +import 'iot_security.dart'; +import 'lifecycle_resource.dart'; +import 'provider_state_management.dart'; +import 'riverpod_state_management.dart'; +import 'rule.dart'; +import 'state_dependency_cycle.dart'; + +/// The single source of truth for rule metadata, defaults, and execution. +class RuleRegistry { + static final List registrations = List.unmodifiable([ + RuleRegistration( + definition: LifecycleResourceRule.describe(), + execute: (context, config, _) => LifecycleResourceRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + definition: BoundaryRule.definition(BoundaryKind.layer), + execute: (context, config, graph) { + if (!config.enabled || context.config.architecture.layers.isEmpty) { + return []; + } + return BoundaryRule( + kind: BoundaryKind.layer, + boundaries: context.config.architecture.layers, + severity: config.severity, + projectPath: context.projectPath, + ).analyze( + context.targetFiles, + allFiles: context.allFiles, + workspace: context.sources, + importGraph: graph!, + ); + }, + ), + RuleRegistration( + definition: BoundaryRule.definition(BoundaryKind.module), + execute: (context, config, graph) { + if (!config.enabled || context.config.architecture.modules.isEmpty) { + return []; + } + return BoundaryRule( + kind: BoundaryKind.module, + boundaries: context.config.architecture.modules, + severity: config.severity, + projectPath: context.projectPath, + ).analyze( + context.targetFiles, + allFiles: context.allFiles, + workspace: context.sources, + importGraph: graph!, + ); + }, + ), + RuleRegistration( + definition: CircularDependencyRule.describe(), + execute: (context, config, graph) => + CircularDependencyRule( + enabled: + config.enabled && + !context.isChanged && + context.config.architecture.detectCycles, + severity: config.severity, + projectPath: context.projectPath, + ).analyze( + context.targetFiles, + workspace: context.sources, + importGraph: graph, + ), + ), + RuleRegistration( + definition: BleScanningRule.describe(), + execute: (context, config, _) => BleScanningRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + definition: IotSecurityRule.describe(), + execute: (context, config, _) => IotSecurityRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + definition: SideEffectInBuildRule.describe(), + execute: (context, config, _) => SideEffectInBuildRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + definition: StateManagerCreatedInBuildRule.describe(), + execute: (context, config, _) => StateManagerCreatedInBuildRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + definition: MutableStateExposedRule.describe(), + execute: (context, config, _) => MutableStateExposedRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + definition: StateLayerUiDependencyRule.describe(), + execute: (context, config, _) => StateLayerUiDependencyRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + definition: StateDependencyCycleRule.describe(), + execute: (context, config, _) => + StateDependencyCycleRule( + config, + projectPath: context.projectPath, + ).analyze( + context.allFiles, + targetFiles: context.targetFiles, + changedOnly: context.isChanged, + workspace: context.sources, + ), + ), + RuleRegistration( + definition: RiverpodReadUsedForRenderRule.describe(), + execute: (context, config, _) => RiverpodReadUsedForRenderRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + definition: RiverpodWatchInCallbackRule.describe(), + execute: (context, config, _) => RiverpodWatchInCallbackRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + definition: BlocEquatablePropsIncompleteRule.describe(), + execute: (context, config, _) => BlocEquatablePropsIncompleteRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + definition: ProviderValueLifecycleMisuseRule.describe(), + execute: (context, config, _) => ProviderValueLifecycleMisuseRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + definition: NotifyListenersInLoopRule.describe(), + execute: (context, config, _) => NotifyListenersInLoopRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + ]); + + static List all() => [ + for (final registration in registrations) registration.definition, + ]; + + static RuleDefinition? find(String id) { + for (final definition in all()) { + if (definition.id == id) return definition; + } + return null; + } + + static List analyze(ScanContext context) { + final needsImportGraph = + context.config.architecture.layers.isNotEmpty || + context.config.architecture.modules.isNotEmpty || + (!context.isChanged && context.config.architecture.detectCycles); + final graph = needsImportGraph + ? ImportGraph.build( + files: context.allFiles, + sourceFiles: context.targetFiles, + workspace: context.sources, + projectPath: context.projectPath, + ) + : null; + + final issues = []; + for (final registration in registrations) { + final definition = registration.definition; + final config = context.config.rule( + definition.id, + defaultSeverity: definition.defaultSeverity, + defaultOptions: definition.defaultOptions, + ); + issues.addAll(registration.execute(context, config, graph)); + } + return issues; + } +} diff --git a/lib/src/rules/riverpod_state_management.dart b/lib/src/rules/riverpod_state_management.dart new file mode 100644 index 0000000..8e0a1e7 --- /dev/null +++ b/lib/src/rules/riverpod_state_management.dart @@ -0,0 +1,276 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import '../config_loader.dart'; +import 'rule.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; +import 'state_management_utils.dart'; + +class RiverpodReadUsedForRenderRule { + final RuleConfig config; + + const RiverpodReadUsedForRenderRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + final source = sources.source(file); + if (source == null || + !frameworkAllowed(source.unit, StateManagementFramework.riverpod)) { + continue; + } + for (final root in buildRoots(source.unit)) { + final reads = _RiverpodReadCollector(root.body)..collect(); + for (final finding in reads.findings) { + issues.add( + StaticIssue( + id: 'riverpod_read_used_for_render', + title: '使用 ref.read 驱动渲染', + file: file, + line: sourceLine(source, finding.read), + level: config.severity, + domain: IssueDomain.performance, + message: 'ref.read 的结果进入了渲染路径,后续状态变化不会触发重建', + suggestion: '在渲染路径使用 ref.watch;命令调用继续使用 ref.read', + framework: StateManagementFramework.riverpod, + evidence: limitedEvidence(finding.sinks), + metadata: {'provider': finding.provider}, + ), + ); + } + } + } + return issues; + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'riverpod_read_used_for_render', + name: 'ref.read 驱动渲染', + domain: IssueDomain.performance, + defaultSeverity: RiskLevel.medium, + purpose: '检测 build 中使用 ref.read 的值构造 Widget 或控制条件渲染', + riskReason: 'read 不订阅 Provider,状态变化后界面可能保持陈旧', + badExample: 'Text(ref.read(deviceProvider).name)', + fixSuggestion: '渲染数据改用 ref.watch', + framework: 'riverpod', + ); +} + +class _ReadFinding { + final MethodInvocation read; + final String provider; + final List sinks; + + const _ReadFinding(this.read, this.provider, this.sinks); +} + +class _RiverpodReadCollector { + final AstNode root; + final findings = <_ReadFinding>[]; + + _RiverpodReadCollector(this.root); + + void collect() { + final visitor = _ReadInvocationVisitor(root); + root.accept(visitor); + for (final read in visitor.reads) { + final provider = read.argumentList.arguments.isEmpty + ? '' + : read.argumentList.arguments.first.toSource(); + final sinks = []; + if (_isRenderSink(read, root)) { + sinks.add( + 'render sink: ${compactEvidence(_nearestRenderNode(read, root))}', + ); + } + final variable = _assignedVariable(read); + if (variable != null) { + final usages = _IdentifierUsageVisitor(variable, root)..scan(); + for (final usage in usages.renderUsages) { + sinks.add( + 'render use of $variable: ${compactEvidence(_nearestRenderNode(usage, root))}', + ); + } + } + if (sinks.isNotEmpty) findings.add(_ReadFinding(read, provider, sinks)); + } + } + + static String? _assignedVariable(MethodInvocation read) { + AstNode? current = read.parent; + while (current != null) { + if (current is VariableDeclaration && current.initializer != null) { + return current.name.lexeme; + } + if (current is Statement || current is FunctionBody) break; + current = current.parent; + } + return null; + } + + static bool _isRenderSink(AstNode node, AstNode root) { + AstNode? current = node; + while (current != null && current != root) { + if (current is FunctionExpression) return false; + if (current is ReturnStatement || + current is IfStatement || + current is ConditionalExpression || + current is IfElement || + current is ForElement) { + return true; + } + current = current.parent; + } + return false; + } + + static AstNode _nearestRenderNode(AstNode node, AstNode root) { + AstNode current = node; + while (current.parent != null && current.parent != root) { + final parent = current.parent!; + if (parent is ReturnStatement || + parent is IfStatement || + parent is ConditionalExpression || + parent is IfElement || + parent is ForElement) { + return parent; + } + current = parent; + } + return current; + } +} + +class _ReadInvocationVisitor extends RecursiveAstVisitor { + final AstNode root; + final reads = []; + + _ReadInvocationVisitor(this.root); + + @override + void visitFunctionExpression(FunctionExpression node) { + if (node == root) super.visitFunctionExpression(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.methodName.name == 'read' && node.target?.toSource() == 'ref') { + reads.add(node); + } + super.visitMethodInvocation(node); + } +} + +class _IdentifierUsageVisitor extends RecursiveAstVisitor { + final String name; + final AstNode root; + final renderUsages = []; + + _IdentifierUsageVisitor(this.name, this.root); + + void scan() => root.accept(this); + + @override + void visitFunctionExpression(FunctionExpression node) {} + + @override + void visitSimpleIdentifier(SimpleIdentifier node) { + if (node.name == name && + node.parent is! VariableDeclaration && + _RiverpodReadCollector._isRenderSink(node, root)) { + renderUsages.add(node); + } + super.visitSimpleIdentifier(node); + } +} + +class RiverpodWatchInCallbackRule { + final RuleConfig config; + + const RiverpodWatchInCallbackRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + final source = sources.source(file); + if (source == null || + !frameworkAllowed(source.unit, StateManagementFramework.riverpod)) { + continue; + } + final visitor = _WatchCallbackVisitor(); + source.unit.accept(visitor); + for (final callback in visitor.callbacks) { + final watches = callback.watches; + if (watches.isEmpty) continue; + issues.add( + StaticIssue( + id: 'riverpod_watch_in_callback', + title: '回调中调用 ref.watch', + file: file, + line: sourceLine(source, callback.function), + level: config.severity, + domain: IssueDomain.performance, + message: '事件或异步回调中调用 ref.watch,订阅不会形成有效渲染依赖', + suggestion: '回调中使用 ref.read;只在 build/provider 声明中使用 ref.watch', + framework: StateManagementFramework.riverpod, + evidence: limitedEvidence(watches.map(compactEvidence)), + ), + ); + } + } + return issues; + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'riverpod_watch_in_callback', + name: '回调中使用 ref.watch', + domain: IssueDomain.performance, + defaultSeverity: RiskLevel.medium, + purpose: '检测事件、listener、timer 和异步回调中的 ref.watch', + riskReason: '回调不是响应式构建范围,watch 的订阅语义无效或误导', + badExample: 'onPressed: () => ref.watch(deviceProvider)', + fixSuggestion: '回调改用 ref.read', + framework: 'riverpod', + ); +} + +class _WatchCallbackFinding { + final FunctionExpression function; + final List watches; + + const _WatchCallbackFinding(this.function, this.watches); +} + +class _WatchCallbackVisitor extends RecursiveAstVisitor { + final callbacks = <_WatchCallbackFinding>[]; + + @override + void visitFunctionExpression(FunctionExpression node) { + if (isCallbackFunction(node)) { + final watches = _WatchInvocationVisitor(); + node.body.accept(watches); + if (watches.nodes.isNotEmpty) { + callbacks.add(_WatchCallbackFinding(node, watches.nodes)); + } + return; + } + super.visitFunctionExpression(node); + } +} + +class _WatchInvocationVisitor extends RecursiveAstVisitor { + final nodes = []; + + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.methodName.name == 'watch' && node.target?.toSource() == 'ref') { + nodes.add(node); + } + super.visitMethodInvocation(node); + } +} diff --git a/lib/src/rules/rule.dart b/lib/src/rules/rule.dart new file mode 100644 index 0000000..0cdb844 --- /dev/null +++ b/lib/src/rules/rule.dart @@ -0,0 +1,64 @@ +import '../config_loader.dart'; +import '../import_graph.dart'; +import '../scan_context.dart'; +import '../static_issue.dart'; + +typedef RuleExecutor = + List Function( + ScanContext context, + RuleConfig config, + ImportGraph? importGraph, + ); + +class RuleDefinition { + final String id; + final String name; + final IssueDomain domain; + final RiskLevel defaultSeverity; + final String purpose; + final String riskReason; + final String badExample; + final String fixSuggestion; + final Map defaultOptions; + final String framework; + + const RuleDefinition({ + required this.id, + required this.name, + required this.domain, + required this.defaultSeverity, + required this.purpose, + required this.riskReason, + required this.badExample, + required this.fixSuggestion, + this.defaultOptions = const {}, + this.framework = 'generic', + }); + + List get configKeys => [ + 'enabled', + 'severity', + ...defaultOptions.keys, + ]; + + Map toJson() => { + 'id': id, + 'name': name, + 'domain': domain.name, + 'severity': defaultSeverity.name, + 'purpose': purpose, + 'riskReason': riskReason, + 'badExample': badExample, + 'fixSuggestion': fixSuggestion, + 'configKeys': configKeys, + 'options': defaultOptions, + 'framework': framework, + }; +} + +class RuleRegistration { + final RuleDefinition definition; + final RuleExecutor execute; + + const RuleRegistration({required this.definition, required this.execute}); +} diff --git a/lib/src/rules/state_dependency_cycle.dart b/lib/src/rules/state_dependency_cycle.dart new file mode 100644 index 0000000..e9dc54e --- /dev/null +++ b/lib/src/rules/state_dependency_cycle.dart @@ -0,0 +1,400 @@ +import 'dart:collection'; + +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import '../config_loader.dart'; +import '../import_utils.dart'; +import '../path_utils.dart'; +import 'rule.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; +import 'state_management_utils.dart'; + +class StateDependencyCycleRule { + final RuleConfig config; + final String projectPath; + + const StateDependencyCycleRule(this.config, {required this.projectPath}); + + List analyze( + List allFiles, { + List? targetFiles, + bool changedOnly = false, + SourceWorkspace? workspace, + }) { + if (!stateRuleEnabled(config)) return []; + final sources = workspace ?? SourceWorkspace(); + final normalizedFiles = allFiles.map(normalizePath).toSet(); + final units = <({String file, SourceUnit source})>[]; + final nodes = {}; + final names = >{}; + + void register(_StateGraphNode node) { + nodes[node.id] = node; + names.putIfAbsent(node.name, () => []).add(node.id); + } + + for (final file in normalizedFiles) { + final source = sources.source(file); + if (source == null) continue; + units.add((file: source.path, source: source)); + for (final cls + in source.unit.declarations.whereType()) { + register( + _StateGraphNode( + id: _nodeId(source.path, 'class', cls.name.lexeme), + name: cls.name.lexeme, + file: source.path, + anchor: cls, + isState: isStateOwnerClass(cls), + ), + ); + } + for (final declaration + in source.unit.declarations + .whereType()) { + for (final variable in declaration.variables.variables) { + final initializer = variable.initializer; + if (initializer != null && _isProviderDeclaration(initializer)) { + register( + _StateGraphNode( + id: _nodeId(source.path, 'provider', variable.name.lexeme), + name: variable.name.lexeme, + file: source.path, + anchor: variable, + isState: true, + ), + ); + } + } + } + } + + for (final candidates in names.values) { + candidates.sort(); + } + final graph = >{ + for (final id in nodes.keys) id: {}, + }; + final knownNames = names.keys.toSet(); + + for (final item in units) { + final importedFiles = _resolvedImports( + item.source.unit, + item.file, + normalizedFiles, + ); + for (final cls + in item.source.unit.declarations.whereType()) { + final ownerId = _nodeId(item.file, 'class', cls.name.lexeme); + if (!nodes.containsKey(ownerId)) continue; + final collector = _DependencyCollector(knownNames); + cls.accept(collector); + _addResolvedEdges( + graph: graph, + ownerId: ownerId, + dependencyNames: collector.dependencies, + sourceFile: item.file, + importedFiles: importedFiles, + nodes: nodes, + names: names, + ); + } + for (final declaration + in item.source.unit.declarations + .whereType()) { + for (final variable in declaration.variables.variables) { + final initializer = variable.initializer; + final ownerId = _nodeId(item.file, 'provider', variable.name.lexeme); + if (initializer == null || !nodes.containsKey(ownerId)) continue; + final collector = _DependencyCollector(knownNames); + initializer.accept(collector); + _addResolvedEdges( + graph: graph, + ownerId: ownerId, + dependencyNames: collector.dependencies, + sourceFile: item.file, + importedFiles: importedFiles, + nodes: nodes, + names: names, + ); + } + } + } + + final targetSet = (targetFiles ?? allFiles).map(normalizePath).toSet(); + final issues = []; + for (final component in _stronglyConnectedComponents(graph)) { + if (component.length < 2 || !component.any((id) => nodes[id]!.isState)) { + continue; + } + final changedNodes = + component.where((id) => targetSet.contains(nodes[id]!.file)).toList() + ..sort(); + if (changedOnly && changedNodes.isEmpty) continue; + final cycleIds = _shortestCycle(component, graph); + if (cycleIds.isEmpty) continue; + final anchorId = changedOnly ? changedNodes.first : cycleIds.first; + final anchor = nodes[anchorId]!; + final cycle = [ + for (final id in cycleIds) _displayName(nodes[id]!, names), + ]; + final componentNames = [ + for (final id in component) _displayName(nodes[id]!, names), + ]..sort(); + final source = sources.source(anchor.file); + if (source == null) continue; + issues.add( + StaticIssue( + id: 'state_dependency_cycle', + title: '状态依赖环', + file: anchor.file, + line: sourceLine(source, anchor.anchor), + level: config.severity, + domain: IssueDomain.architecture, + message: '状态依赖形成环: ${cycle.join(' -> ')}', + suggestion: '提取单向协调器或接口,移除环中的一条状态依赖边', + evidence: [cycle.join(' -> ')], + metadata: {'cycle': cycle, 'nodes': componentNames}, + ), + ); + } + return issues; + } + + static String _nodeId(String file, String kind, String name) => + '${normalizePath(file)}::$kind::$name'; + + Set _resolvedImports( + CompilationUnit unit, + String file, + Set allFiles, + ) { + final result = {}; + for (final directive in unit.directives.whereType()) { + final uri = directive.uri.stringValue; + if (uri == null) continue; + final resolved = resolveImport( + file, + uri, + allFiles, + projectPath: projectPath, + ); + if (resolved != null) result.add(normalizePath(resolved)); + } + return result; + } + + static void _addResolvedEdges({ + required Map> graph, + required String ownerId, + required Set dependencyNames, + required String sourceFile, + required Set importedFiles, + required Map nodes, + required Map> names, + }) { + for (final name in dependencyNames) { + final dependencyId = _resolveNode( + name: name, + sourceFile: sourceFile, + importedFiles: importedFiles, + nodes: nodes, + names: names, + ); + if (dependencyId != null && dependencyId != ownerId) { + graph[ownerId]!.add(dependencyId); + } + } + } + + static String? _resolveNode({ + required String name, + required String sourceFile, + required Set importedFiles, + required Map nodes, + required Map> names, + }) { + final candidates = names[name] ?? const []; + if (candidates.isEmpty) return null; + final normalizedSource = normalizePath(sourceFile); + final local = candidates + .where((id) => nodes[id]!.file == normalizedSource) + .toList(); + if (local.length == 1) return local.single; + final imported = candidates + .where((id) => importedFiles.contains(nodes[id]!.file)) + .toList(); + if (imported.length == 1) return imported.single; + return candidates.length == 1 ? candidates.single : null; + } + + String _displayName(_StateGraphNode node, Map> names) { + if ((names[node.name]?.length ?? 0) <= 1) return node.name; + final path = projectRelativePath( + node.file, + projectPath, + ).replaceAll('\\', '/'); + return '$path::${node.name}'; + } + + static bool _isProviderDeclaration(Expression initializer) { + final source = initializer.toSource(); + return RegExp(r'(^|\.)[A-Za-z]*Provider(?:<[^>]+>)?\s*\(').hasMatch(source); + } + + static List> _stronglyConnectedComponents( + Map> graph, + ) { + var index = 0; + final indices = {}; + final lowlink = {}; + final stack = []; + final onStack = {}; + final result = >[]; + + void connect(String node) { + indices[node] = index; + lowlink[node] = index; + index++; + stack.add(node); + onStack.add(node); + final neighbors = (graph[node] ?? const {}).toList()..sort(); + for (final next in neighbors) { + if (!indices.containsKey(next)) { + connect(next); + lowlink[node] = lowlink[node]! < lowlink[next]! + ? lowlink[node]! + : lowlink[next]!; + } else if (onStack.contains(next)) { + lowlink[node] = lowlink[node]! < indices[next]! + ? lowlink[node]! + : indices[next]!; + } + } + if (lowlink[node] != indices[node]) return; + final component = {}; + while (stack.isNotEmpty) { + final current = stack.removeLast(); + onStack.remove(current); + component.add(current); + if (current == node) break; + } + result.add(component); + } + + final ids = graph.keys.toList()..sort(); + for (final id in ids) { + if (!indices.containsKey(id)) connect(id); + } + return result; + } + + static List _shortestCycle( + Set component, + Map> graph, + ) { + List? best; + final starts = component.toList()..sort(); + for (final start in starts) { + final queue = Queue>()..add([start]); + final shortestDepth = {start: 0}; + while (queue.isNotEmpty) { + final path = queue.removeFirst(); + final current = path.last; + final neighbors = + (graph[current] ?? const {}) + .where(component.contains) + .toList() + ..sort(); + for (final next in neighbors) { + if (next == start && path.length > 1) { + final candidate = [...path, start]; + if (best == null || + candidate.length < best.length || + candidate.length == best.length && + candidate.join('\u0000').compareTo(best.join('\u0000')) < + 0) { + best = candidate; + } + continue; + } + if (path.contains(next)) continue; + final depth = path.length; + if ((shortestDepth[next] ?? 1 << 30) < depth) continue; + shortestDepth[next] = depth; + queue.add([...path, next]); + } + } + } + return best ?? const []; + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'state_dependency_cycle', + name: '状态依赖环', + domain: IssueDomain.architecture, + defaultSeverity: RiskLevel.high, + purpose: '检测 Provider、状态 owner 与其项目内依赖形成的强连通环', + riskReason: '依赖环导致初始化顺序不确定、递归更新和难以隔离的测试', + badExample: 'AuthController -> SessionProvider -> AuthController', + fixSuggestion: '提取协调器或接口,使依赖保持单向', + ); +} + +class _StateGraphNode { + final String id; + final String name; + final String file; + final AstNode anchor; + final bool isState; + + const _StateGraphNode({ + required this.id, + required this.name, + required this.file, + required this.anchor, + required this.isState, + }); +} + +class _DependencyCollector extends RecursiveAstVisitor { + final Set knownNames; + final dependencies = {}; + + _DependencyCollector(this.knownNames); + + @override + void visitNamedType(NamedType node) { + final type = simpleTypeName(node.toSource()); + if (knownNames.contains(type)) dependencies.add(type); + super.visitNamedType(node); + } + + @override + void visitInstanceCreationExpression(InstanceCreationExpression node) { + final type = simpleTypeName(node.constructorName.type.toSource()); + if (knownNames.contains(type)) dependencies.add(type); + super.visitInstanceCreationExpression(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + final name = node.methodName.name; + if (name == 'watch' || name == 'read') { + if (node.argumentList.arguments.isNotEmpty) { + final dependency = node.argumentList.arguments.first.toSource(); + final simple = dependency.split('.').first; + if (knownNames.contains(simple)) dependencies.add(simple); + } + } + final typeArguments = + node.typeArguments?.arguments ?? const []; + for (final argument in typeArguments) { + final type = simpleTypeName(argument.toSource()); + if (knownNames.contains(type)) dependencies.add(type); + } + super.visitMethodInvocation(node); + } +} diff --git a/lib/src/rules/state_management_utils.dart b/lib/src/rules/state_management_utils.dart new file mode 100644 index 0000000..4479f9d --- /dev/null +++ b/lib/src/rules/state_management_utils.dart @@ -0,0 +1,205 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import '../config_loader.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; + +const stateOwnerSuffixes = [ + 'State', + 'Notifier', + 'Controller', + 'Cubit', + 'Bloc', + 'ChangeNotifier', +]; + +bool stateRuleEnabled(RuleConfig rule) => rule.enabled; + +bool hasFrameworkImport( + CompilationUnit unit, + StateManagementFramework framework, +) { + final imports = unit.directives + .whereType() + .map((directive) => directive.uri.stringValue ?? '') + .toList(); + return switch (framework) { + StateManagementFramework.riverpod => imports.any( + (uri) => uri.contains('riverpod') || uri.contains('hooks_riverpod'), + ), + StateManagementFramework.bloc => imports.any( + (uri) => + uri.contains('package:bloc/') || + uri.contains('package:flutter_bloc/'), + ), + StateManagementFramework.provider => imports.any( + (uri) => uri.contains('package:provider/'), + ), + StateManagementFramework.generic => true, + }; +} + +bool frameworkAllowed( + CompilationUnit unit, + StateManagementFramework framework, +) => hasFrameworkImport(unit, framework); + +bool isStateOwnerClass(ClassDeclaration declaration) { + final name = declaration.name.lexeme; + final supertype = declaration.extendsClause?.superclass.toSource() ?? ''; + final mixins = + declaration.withClause?.mixinTypes + .map((type) => type.toSource()) + .join(' ') ?? + ''; + if (supertype.startsWith('State<')) return false; + return stateOwnerSuffixes.any(name.endsWith) || + stateOwnerSuffixes.any(supertype.endsWith) || + stateOwnerSuffixes.any(mixins.contains); +} + +String typeName(TypeAnnotation? type) => type?.toSource() ?? ''; + +String simpleTypeName(String type) { + final withoutGenerics = type.split('<').first; + return withoutGenerics.split('.').last.replaceAll('?', ''); +} + +bool isCollectionType(String type) { + final simple = simpleTypeName(type); + return simple == 'List' || + simple == 'Set' || + simple == 'Map' || + simple.startsWith('Iterable'); +} + +bool isUnmodifiableExpression(Expression? expression) { + if (expression == null) return false; + final source = expression.toSource(); + return source.contains('unmodifiable') || + source.contains('Unmodifiable') || + source.contains('List.unmodifiable') || + source.contains('Set.unmodifiable') || + source.contains('Map.unmodifiable'); +} + +bool isPublicName(String name) => !name.startsWith('_'); + +String compactEvidence(AstNode node) { + final value = node.toSource().replaceAll(RegExp(r'\s+'), ' ').trim(); + return value.length <= 140 ? value : '${value.substring(0, 137)}...'; +} + +List limitedEvidence(Iterable evidence) => + evidence.toSet().take(5).toList(); + +int sourceLine(SourceUnit source, AstNode node) => + source.lineInfo.getLocation(node.offset).lineNumber; + +class BuildRoot { + final AstNode body; + final AstNode anchor; + final String label; + + const BuildRoot(this.body, this.anchor, this.label); +} + +List buildRoots(CompilationUnit unit) { + final collector = _BuildRootCollector(); + unit.accept(collector); + final roots = []; + final seen = {}; + for (final root in collector.roots) { + if (seen.add(root.anchor.offset)) roots.add(root); + } + return roots; +} + +class _BuildRootCollector extends RecursiveAstVisitor { + final roots = []; + + @override + void visitMethodDeclaration(MethodDeclaration node) { + if (node.name.lexeme == 'build') { + roots.add(BuildRoot(node.body, node, 'build')); + } + super.visitMethodDeclaration(node); + } + + @override + void visitNamedExpression(NamedExpression node) { + if (node.name.label.name == 'builder' && + node.expression is FunctionExpression && + _isConsumerBuilder(node)) { + final function = node.expression as FunctionExpression; + roots.add(BuildRoot(function.body, function, 'Consumer.builder')); + } + super.visitNamedExpression(node); + } + + bool _isConsumerBuilder(NamedExpression node) { + final arguments = node.parent; + final call = arguments?.parent; + if (call is InstanceCreationExpression) { + return call.constructorName.type.toSource().contains('Consumer'); + } + if (call is MethodInvocation) { + return call.methodName.name.toLowerCase().contains('consumer'); + } + return false; + } +} + +bool isCallbackFunction(FunctionExpression node) { + final parent = node.parent; + if (parent is NamedExpression) { + const callbackNames = { + 'onPressed', + 'onTap', + 'onChanged', + 'onSubmitted', + 'listener', + 'listenWhen', + 'callback', + 'onData', + 'onError', + 'onDone', + 'timer', + }; + return callbackNames.contains(parent.name.label.name); + } + final arguments = parent is NamedExpression ? parent.parent : parent; + final invocation = arguments?.parent; + if (invocation is MethodInvocation) { + return const { + 'forEach', + 'listen', + 'then', + 'catchError', + 'whenComplete', + 'delayed', + 'microtask', + }.contains(invocation.methodName.name); + } + if (invocation is InstanceCreationExpression) { + final type = simpleTypeName(invocation.constructorName.type.toSource()); + return type == 'Timer' || type == 'Future'; + } + return false; +} + +bool isInsideNestedFunction(AstNode node, AstNode root) { + AstNode? current = node.parent; + while (current != null && current != root) { + if (current is FunctionExpression) return true; + current = current.parent; + } + return false; +} + +bool hasEquatableImport(CompilationUnit unit) => + unit.directives.whereType().any( + (directive) => + (directive.uri.stringValue ?? '').contains('package:equatable/'), + ); diff --git a/packages/flutterguard_cli/lib/src/sarif_report.dart b/lib/src/sarif_report.dart similarity index 79% rename from packages/flutterguard_cli/lib/src/sarif_report.dart rename to lib/src/sarif_report.dart index 89e65f4..524ae82 100644 --- a/packages/flutterguard_cli/lib/src/sarif_report.dart +++ b/lib/src/sarif_report.dart @@ -30,9 +30,10 @@ class SarifReport { 'fullDescription': {'text': rule.riskReason}, 'help': {'text': rule.fixSuggestion}, 'defaultConfiguration': { - 'level': _levelFromRule(rule.riskLevel), + 'level': _level(rule.defaultSeverity), }, - } + 'properties': {'framework': rule.framework}, + }, ], }, }, @@ -42,21 +43,23 @@ class SarifReport { 'ruleId': issue.id, 'level': _level(issue.level), 'message': {'text': issue.message}, + 'properties': { + 'framework': issue.framework.name, + 'evidence': issue.evidence.take(5).toList(), + }, 'locations': [ { 'physicalLocation': { 'artifactLocation': { 'uri': _relativeUri(issue.file, projectPath), }, - 'region': { - 'startLine': issue.line ?? 1, - }, + 'region': {'startLine': issue.line ?? 1}, }, - } + }, ], - } + }, ], - } + }, ], }; @@ -66,8 +69,8 @@ class SarifReport { static String _relativeUri(String filePath, String projectPath) { final relative = p.isWithin(projectPath, filePath) || p.equals(projectPath, filePath) - ? p.relative(filePath, from: projectPath) - : filePath; + ? p.relative(filePath, from: projectPath) + : filePath; return relative.replaceAll('\\', '/'); } @@ -81,15 +84,4 @@ class SarifReport { return 'note'; } } - - static String _levelFromRule(String riskLevel) { - switch (riskLevel.toLowerCase()) { - case 'high': - return 'error'; - case 'medium': - return 'warning'; - default: - return 'note'; - } - } } diff --git a/packages/flutterguard_cli/lib/src/scan_context.dart b/lib/src/scan_context.dart similarity index 100% rename from packages/flutterguard_cli/lib/src/scan_context.dart rename to lib/src/scan_context.dart diff --git a/packages/flutterguard_cli/lib/src/scanner.dart b/lib/src/scanner.dart similarity index 91% rename from packages/flutterguard_cli/lib/src/scanner.dart rename to lib/src/scanner.dart index fe81963..7bc7ce8 100644 --- a/packages/flutterguard_cli/lib/src/scanner.dart +++ b/lib/src/scanner.dart @@ -9,7 +9,7 @@ import 'path_utils.dart'; import 'project_resolver.dart'; import 'report_generator.dart'; import 'scan_context.dart'; -import 'rules/catalog.dart'; +import 'rules/registry.dart'; import 'sarif_report.dart'; import 'static_issue.dart'; import 'source_workspace.dart'; @@ -32,7 +32,6 @@ class ScanResult { final List issues; final int suppressedCount; final int suppressedByBaselineCount; - final int score; final String scanMode; final List diagnostics; final SourceWorkspace sources; @@ -45,7 +44,6 @@ class ScanResult { required this.issues, required this.suppressedCount, required this.suppressedByBaselineCount, - required this.score, required this.scanMode, required this.sources, this.diagnostics = const [], @@ -59,8 +57,6 @@ class FlutterGuardScanner { String outputDir = '.flutterguard', bool writeJson = false, bool writeSarif = false, - @Deprecated('Color is a presentation concern; pass it to ReportGenerator.') - bool noColor = false, bool changedOnly = false, String base = 'main', String? baselinePath, @@ -99,8 +95,9 @@ class FlutterGuardScanner { ); if (changed != null) { changedFiles = changed.map(normalizePath).toSet(); - final changedDart = - changedFiles.where((f) => f.endsWith('.dart')).toSet(); + final changedDart = changedFiles + .where((f) => f.endsWith('.dart')) + .toSet(); filesToScan = files.where((f) => changedDart.contains(f)).toList(); scanMode = ScanMode.changed; } @@ -128,10 +125,7 @@ class FlutterGuardScanner { var issues = rawIssues; var suppressedCount = 0; if (applySuppression) { - final suppression = SuppressionFilter( - filesToScan, - workspace: sources, - ); + final suppression = SuppressionFilter(filesToScan, workspace: sources); final visible = []; for (final issue in issues) { if (suppression.isSuppressed(issue)) { @@ -160,8 +154,6 @@ class FlutterGuardScanner { issues = visible; } - final score = ReportGenerator.calculateScore(issues); - if (writeJson || writeSarif) { Directory(reportDir).createSync(recursive: true); } @@ -192,7 +184,6 @@ class FlutterGuardScanner { issues: issues, suppressedCount: suppressedCount, suppressedByBaselineCount: suppressedByBaselineCount, - score: score, scanMode: scanMode.name, sources: sources, diagnostics: sources.diagnostics, @@ -200,7 +191,7 @@ class FlutterGuardScanner { } static List _analyze(ScanContext context) { - final allIssues = RuleCatalog.analyze(context); + final allIssues = RuleRegistry.analyze(context); allIssues.sort((a, b) { final levelOrder = { diff --git a/packages/flutterguard_cli/lib/src/source_workspace.dart b/lib/src/source_workspace.dart similarity index 85% rename from packages/flutterguard_cli/lib/src/source_workspace.dart rename to lib/src/source_workspace.dart index 7e8ca95..189c856 100644 --- a/packages/flutterguard_cli/lib/src/source_workspace.dart +++ b/lib/src/source_workspace.dart @@ -7,6 +7,9 @@ import 'package:analyzer/source/line_info.dart'; import 'path_utils.dart'; +int lineNumberForOffset(LineInfo lineInfo, int offset) => + lineInfo.getLocation(offset).lineNumber; + enum ScanDiagnosticSeverity { warning, error } class ScanDiagnostic { @@ -23,11 +26,11 @@ class ScanDiagnostic { }); Map toJson() => { - 'stage': stage, - 'message': message, - 'file': file, - 'severity': severity.name, - }; + 'stage': stage, + 'message': message, + 'file': file, + 'severity': severity.name, + }; } class SourceUnit { @@ -87,11 +90,13 @@ class SourceWorkspace { } void _recordFailure(String path, String stage, String message) { - _diagnostics.add(ScanDiagnostic( - stage: stage, - file: path, - message: message, - severity: ScanDiagnosticSeverity.error, - )); + _diagnostics.add( + ScanDiagnostic( + stage: stage, + file: path, + message: message, + severity: ScanDiagnosticSeverity.error, + ), + ); } } diff --git a/packages/flutterguard_cli/lib/src/static_issue.dart b/lib/src/static_issue.dart similarity index 50% rename from packages/flutterguard_cli/lib/src/static_issue.dart rename to lib/src/static_issue.dart index 2450735..99dc1e2 100644 --- a/packages/flutterguard_cli/lib/src/static_issue.dart +++ b/lib/src/static_issue.dart @@ -1,8 +1,9 @@ -import 'domain.dart'; -import 'priority.dart'; +enum IssueDomain { architecture, performance, standards } enum RiskLevel { low, medium, high } +enum StateManagementFramework { riverpod, bloc, provider, generic } + class StaticIssue { final String id; final String title; @@ -10,11 +11,12 @@ class StaticIssue { final int? line; final RiskLevel level; final IssueDomain domain; - final Priority priority; final String message; final String detail; final String suggestion; final Map metadata; + final StateManagementFramework framework; + final List evidence; const StaticIssue({ required this.id, @@ -23,24 +25,26 @@ class StaticIssue { this.line, required this.level, required this.domain, - required this.priority, required this.message, this.detail = '', required this.suggestion, this.metadata = const {}, + this.framework = StateManagementFramework.generic, + this.evidence = const [], }); Map toJson() => { - 'id': id, - 'title': title, - 'file': file, - 'line': line, - 'level': level.name, - 'domain': domain.name, - 'priority': priority.name, - 'message': message, - 'detail': detail, - 'suggestion': suggestion, - 'metadata': metadata, - }; + 'ruleId': id, + 'title': title, + 'file': file, + 'line': line, + 'severity': level.name, + 'domain': domain.name, + 'message': message, + 'detail': detail, + 'suggestion': suggestion, + 'metadata': metadata, + 'framework': framework.name, + 'evidence': evidence.take(5).toList(), + }; } diff --git a/packages/flutterguard_cli/lib/src/suppression.dart b/lib/src/suppression.dart similarity index 90% rename from packages/flutterguard_cli/lib/src/suppression.dart rename to lib/src/suppression.dart index 9bb2c47..91e3c0e 100644 --- a/packages/flutterguard_cli/lib/src/suppression.dart +++ b/lib/src/suppression.dart @@ -6,10 +6,7 @@ import 'static_issue.dart'; class SuppressionFilter { final Map>> _rulesByFileAndLine = {}; - SuppressionFilter( - Iterable files, { - SourceWorkspace? workspace, - }) { + SuppressionFilter(Iterable files, {SourceWorkspace? workspace}) { for (final file in files) { _rulesByFileAndLine[file] = _parseFile(file, workspace: workspace); } @@ -52,8 +49,9 @@ class SuppressionFilter { } static Set? _parseLine(String line) { - final match = RegExp(r'flutterguard:\s*ignore\s+([A-Za-z0-9_,\s-]+)') - .firstMatch(line); + final match = RegExp( + r'flutterguard:\s*ignore\s+([A-Za-z0-9_,\s-]+)', + ).firstMatch(line); if (match == null) return null; final raw = match.group(1) ?? ''; final ids = raw diff --git a/melos.yaml b/melos.yaml deleted file mode 100644 index 3e3ef07..0000000 --- a/melos.yaml +++ /dev/null @@ -1,21 +0,0 @@ -name: flutterguard - -packages: - - packages/flutterguard_cli - - examples/** - -command: - bootstrap: - usePubspecOverrides: true - version: - branch: main - -scripts: - analyze: - run: dart run melos exec -c 1 -- "dart analyze ." - format: - run: dart run melos exec -- "dart format ." - test: - run: dart run melos exec --scope="flutterguard_cli" -- dart test - test:cli: - run: dart run melos exec --scope="flutterguard_cli" -- dart test diff --git a/opencode.json b/opencode.json deleted file mode 100644 index 162d075..0000000 --- a/opencode.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "instructions": [ - "AGENTS.md", - "PROJECT_RULES.md", - "packages/*/AGENTS.md", - "docs/ARCHITECTURE.md" - ] -} diff --git a/packages/flutterguard_cli/AGENTS.md b/packages/flutterguard_cli/AGENTS.md deleted file mode 100644 index 238cad4..0000000 --- a/packages/flutterguard_cli/AGENTS.md +++ /dev/null @@ -1,71 +0,0 @@ -# Package: flutterguard_cli [ACTIVE] - -## Role -Primary CLI tool for IoT Flutter static architecture scanning and CI gating. - -## Dependency Map -- depends on: args, analyzer ^7.3.0, glob, path, yaml -- depended by: nothing - -## Entry Points -- bin: `bin/flutterguard.dart` — thin CLI router, help, and exit codes -- lib barrel: `lib/flutterguard_cli.dart` - -## Key Source Files -| File | Responsibility | -|------|---------------| -| `bin/flutterguard.dart` | Top-level routing, positional path, help, exit codes | -| `src/cli/` | Parser tree and functional command handlers | -| `src/scan_context.dart` | Project/all/target file scope and scan mode | -| `src/source_workspace.dart` | Shared source/AST cache and diagnostics | -| `src/import_graph.dart` | Shared import graph for architecture rules | -| `src/rules/catalog.dart` | Explicit metadata and execution wiring | -| `src/config_loader.dart` | YAML → ScanConfig typedef parsing (11 rule configs + architecture) | -| `src/file_collector.dart` | Glob-based .dart file discovery | -| `src/project_resolver.dart` | Project auto-discovery (walk-up flutterguard.yaml / pubspec.yaml / lib/) | -| `src/import_utils.dart` | Shared import resolution utility | -| `src/path_utils.dart` | Cross-platform path/glob helpers (p.Context abstraction) | -| `src/source_utils.dart` | Analyzer offset → line number conversion | -| `src/static_issue.dart` | StaticIssue model + RiskLevel enum | -| `src/report_generator.dart` | JSON + Table report generation + score + --no-color | -| `src/rules/large_units.dart` | 3 sub-rules: file size, class size, build method size | -| `src/rules/lifecycle_resource.dart` | Undisposed controllers/streams/MQTT/BLE detection | -| `src/rules/layer_violation.dart` | Cross-layer import violation detection | -| `src/rules/module_violation.dart` | Cross-module import violation detection | -| `src/rules/circular_dependency.dart` | File-level cycle detection | -| `src/rules/missing_const_constructor.dart` | Widgets missing const constructor | -| `src/rules/iot_security.dart` | Hardcoded secrets, cleartext MQTT/HTTP, insecure BLE | -| `src/rules/device_lifecycle.dart` | Device init/teardown pair checks | -| `src/rules/mqtt_connection.dart` | MQTT connect/disconnect, subscribe/unsubscribe, broker URLs | -| `src/rules/ble_scanning.dart` | BLE startScan/stopScan, connect/disconnect, scan timeout | -| `src/rules/pubspec_security.dart` | Unbounded deps, deprecated packages, outdated IoT dependencies | - -## Wired Rules (11 rule classes, 13 rule IDs) -Standards: LargeUnitsRule (3 IDs), MissingConstConstructorRule, PubspecSecurityRule -Performance: LifecycleResourceRule -Architecture: LayerViolationRule, ModuleViolationRule, CircularDependencyRule -IoT: DeviceLifecycleRule, MqttConnectionRule, BleScanningRule, IotSecurityRule - -## Test -- command: `melos run test:cli` -- test files: `test/scanner_test.dart` (57 tests) and `test/cli_test.dart` (4 process-level tests) -- fixtures: `test/fixtures/` (16 functional fixture files) -- every new rule needs: spec entry → config typedef → class → fixture → test → wire in rules/catalog.dart - -## Current Toolchain Flow -1. `bin/flutterguard.dart` routes CLI commands (supports positional ``) and maps validation errors to exit codes; `lib/src/cli/` owns parser and command-family behavior. -2. `lib/src/project_resolver.dart` auto-discovers project root by walking up for flutterguard.yaml / pubspec.yaml / lib/. -3. `lib/src/scanner.dart` owns scan orchestration: config loading, file collection, rule execution, issue sorting, and optional JSON writing. -4. `lib/src/config_loader.dart` parses `flutterguard.yaml` into typed record configs. -5. `lib/src/file_collector.dart` resolves include/exclude globs to Dart files. -6. `lib/src/rules/catalog.dart` explicitly registers metadata and execution. Do not add reflection or dynamic plugin loading. -7. `SourceWorkspace` and `ImportGraph` provide per-scan shared analysis data. -8. `lib/src/report_generator.dart` renders table output (with optional `--no-color`) and JSON report payloads. - -## Change Boundaries -- Put top-level CLI routing/help in `bin/`; put functional command parsing and behavior in `lib/src/cli/`. -- Put reusable scan behavior in `lib/src/scanner.dart`, not in `bin/`. -- Put rule-specific detection in `lib/src/rules/`. -- Put shared path/import/source helpers in `lib/src/*_utils.dart`. -- Put project resolution logic in `lib/src/project_resolver.dart`. -- Add or update tests in `test/scanner_test.dart` for every behavior change. diff --git a/packages/flutterguard_cli/CHANGELOG.md b/packages/flutterguard_cli/CHANGELOG.md deleted file mode 100644 index ab05a9f..0000000 --- a/packages/flutterguard_cli/CHANGELOG.md +++ /dev/null @@ -1,113 +0,0 @@ -# Changelog - -## 0.5.0 (2026-07-12) - -### Architecture Refactor - -- **cli:** Introduced shared scan analysis infrastructure: `ScanContext`, `SourceWorkspace`, `ImportGraph`, and `DependencyBoundaryEngine`. -- **cli:** Added `rules/catalog.dart` as the single source of truth for rule metadata and execution, routing all rules through the shared workspace. -- **cli:** Split command handlers into `lib/src/cli/`, reducing `bin/flutterguard.dart` to routing, help, and exit codes. -- **ci:** Aligned the release workflow Dart SDK with the workspace `^3.11.5` constraint. -- **fix:** Reject full scans whose include/exclude policy matches no Dart files instead of returning a successful empty report. -- **fix:** Resolve relative configs from the target project and reject missing explicitly selected config files. -- **fix:** Treat clean changed-only scans as successful empty incremental reports and reject invalid Git base refs. -- **ci:** Validate the current checkout on all supported CI operating systems instead of scanning with a pub.dev installation. - -## 0.4.1 (2026-07-09) - -### Adoption Hardening - -- **cli:** Added `flutterguard doctor install` to diagnose executable version, Dart entrypoint, and PATH conflicts. -- **cli:** Added `flutterguard issue export` for local, user-reviewed false-positive feedback bundles. -- **cli:** Added `flutterguard baseline stats`, `flutterguard baseline prune`, and `flutterguard baseline check --no-growth`. -- **cli:** Added `flutterguard init --profile` starter profiles and positional `init []` support. -- **test:** Added coverage for install diagnostics, issue export, baseline management, and config profiles. - -## 0.4.0 (2026-07-08) - -### CI Adoption - -- **cli:** Added `flutterguard baseline create []` to snapshot existing issues into `.flutterguard/baseline.json`. -- **cli:** Added `scan --baseline ` to hide baseline-matched issues from stdout, JSON, SARIF, scoring, and CI gates. -- **cli:** Added source suppression comments: `// flutterguard: ignore `, comma-separated IDs, and `ignore all`. -- **cli:** Added `--format sarif` with SARIF 2.1.0 output at `.flutterguard/report.sarif` for GitHub Code Scanning. -- **cli:** JSON summary now reports `suppressed` and `suppressedByBaseline` counts. -- **docs:** Updated README command references, CI onboarding order, suppression examples, baseline usage, and SARIF upload workflow. -- **release:** Documented source-checkout launcher usage to avoid stale global binaries. - -### Tests - -- **test:** Added coverage for suppression matching, next-line `ignore all`, baseline filtering, missing baseline errors, JSON counters, and SARIF structure. -- **test:** CLI test suite now covers 43 tests. - -## 0.3.0 (2026-06-28) - -### Incremental Scan (--changed-only) - -- **cli:** New `--changed-only` flag — only scans `.dart` files changed since `--base` (default: `main`) -- **cli:** Integrated `git diff --name-only` + `git ls-files --others` for change detection -- **cli:** Non-git fallback: gracefully degrades to full scan -- **cli:** `circular_dependency` auto-disabled in changed-only mode -- **cli:** JSON report now includes `scanMode: full|changed` field - -### Rule Introspection (rules / explain) - -- **cli:** New `flutterguard rules` subcommand — list all 13 rules with ID, domain, name -- **cli:** New `flutterguard explain ` subcommand — detailed purpose, risk, example, fix, config -- **cli:** New `RuleMeta` class in `lib/src/rule_meta.dart` — structured rule metadata -- **cli:** New `RuleRegistry` singleton in `lib/src/rules/registry.dart` — registry with `all()` and `find()` -- **cli:** Output supports both `table` (default) and `--format json` - -### Tests - -- **test:** 37 total tests (26 base + 6 new + 5 existing) -- **test:** 3 `changed-only` tests: git repo filter, non-git fallback, skip cycle -- **test:** 3 registry tests: all 13 rules, find by ID, unknown returns null - -### Infrastructure - -- **meta:** Version bumped to 0.3.0 -- **meta:** `AGENTS.md` and `FLUTTERGUARD_SPEC.md` updated for new features - -## 0.1.1 (2026-06-09) - -### pub.dev Publishing - -- **cli:** Published to pub.dev — `dart pub global activate flutterguard_cli` -- **cli:** Added pubspec.yaml metadata (repository, issue_tracker, topics) -- **cli:** Added package-level README, LICENSE, CHANGELOG -- **cli:** Removed Flutter imports from test fixtures for pure Dart compatibility - -### Cross-Platform Documentation - -- **docs:** `USAGE.md` — comprehensive usage guide (macOS / Windows / Linux) -- **docs:** `WINDOWS_ASSESSMENT.md` — full Windows compatibility audit -- **docs:** Enhanced README.md and README.zh.md with platform-specific install/usage/troubleshooting sections - -### Fixes - -- **cli:** Fixed pub.dev topic count limit (5 max) -- **cli:** Fixed test fixture Flutter dependency warnings - -## 0.1.0 (2026-05-17) - -### Initial Release — CLI Static Analysis - -- **cli:** 6 static analysis rules: large_file, large_class, large_build_method, lifecycle_resource_not_disposed (IoT-aware), layer_violation, module_violation, circular_dependency, missing_const_constructor -- **cli:** YAML-driven config with include/exclude patterns, rule thresholds, and architecture layers/modules -- **cli:** Table (terminal) and JSON output formats with domain-grouped reporting -- **cli:** CI gate integration with --fail-on threshold and --min-score support -- **cli:** Architecture layer/module enforcement with configurable enabled/disabled -- **cli:** Config key validation (warns on unknown YAML keys) -- **cli:** --version and comprehensive --help output -- **cli:** Native binary compilation (dart compile exe) -- **docs:** FLUTTERGUARD_SPEC.md with full rule contracts, config schema, and output spec -- **docs:** ARCHITECTURE.md, AGENTS.md, PROJECT_RULES.md with dependency graph and override chains -- **meta:** melos monorepo setup with 4 packages + 2 examples -- **meta:** MIT license - -### Known Limitations - -- IoT-specific rules (device_lifecycle, mqtt_connection, ble_scanning, iot_security, pubspec_security) defined in spec but not yet implemented -- Lifecycle resource detection uses string pattern matching (not type-resolution) -- runtime tracing packages (core/dio/flutter) are frozen — Path A (static analysis) is primary diff --git a/packages/flutterguard_cli/LICENSE b/packages/flutterguard_cli/LICENSE deleted file mode 100644 index 127a57b..0000000 --- a/packages/flutterguard_cli/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 FlutterGuard - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/flutterguard_cli/README.md b/packages/flutterguard_cli/README.md deleted file mode 100644 index b5f2eee..0000000 --- a/packages/flutterguard_cli/README.md +++ /dev/null @@ -1,193 +0,0 @@ -[![pub package](https://img.shields.io/pub/v/flutterguard_cli.svg)](https://pub.dev/packages/flutterguard_cli) -[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT) - -# flutterguard_cli - -IoT Flutter project static analysis CLI for architecture enforcement, code quality, and CI gating. - -**Platforms**: macOS / Windows / Linux — pure Dart, zero native dependencies. - -## Quick Start - -```bash -# Install globally -dart pub global activate flutterguard_cli -flutterguard --version - -# Scan a Flutter project -flutterguard scan -p /path/to/flutter_project - -# Create and validate a starter config -flutterguard init --profile migration -flutterguard config doctor -flutterguard doctor install - -# JSON output with CI gate -flutterguard scan -p . --format json --fail-on high --min-score 80 - -# Baseline existing issues before enforcing CI -flutterguard baseline create . -flutterguard baseline stats -flutterguard baseline check . --baseline .flutterguard/baseline.json --no-growth -flutterguard scan . --baseline .flutterguard/baseline.json --fail-on high - -# GitHub Code Scanning output -flutterguard scan . --format sarif --baseline .flutterguard/baseline.json - -# Export one finding for feedback -flutterguard issue export --rule mqtt_connection --file lib/device/mqtt.dart --line 42 -``` - -When working from a source checkout, prefer the local launcher so you always -run the current files instead of an older global executable: - -```bash -./scripts/flutterguard-dev --version -./scripts/flutterguard-dev scan . -``` - -## Checks - -| Rule | Level | What it checks | -|------|-------|----------------| -| `large_file` | LOW | File line count | -| `large_class` | LOW | Class body line count | -| `large_build_method` | MEDIUM | `build()` method size | -| `lifecycle_resource_not_disposed` | MEDIUM | Undisposed `StreamSubscription`, `Timer`, `AnimationController`, `TextEditingController`, `ScrollController`, `FocusNode`, `MqttClient` (IoT), `BluetoothDevice` (IoT), `StreamController` | -| `layer_violation` | HIGH | Cross-layer architecture import violations | -| `module_violation` | HIGH | Cross-module architecture import violations | -| `circular_dependency` | MEDIUM | File-level import cycles | -| `missing_const_constructor` | LOW | Widget classes missing `const` constructor | -| `device_lifecycle` | HIGH | Unbalanced device init/teardown pairs | -| `mqtt_connection` | HIGH | MQTT connect/disconnect and broker URL checks | -| `iot_security` | HIGH | Hardcoded secrets, cleartext MQTT/HTTP, insecure BLE patterns | -| `ble_scanning` | MEDIUM | BLE scan/stop and timeout checks | -| `pubspec_security` | MEDIUM | Unbounded, deprecated, or outdated dependency checks | - -## Configuration - -Create `flutterguard.yaml` in your project root: - -Start without config, then run `flutterguard init` only when you need custom -thresholds, excludes, CI gates, or explicit architecture boundaries. Use -`flutterguard config print` to inspect merged defaults and -`flutterguard config doctor` before enabling CI gates. - -```yaml -rules: - large_file: - enabled: true - maxLines: 500 - lifecycle_resource: - enabled: true - -architecture: - layers: - - name: presentation - path: lib/presentation/** - allowed_deps: [domain, core] - - name: domain - path: lib/domain/** - allowed_deps: [core] - modules: - - name: device_mqtt - path: lib/device/mqtt/** - allowed_deps: [domain, core] - detect_cycles: true -``` - -If the default config is omitted, built-in defaults are used (all default rules enabled, no architecture constraints). Any path explicitly passed with `--config` must exist and relative paths resolve from the target project root. - -## CLI Reference - -``` -flutterguard scan [] [options] - -p, --path Project path (default: .) - -c, --config Config file (default: flutterguard.yaml) - -f, --format table | json | sarif (default: table) - -o, --output Output directory (default: .flutterguard) - -v, --verbose Show detailed context - --changed-only Only scan changed Dart files - --base Git base ref for changed-only mode - --baseline Baseline JSON file for existing issues - --fail-on CI gate: none | high | medium | low - --min-score Minimum score threshold 0-100 - -flutterguard baseline create [] [--output .flutterguard/baseline.json] -flutterguard baseline stats -flutterguard baseline prune [] [--dry-run] -flutterguard baseline check [] --no-growth -flutterguard doctor install -flutterguard init [--with-architecture] [--force] -flutterguard init [] [--profile recommended|migration|strict|iot-security|architecture-only|performance-only] -flutterguard config print -flutterguard config doctor -flutterguard issue export [--rule ] [--file ] [--line ] -flutterguard rules [--format table|json] -flutterguard explain -``` - -Exit codes: `0` success, `1` CI gate failed, `2` scan setup error. A full scan that matches no configured Dart files exits with `2`; a changed-only scan with no relevant changes exits with `0`. - -## Scoring - -``` -score = max(0, 100 - HIGH*10 - MEDIUM*4 - LOW*1) -``` - -| Score | Rating | -|-------|--------| -| 80-100 | Excellent | -| 50-79 | Needs review | -| 0-49 | Needs action | - -## CI Integration - -Recommended adoption order: - -```bash -flutterguard config doctor -flutterguard baseline create . -flutterguard scan . --baseline .flutterguard/baseline.json --format json --fail-on high -``` - -```yaml -# GitHub Actions -- uses: dart-lang/setup-dart@v1 - with: - sdk: 3.3.0 -- run: dart pub global activate flutterguard_cli -- run: flutterguard scan . --format json --baseline .flutterguard/baseline.json --fail-on high --min-score 80 -``` - -For GitHub Code Scanning: - -```yaml -- run: flutterguard scan . --format sarif --baseline .flutterguard/baseline.json -- uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: .flutterguard/report.sarif -``` - -Known false positives can be suppressed on the same line or the next line: - -```dart -// flutterguard: ignore missing_const_constructor -// flutterguard: ignore iot_security, mqtt_connection -// flutterguard: ignore all -``` - -## Requirements - -- Dart SDK >=3.3.0 -- Supported OS: macOS, Windows, Linux - -## Further Reading - -- [Full Usage Guide](https://github.com/lizy-coding/flutterguard/blob/develop/docs/USAGE.md) -- [Windows Compatibility](https://github.com/lizy-coding/flutterguard/blob/develop/docs/WINDOWS_ASSESSMENT.md) -- [Specification](https://github.com/lizy-coding/flutterguard/blob/develop/docs/FLUTTERGUARD_SPEC.md) - -## License - -MIT diff --git a/packages/flutterguard_cli/analysis_options.yaml b/packages/flutterguard_cli/analysis_options.yaml deleted file mode 100644 index d27b937..0000000 --- a/packages/flutterguard_cli/analysis_options.yaml +++ /dev/null @@ -1,5 +0,0 @@ -include: package:lints/recommended.yaml - -analyzer: - exclude: - - test/fixtures/** diff --git a/packages/flutterguard_cli/bin/AGENTS.md b/packages/flutterguard_cli/bin/AGENTS.md deleted file mode 100644 index 3f76672..0000000 --- a/packages/flutterguard_cli/bin/AGENTS.md +++ /dev/null @@ -1,27 +0,0 @@ -# CLI Entry Layer - -## Responsibility -`flutterguard.dart` is only the top-level command router. - -It should: -- Use the parser tree from `lib/src/cli/cli_parsers.dart`. -- Support positional path: `flutterguard scan ./my_project` (no `-p` required). -- Print help/version text. -- Convert validation or scan errors into documented exit codes. -- Delegate functional command behavior to `lib/src/cli/`. - -It should not: -- Implement rules. -- Read Dart files directly. -- Generate reports beyond calling `ReportGenerator`. -- Duplicate scan orchestration already in `lib/src/scanner.dart`. - -## Exit Codes -- `0`: success or help/version output -- `1`: CI gate failed -- `2`: bad input, bad config, or scan setup error - -## Required Checks -After changing this layer, run: -- `dart run melos run analyze` -- `dart run melos run test:cli` diff --git a/packages/flutterguard_cli/bin/flutterguard.dart b/packages/flutterguard_cli/bin/flutterguard.dart deleted file mode 100644 index ad79380..0000000 --- a/packages/flutterguard_cli/bin/flutterguard.dart +++ /dev/null @@ -1,523 +0,0 @@ -import 'dart:io'; - -import 'package:args/args.dart'; - -import 'package:flutterguard_cli/src/cli/scan_command.dart'; -import 'package:flutterguard_cli/src/cli/baseline_commands.dart'; -import 'package:flutterguard_cli/src/cli/cli_parsers.dart'; -import 'package:flutterguard_cli/src/cli/config_commands.dart'; -import 'package:flutterguard_cli/src/cli/issue_commands.dart'; -import 'package:flutterguard_cli/src/cli/rule_commands.dart'; - -const _version = '0.4.1'; - -void main(List args) { - final normalizedArgs = _extractPositionalPath(args); - - final parsers = CliParsers(); - final scanParser = parsers.scan; - final baselineCreateParser = parsers.baselineCreate; - final baselineStatsParser = parsers.baselineStats; - final baselinePruneParser = parsers.baselinePrune; - final baselineCheckParser = parsers.baselineCheck; - final baselineParser = parsers.baseline; - final initParser = parsers.init; - final installDoctorParser = parsers.installDoctor; - final doctorParser = parsers.doctor; - final issueExportParser = parsers.issueExport; - final issueParser = parsers.issue; - final configPrintParser = parsers.configPrint; - final configDoctorParser = parsers.configDoctor; - final configParser = parsers.config; - final rulesParser = parsers.rules; - final explainParser = parsers.explain; - final parser = parsers.root; - - try { - final results = parser.parse(normalizedArgs); - - if (results['version'] == true) { - stdout.writeln('flutterguard $_version'); - exit(0); - } - - if (results['help'] == true || normalizedArgs.isEmpty) { - _printUsage(parser); - exit(0); - } - - final command = results.command; - if (command == null) { - _printUsage(parser); - exit(0); - } - - if (command.name == 'scan') { - if (command['help'] == true) { - _printScanUsage(scanParser); - exit(0); - } - _handleScan(command); - } else if (command.name == 'baseline') { - _handleBaseline( - command, - baselineParser, - baselineCreateParser, - baselineStatsParser, - baselinePruneParser, - baselineCheckParser, - ); - } else if (command.name == 'doctor') { - _handleDoctor(command, doctorParser, installDoctorParser); - } else if (command.name == 'init') { - if (command['help'] == true) { - _printInitUsage(initParser); - exit(0); - } - _handleInit(command); - } else if (command.name == 'config') { - _handleConfig( - command, configParser, configPrintParser, configDoctorParser); - } else if (command.name == 'issue') { - _handleIssue(command, issueParser, issueExportParser); - } else if (command.name == 'rules') { - if (command['help'] == true) { - _printRulesUsage(rulesParser); - exit(0); - } - _handleRules(command); - } else if (command.name == 'explain') { - if (command['help'] == true) { - _printExplainUsage(explainParser); - exit(0); - } - _handleExplain(command); - } else { - _printUsage(parser); - exit(0); - } - } on FormatException catch (e) { - stderr.writeln('Error: ${e.message}'); - stderr.writeln(); - _printUsage(parser); - exit(2); - } -} - -void _handleInit(ArgResults args) { - ConfigCommands.init(args); -} - -void _handleConfig( - ArgResults command, - ArgParser configParser, - ArgParser configPrintParser, - ArgParser configDoctorParser, -) { - if (command['help'] == true || command.command == null) { - _printConfigUsage(configParser); - exit(0); - } - - final subcommand = command.command!; - if (subcommand.name == 'print') { - if (subcommand['help'] == true) { - _printConfigPrintUsage(configPrintParser); - exit(0); - } - _handleConfigPrint(subcommand); - return; - } - if (subcommand.name == 'doctor') { - if (subcommand['help'] == true) { - _printConfigDoctorUsage(configDoctorParser); - exit(0); - } - _handleConfigDoctor(subcommand); - return; - } - - _printConfigUsage(configParser); - exit(0); -} - -void _handleConfigPrint(ArgResults args) { - ConfigCommands.printEffective( - args, - configPath: _explicitConfigPath(args), - ); -} - -void _handleConfigDoctor(ArgResults args) { - ConfigCommands.doctor(args, configPath: _explicitConfigPath(args)); -} - -List _extractPositionalPath(List args) { - if (args.isEmpty) return args; - - final scanIndex = args.indexOf('scan'); - if (scanIndex == -1) return args; - - final positionalStart = scanIndex + 1; - if (positionalStart >= args.length) return args; - - final candidate = args[positionalStart]; - if (candidate.startsWith('-')) return args; - - final positionalParts = [candidate]; - var i = positionalStart + 1; - while (i < args.length && !args[i].startsWith('-')) { - positionalParts.add(args[i]); - i++; - } - - final before = args.sublist(0, positionalStart); - final after = args.sublist(positionalStart + positionalParts.length); - final result = [...before, '-p', ...positionalParts, ...after]; - return result; -} - -void _handleScan(ArgResults args) { - ScanCommand.run(args, configPath: _explicitConfigPath(args)); -} - -void _handleBaseline( - ArgResults command, - ArgParser baselineParser, - ArgParser baselineCreateParser, - ArgParser baselineStatsParser, - ArgParser baselinePruneParser, - ArgParser baselineCheckParser, -) { - if (command['help'] == true || command.command == null) { - _printBaselineUsage(baselineParser); - exit(0); - } - - final subcommand = command.command!; - if (subcommand.name == 'create') { - if (subcommand['help'] == true) { - _printBaselineCreateUsage(baselineCreateParser); - exit(0); - } - _handleBaselineCreate(subcommand); - return; - } - if (subcommand.name == 'stats') { - if (subcommand['help'] == true) { - _printBaselineStatsUsage(baselineStatsParser); - exit(0); - } - _handleBaselineStats(subcommand); - return; - } - if (subcommand.name == 'prune') { - if (subcommand['help'] == true) { - _printBaselinePruneUsage(baselinePruneParser); - exit(0); - } - _handleBaselinePrune(subcommand); - return; - } - if (subcommand.name == 'check') { - if (subcommand['help'] == true) { - _printBaselineCheckUsage(baselineCheckParser); - exit(0); - } - _handleBaselineCheck(subcommand); - return; - } - - _printBaselineUsage(baselineParser); - exit(0); -} - -void _handleBaselineCreate(ArgResults args) { - BaselineCommands.create(args, configPath: _explicitConfigPath(args)); -} - -void _handleBaselineStats(ArgResults args) { - BaselineCommands.stats(args); -} - -void _handleBaselinePrune(ArgResults args) { - BaselineCommands.prune(args, configPath: _explicitConfigPath(args)); -} - -void _handleBaselineCheck(ArgResults args) { - BaselineCommands.check(args, configPath: _explicitConfigPath(args)); -} - -void _handleDoctor( - ArgResults command, - ArgParser doctorParser, - ArgParser installDoctorParser, -) { - if (command['help'] == true || command.command == null) { - _printDoctorUsage(doctorParser); - exit(0); - } - final subcommand = command.command!; - if (subcommand.name == 'install') { - if (subcommand['help'] == true) { - _printInstallDoctorUsage(installDoctorParser); - exit(0); - } - ConfigCommands.installDoctor(version: _version); - return; - } - _printDoctorUsage(doctorParser); - exit(0); -} - -void _handleIssue( - ArgResults command, - ArgParser issueParser, - ArgParser issueExportParser, -) { - if (command['help'] == true || command.command == null) { - _printIssueUsage(issueParser); - exit(0); - } - final subcommand = command.command!; - if (subcommand.name != 'export') { - _printIssueUsage(issueParser); - exit(0); - } - if (subcommand['help'] == true) { - _printIssueExportUsage(issueExportParser); - exit(0); - } - IssueCommands.export( - subcommand, - configPath: _explicitConfigPath(subcommand), - ); -} - -String? _explicitConfigPath(ArgResults args) { - return args.wasParsed('config') ? args['config'] as String : null; -} - -void _handleRules(ArgResults args) { - RuleCommands.list(args); -} - -void _handleExplain(ArgResults args) { - RuleCommands.explain(args); -} - -void _printUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — IoT Flutter architecture static analysis CLI'); - stdout.writeln( - 'No API key is required. This CLI scans local source code only.'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard [options]'); - stdout.writeln(); - stdout.writeln('Commands:'); - stdout.writeln( - ' scan [] Scan a Flutter project for architecture issues'); - stdout.writeln(' baseline Create a baseline for existing issues'); - stdout.writeln(' doctor Diagnose installation and environment'); - stdout.writeln(' init Create a starter flutterguard.yaml'); - stdout.writeln(' config Print or validate effective configuration'); - stdout.writeln(' issue Export issue feedback bundles'); - stdout.writeln(' rules List available rules'); - stdout.writeln(' explain Explain a rule by ID'); - stdout.writeln(); - stdout.writeln('Global Options:'); - stdout.writeln(' -h, --help Show this help message'); - stdout.writeln(' -V, --version Show version'); - stdout.writeln(); - stdout.writeln('Examples:'); - stdout.writeln( - ' flutterguard scan # Scan current directory'); - stdout - .writeln(' flutterguard init # Create basic config'); - stdout.writeln( - ' flutterguard config doctor # Validate config and globs'); - stdout.writeln( - ' flutterguard scan ./my_flutter_app # Scan specific project'); - stdout.writeln(' flutterguard scan -p /path/to/app # Explicit path flag'); - stdout.writeln(' flutterguard scan . --format json --fail-on high'); - stdout.writeln(' flutterguard baseline create .'); - stdout - .writeln(' flutterguard scan . --baseline .flutterguard/baseline.json'); - stdout.writeln(' flutterguard doctor install'); - stdout.writeln(' flutterguard issue export --rule mqtt_connection'); - stdout.writeln(); - stdout.writeln('Configuration strategy:'); - stdout.writeln( - ' Start with zero config, add flutterguard.yaml only for thresholds, excludes,'); - stdout.writeln(' CI gates, or explicit architecture layers/modules.'); -} - -void _printScanUsage(ArgParser scanParser) { - stdout.writeln('FlutterGuard — scan command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard scan [] [options]'); - stdout.writeln(); - stdout.writeln( - ' Project path to scan (default: current directory)'); - stdout.writeln(); - stdout.writeln(scanParser.usage); - stdout.writeln(); - stdout.writeln('Configuration strategy:'); - stdout.writeln(' 1. Zero config: run scan with built-in defaults.'); - stdout - .writeln(' 2. Basic config: tune include/exclude and rule thresholds.'); - stdout.writeln( - ' 3. Baseline: run baseline create . before gating legacy projects.'); - stdout.writeln( - ' 4. CI config: add --format json --baseline .flutterguard/baseline.json --fail-on high.'); - stdout.writeln( - ' 5. Changed-only: add --changed-only --base main to scan git changes.'); - stdout.writeln( - ' 6. Architecture config: declare layers/modules explicitly before enabling boundary gates.'); - stdout.writeln(); - stdout.writeln('Docs: README.md and CONFIGURATION_STRATEGY.md'); -} - -void _printBaselineUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — baseline command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard baseline [options]'); - stdout.writeln(); - stdout.writeln('Commands:'); - stdout.writeln(' create [] Create a baseline for existing issues'); - stdout.writeln(' stats Show baseline fingerprint counts'); - stdout.writeln(' prune [] Remove fixed issues from a baseline'); - stdout.writeln(' check [] Check current issues against a baseline'); - stdout.writeln(); - stdout.writeln(parser.usage); -} - -void _printBaselineCreateUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — baseline create command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard baseline create [] [options]'); - stdout.writeln(); - stdout.writeln(parser.usage); -} - -void _printBaselineStatsUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — baseline stats command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard baseline stats [options]'); - stdout.writeln(); - stdout.writeln(parser.usage); -} - -void _printBaselinePruneUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — baseline prune command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard baseline prune [] [options]'); - stdout.writeln(); - stdout.writeln(parser.usage); -} - -void _printBaselineCheckUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — baseline check command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard baseline check [] [options]'); - stdout.writeln(); - stdout.writeln(parser.usage); -} - -void _printDoctorUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — doctor command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard doctor [options]'); - stdout.writeln(); - stdout.writeln('Commands:'); - stdout.writeln(' install Diagnose installed executable and PATH'); - stdout.writeln(); - stdout.writeln(parser.usage); -} - -void _printInstallDoctorUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — doctor install command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard doctor install [options]'); - stdout.writeln(); - stdout.writeln(parser.usage); -} - -void _printIssueUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — issue command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard issue [options]'); - stdout.writeln(); - stdout.writeln('Commands:'); - stdout.writeln(' export Export a local feedback bundle for one issue'); - stdout.writeln(); - stdout.writeln(parser.usage); -} - -void _printIssueExportUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — issue export command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard issue export [options]'); - stdout.writeln(); - stdout.writeln(parser.usage); -} - -void _printRulesUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — rules command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard rules [options]'); - stdout.writeln(); - stdout.writeln('List all available rules.'); - stdout.writeln(parser.usage); -} - -void _printExplainUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — explain command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard explain '); - stdout.writeln(); - stdout.writeln('Show detailed explanation for a rule.'); - stdout.writeln(parser.usage); -} - -void _printInitUsage(ArgParser initParser) { - stdout.writeln('FlutterGuard — init command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard init [] [options]'); - stdout.writeln(); - stdout.writeln(initParser.usage); - stdout.writeln(); - stdout.writeln('Examples:'); - stdout.writeln(' flutterguard init'); - stdout.writeln(' flutterguard init --profile migration'); - stdout.writeln(' flutterguard init --with-architecture'); - stdout.writeln(' flutterguard init -p ./my_flutter_app --force'); -} - -void _printConfigUsage(ArgParser configParser) { - stdout.writeln('FlutterGuard — config command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard config [options]'); - stdout.writeln(); - stdout.writeln('Commands:'); - stdout.writeln(' print Show merged effective config'); - stdout.writeln( - ' doctor Validate config, globs, and architecture references'); - stdout.writeln(); - stdout.writeln(configParser.usage); -} - -void _printConfigPrintUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — config print command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard config print [options]'); - stdout.writeln(); - stdout.writeln(parser.usage); -} - -void _printConfigDoctorUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — config doctor command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard config doctor [options]'); - stdout.writeln(); - stdout.writeln(parser.usage); -} diff --git a/packages/flutterguard_cli/lib/AGENTS.md b/packages/flutterguard_cli/lib/AGENTS.md deleted file mode 100644 index 1bf9942..0000000 --- a/packages/flutterguard_cli/lib/AGENTS.md +++ /dev/null @@ -1,12 +0,0 @@ -# Public Library Layer - -## Responsibility -`lib/flutterguard_cli.dart` is the public barrel for tests and programmatic consumers. - -Export reusable models, config, scanner, report generation, rules, and shared utilities from here when they are stable enough for internal reuse. - -## Rules -- Keep implementation in `lib/src/`. -- Do not put business logic directly in the barrel file. -- Avoid exporting experimental helpers unless tests or downstream usage need them. -- Preserve the static-analysis CLI identity; do not add runtime SDK APIs here. diff --git a/packages/flutterguard_cli/lib/flutterguard_cli.dart b/packages/flutterguard_cli/lib/flutterguard_cli.dart deleted file mode 100644 index e5c800d..0000000 --- a/packages/flutterguard_cli/lib/flutterguard_cli.dart +++ /dev/null @@ -1,27 +0,0 @@ -export 'src/config_loader.dart'; -export 'src/domain.dart'; -export 'src/file_collector.dart'; -export 'src/import_utils.dart'; -export 'src/path_utils.dart'; -export 'src/priority.dart'; -export 'src/project_resolver.dart'; -export 'src/report_generator.dart'; -export 'src/rule_meta.dart'; -export 'src/scan_context.dart'; -export 'src/rules/ble_scanning.dart'; -export 'src/rules/catalog.dart'; -export 'src/rules/circular_dependency.dart'; -export 'src/rules/device_lifecycle.dart'; -export 'src/rules/iot_security.dart'; -export 'src/rules/large_units.dart'; -export 'src/rules/layer_violation.dart'; -export 'src/rules/lifecycle_resource.dart'; -export 'src/rules/missing_const_constructor.dart'; -export 'src/rules/module_violation.dart'; -export 'src/rules/mqtt_connection.dart'; -export 'src/rules/pubspec_security.dart'; -export 'src/rules/registry.dart'; -export 'src/scanner.dart'; -export 'src/source_utils.dart'; -export 'src/source_workspace.dart'; -export 'src/static_issue.dart'; diff --git a/packages/flutterguard_cli/lib/src/AGENTS.md b/packages/flutterguard_cli/lib/src/AGENTS.md deleted file mode 100644 index b8c4e9e..0000000 --- a/packages/flutterguard_cli/lib/src/AGENTS.md +++ /dev/null @@ -1,29 +0,0 @@ -# Internal Implementation Layer - -## Responsibility -This directory contains reusable implementation for the CLI. - -## Main Files -- `scanner.dart`: global scan orchestration and `ScanResult`. -- `scan_context.dart`: project/all/target file scope and scan mode. -- `source_workspace.dart`: shared source text/AST/line cache and diagnostics. -- `import_graph.dart`: resolved import graph shared by architecture rules. -- `boundary_engine.dart`: common layer/module dependency enforcement. -- `rules/catalog.dart`: explicit rule metadata and execution wiring. -- `config_loader.dart`: YAML parsing into typed record configs (11 rule configs + architecture). -- `file_collector.dart`: include/exclude glob file discovery. -- `project_resolver.dart`: project auto-discovery (walk-up flutterguard.yaml / pubspec.yaml / lib/). -- `report_generator.dart`: table and JSON output with optional `--no-color` support. -- `static_issue.dart`: issue data model. -- `path_utils.dart`: cross-platform path/glob helpers. -- `import_utils.dart`: Dart import resolution against collected files. -- `source_utils.dart`: analyzer source location helpers. -- `rules/`: rule implementations only (11 rule classes, 13 rule IDs). - -## Design Rules -- Keep `bin/` thin; reusable logic belongs here. -- Prefer typed records and small helper classes over dynamic maps. -- Convert analyzer offsets to line numbers before storing `StaticIssue.line`. -- Keep Windows path behavior covered by tests when touching path/import logic. -- Do not depend on Flutter; this package is a Dart CLI. -- pubspec_security handles project-root YAML parsing (uses `package:yaml` directly). diff --git a/packages/flutterguard_cli/lib/src/cli/baseline_commands.dart b/packages/flutterguard_cli/lib/src/cli/baseline_commands.dart deleted file mode 100644 index 7cf8eb1..0000000 --- a/packages/flutterguard_cli/lib/src/cli/baseline_commands.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:args/args.dart'; -import 'package:path/path.dart' as p; - -import '../baseline.dart'; -import '../scanner.dart'; - -class BaselineCommands { - static void create(ArgResults args, {String? configPath}) { - final projectPath = - args.rest.isNotEmpty ? args.rest.first : args['path'] as String; - final output = args['output'] as String; - - try { - final result = FlutterGuardScanner.scan( - projectPath: projectPath, - configPath: configPath, - applySuppression: false, - ); - final outputPath = - p.isAbsolute(output) ? output : p.join(result.projectPath, output); - Directory(p.dirname(outputPath)).createSync(recursive: true); - File(outputPath).writeAsStringSync(Baseline.encode( - projectPath: result.projectPath, - issues: result.rawIssues, - )); - stdout.writeln( - 'Created FlutterGuard baseline: $outputPath ' - '(${result.rawIssues.length} issues)', - ); - } on ScanException catch (error) { - _fail(error.message); - } on FormatException catch (error) { - _fail(error.message); - } - } - - static void stats(ArgResults args) { - try { - final stats = Baseline.stats(args['baseline'] as String); - stdout.writeln(const JsonEncoder.withIndent(' ').convert(stats)); - } on FormatException catch (error) { - _fail(error.message); - } - } - - static void prune(ArgResults args, {String? configPath}) { - final projectPath = - args.rest.isNotEmpty ? args.rest.first : args['path'] as String; - final baselinePath = args['baseline'] as String; - try { - final result = FlutterGuardScanner.scan( - projectPath: projectPath, - configPath: configPath, - applySuppression: false, - ); - final resolvedBaselinePath = _resolve(result.projectPath, baselinePath); - final baseline = Baseline.load(resolvedBaselinePath); - final pruned = Baseline.prune( - projectPath: result.projectPath, - baseline: baseline, - issues: result.rawIssues, - ); - final prunedBaseline = Baseline.loadFromString(pruned); - final removed = - baseline.fingerprints.length - prunedBaseline.fingerprints.length; - if (args['dry-run'] == true) { - stdout.writeln( - 'Baseline prune dry-run: ${baseline.fingerprints.length} -> ' - '${prunedBaseline.fingerprints.length} fingerprints ' - '($removed removed)', - ); - return; - } - File(resolvedBaselinePath).writeAsStringSync(pruned); - stdout.writeln( - 'Pruned baseline: ${baseline.fingerprints.length} -> ' - '${prunedBaseline.fingerprints.length} fingerprints ($removed removed)', - ); - } on ScanException catch (error) { - _fail(error.message); - } on FormatException catch (error) { - _fail(error.message); - } - } - - static void check(ArgResults args, {String? configPath}) { - final projectPath = - args.rest.isNotEmpty ? args.rest.first : args['path'] as String; - try { - final result = FlutterGuardScanner.scan( - projectPath: projectPath, - configPath: configPath, - applySuppression: false, - ); - final baseline = Baseline.load(_resolve( - result.projectPath, - args['baseline'] as String, - )); - final newFingerprints = Baseline.newFingerprints( - projectPath: result.projectPath, - baseline: baseline, - issues: result.rawIssues, - ); - stdout.writeln( - 'Baseline check: ${newFingerprints.length} issue(s) missing from baseline.', - ); - for (final fingerprint in newFingerprints.take(20)) { - stdout.writeln(' $fingerprint'); - } - if (args['no-growth'] == true && newFingerprints.isNotEmpty) { - exit(1); - } - } on ScanException catch (error) { - _fail(error.message); - } on FormatException catch (error) { - _fail(error.message); - } - } - - static String _resolve(String projectPath, String filePath) => - p.isAbsolute(filePath) ? filePath : p.join(projectPath, filePath); - - static Never _fail(String message) { - stderr.writeln('Error: $message'); - exit(2); - } -} diff --git a/packages/flutterguard_cli/lib/src/cli/cli_parsers.dart b/packages/flutterguard_cli/lib/src/cli/cli_parsers.dart deleted file mode 100644 index 7417ed1..0000000 --- a/packages/flutterguard_cli/lib/src/cli/cli_parsers.dart +++ /dev/null @@ -1,202 +0,0 @@ -import 'package:args/args.dart'; - -import '../config_tools.dart'; - -class CliParsers { - late final ArgParser scan; - late final ArgParser baselineCreate; - late final ArgParser baselineStats; - late final ArgParser baselinePrune; - late final ArgParser baselineCheck; - late final ArgParser baseline; - late final ArgParser init; - late final ArgParser installDoctor; - late final ArgParser doctor; - late final ArgParser issueExport; - late final ArgParser issue; - late final ArgParser configPrint; - late final ArgParser configDoctor; - late final ArgParser config; - late final ArgParser rules; - late final ArgParser explain; - late final ArgParser root; - - CliParsers() { - scan = ArgParser(allowTrailingOptions: false) - ..addOption('path', - abbr: 'p', defaultsTo: '.', help: 'Project path to scan') - ..addOption('config', - abbr: 'c', - defaultsTo: 'flutterguard.yaml', - help: 'Path to flutterguard.yaml config file') - ..addOption('format', - abbr: 'f', - defaultsTo: 'table', - allowed: ['table', 'json', 'sarif'], - help: 'Output format') - ..addOption('output', - abbr: 'o', - defaultsTo: '.flutterguard', - help: 'Output directory for reports') - ..addFlag('verbose', - abbr: 'v', - help: 'Show detailed output with code context', - negatable: false) - ..addFlag('no-color', - help: 'Disable ANSI terminal colors', negatable: false) - ..addFlag('changed-only', - help: 'Only scan files changed since --base', negatable: false) - ..addOption('base', - defaultsTo: 'main', help: 'Git base branch/ref for changed-only mode') - ..addOption('fail-on', - defaultsTo: 'none', - allowed: ['none', 'high', 'medium', 'low'], - help: 'Fail threshold for CI gate') - ..addOption('min-score', help: 'Minimum score threshold (0-100)') - ..addOption('baseline', - help: 'Baseline JSON file used to hide existing issues') - ..addFlag('help', abbr: 'h', help: 'Show scan usage', negatable: false); - - baselineCreate = ArgParser(allowTrailingOptions: false) - ..addOption('path', - abbr: 'p', defaultsTo: '.', help: 'Project path to scan') - ..addOption('config', - abbr: 'c', - defaultsTo: 'flutterguard.yaml', - help: 'Path to flutterguard.yaml config file') - ..addOption('output', - abbr: 'o', - defaultsTo: '.flutterguard/baseline.json', - help: 'Baseline file to create') - ..addFlag('help', - abbr: 'h', help: 'Show baseline create usage', negatable: false); - baselineStats = ArgParser() - ..addOption('baseline', - abbr: 'b', - defaultsTo: '.flutterguard/baseline.json', - help: 'Baseline file to inspect') - ..addFlag('help', - abbr: 'h', help: 'Show baseline stats usage', negatable: false); - baselinePrune = ArgParser(allowTrailingOptions: false) - ..addOption('path', - abbr: 'p', defaultsTo: '.', help: 'Project path to scan') - ..addOption('config', - abbr: 'c', - defaultsTo: 'flutterguard.yaml', - help: 'Path to flutterguard.yaml config file') - ..addOption('baseline', - abbr: 'b', - defaultsTo: '.flutterguard/baseline.json', - help: 'Baseline file to prune') - ..addFlag('dry-run', - help: 'Print prune result without updating the baseline file', - negatable: false) - ..addFlag('help', - abbr: 'h', help: 'Show baseline prune usage', negatable: false); - baselineCheck = ArgParser(allowTrailingOptions: false) - ..addOption('path', - abbr: 'p', defaultsTo: '.', help: 'Project path to scan') - ..addOption('config', - abbr: 'c', - defaultsTo: 'flutterguard.yaml', - help: 'Path to flutterguard.yaml config file') - ..addOption('baseline', - abbr: 'b', - defaultsTo: '.flutterguard/baseline.json', - help: 'Baseline file to compare against') - ..addFlag('no-growth', - help: 'Fail if current scan has issues missing from baseline', - negatable: false) - ..addFlag('help', - abbr: 'h', help: 'Show baseline check usage', negatable: false); - baseline = ArgParser() - ..addCommand('create', baselineCreate) - ..addCommand('stats', baselineStats) - ..addCommand('prune', baselinePrune) - ..addCommand('check', baselineCheck) - ..addFlag('help', - abbr: 'h', help: 'Show baseline usage', negatable: false); - - init = ArgParser() - ..addOption('path', - abbr: 'p', - defaultsTo: '.', - help: 'Project path for flutterguard.yaml') - ..addOption('config', - abbr: 'c', - defaultsTo: 'flutterguard.yaml', - help: 'Config file path to create') - ..addFlag('with-architecture', - help: 'Include architecture layer/module template', negatable: false) - ..addOption('profile', - defaultsTo: 'recommended', - allowed: ConfigTools.profiles.toList()..sort(), - help: 'Starter config profile') - ..addFlag('force', - help: 'Overwrite an existing config file', negatable: false) - ..addFlag('help', abbr: 'h', help: 'Show init usage', negatable: false); - - installDoctor = ArgParser() - ..addFlag('help', - abbr: 'h', help: 'Show install doctor usage', negatable: false); - doctor = ArgParser() - ..addCommand('install', installDoctor) - ..addFlag('help', abbr: 'h', help: 'Show doctor usage', negatable: false); - - issueExport = ArgParser(allowTrailingOptions: false) - ..addOption('path', - abbr: 'p', defaultsTo: '.', help: 'Project path to scan') - ..addOption('config', - abbr: 'c', - defaultsTo: 'flutterguard.yaml', - help: 'Path to flutterguard.yaml config file') - ..addOption('rule', help: 'Rule ID to export') - ..addOption('file', help: 'Project-relative or absolute file path') - ..addOption('line', help: 'Issue line number') - ..addOption('context', - defaultsTo: '2', help: 'Number of context lines around issue') - ..addOption('output', abbr: 'o', help: 'Output file. Defaults to stdout') - ..addFlag('help', - abbr: 'h', help: 'Show issue export usage', negatable: false); - issue = ArgParser() - ..addCommand('export', issueExport) - ..addFlag('help', abbr: 'h', help: 'Show issue usage', negatable: false); - - configPrint = _configLeafParser(); - configDoctor = _configLeafParser(); - config = ArgParser() - ..addCommand('print', configPrint) - ..addCommand('doctor', configDoctor) - ..addFlag('help', abbr: 'h', help: 'Show config usage', negatable: false); - rules = ArgParser() - ..addOption('format', - abbr: 'f', - defaultsTo: 'table', - allowed: ['table', 'json'], - help: 'Output format') - ..addFlag('help', abbr: 'h', help: 'Show rules usage', negatable: false); - explain = ArgParser() - ..addFlag('help', - abbr: 'h', help: 'Show explain usage', negatable: false); - - root = ArgParser() - ..addCommand('scan', scan) - ..addCommand('baseline', baseline) - ..addCommand('doctor', doctor) - ..addCommand('init', init) - ..addCommand('config', config) - ..addCommand('issue', issue) - ..addCommand('rules', rules) - ..addCommand('explain', explain) - ..addFlag('help', abbr: 'h', help: 'Show usage', negatable: false) - ..addFlag('version', abbr: 'V', help: 'Show version', negatable: false); - } - - ArgParser _configLeafParser() => ArgParser() - ..addOption('path', - abbr: 'p', defaultsTo: '.', help: 'Project path for config resolution') - ..addOption('config', - abbr: 'c', defaultsTo: 'flutterguard.yaml', help: 'Config file path') - ..addFlag('help', - abbr: 'h', help: 'Show config command usage', negatable: false); -} diff --git a/packages/flutterguard_cli/lib/src/cli/config_commands.dart b/packages/flutterguard_cli/lib/src/cli/config_commands.dart deleted file mode 100644 index b309e36..0000000 --- a/packages/flutterguard_cli/lib/src/cli/config_commands.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'dart:io'; - -import 'package:args/args.dart'; - -import '../config_loader.dart'; -import '../config_tools.dart'; -import '../install_doctor.dart'; -import '../project_resolver.dart'; - -class ConfigCommands { - static void init(ArgResults args) { - try { - final projectPath = - args.rest.isNotEmpty ? args.rest.first : args['path'] as String; - final outputPath = ConfigTools.writeInitConfig( - projectPath: projectPath, - configPath: args['config'] as String, - withArchitecture: args['with-architecture'] as bool, - force: args['force'] as bool, - profile: args['profile'] as String, - ); - stdout.writeln('Created FlutterGuard config: $outputPath'); - stdout.writeln('Next: flutterguard config doctor -p $projectPath'); - } on StateError catch (error) { - _fail(error.message); - } - } - - static void printEffective(ArgResults args, {String? configPath}) { - try { - final projectPath = ProjectResolver.resolveProjectPath( - args['path'] as String, - ); - final resolvedConfigPath = ConfigTools.resolveConfigPathForProject( - projectPath: projectPath, - configPath: configPath, - ); - final config = ScanConfig.fromFile( - resolvedConfigPath, - requireFile: configPath != null, - ); - stdout.write(ConfigTools.effectiveYaml(config)); - } on FormatException catch (error) { - _fail(error.message); - } - } - - static void doctor(ArgResults args, {String? configPath}) { - try { - final result = ConfigTools.doctor( - projectPath: args['path'] as String, - configPath: configPath, - ); - stdout.write(ConfigTools.formatDoctorResult(result)); - if (result.hasErrors) exit(1); - } on StateError catch (error) { - _fail(error.message); - } on FormatException catch (error) { - _fail(error.message); - } - } - - static void installDoctor({required String version}) { - stdout.write(InstallDoctor.generate(version: version)); - } - - static Never _fail(String message) { - stderr.writeln('Error: $message'); - exit(2); - } -} diff --git a/packages/flutterguard_cli/lib/src/cli/issue_commands.dart b/packages/flutterguard_cli/lib/src/cli/issue_commands.dart deleted file mode 100644 index 860f6db..0000000 --- a/packages/flutterguard_cli/lib/src/cli/issue_commands.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'dart:io'; - -import 'package:args/args.dart'; -import 'package:path/path.dart' as p; - -import '../issue_export.dart'; -import '../scanner.dart'; - -class IssueCommands { - static void export(ArgResults args, {String? configPath}) { - try { - final contextLines = int.tryParse(args['context'] as String); - if (contextLines == null || contextLines < 0) { - throw const FormatException( - 'Expected --context to be a non-negative integer.', - ); - } - final lineRaw = args['line'] as String?; - final line = lineRaw == null ? null : int.tryParse(lineRaw); - if (lineRaw != null && (line == null || line < 1)) { - throw const FormatException( - 'Expected --line to be a positive integer.', - ); - } - - final result = FlutterGuardScanner.scan( - projectPath: args['path'] as String, - configPath: configPath, - applySuppression: false, - ); - final exported = IssueExporter.export( - projectPath: result.projectPath, - issues: result.rawIssues, - ruleId: args['rule'] as String?, - filePath: args['file'] as String?, - line: line, - contextLines: contextLines, - workspace: result.sources, - ); - final output = args['output'] as String?; - if (output == null) { - stdout.writeln(exported); - return; - } - final outputPath = - p.isAbsolute(output) ? output : p.join(result.projectPath, output); - File(outputPath).parent.createSync(recursive: true); - File(outputPath).writeAsStringSync(exported); - stdout.writeln('Exported issue feedback bundle: $outputPath'); - } on ScanException catch (error) { - _fail(error.message); - } on FormatException catch (error) { - _fail(error.message); - } - } - - static Never _fail(String message) { - stderr.writeln('Error: $message'); - exit(2); - } -} diff --git a/packages/flutterguard_cli/lib/src/cli/rule_commands.dart b/packages/flutterguard_cli/lib/src/cli/rule_commands.dart deleted file mode 100644 index 482e6e9..0000000 --- a/packages/flutterguard_cli/lib/src/cli/rule_commands.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:args/args.dart'; - -import '../rules/registry.dart'; - -class RuleCommands { - static void list(ArgResults args) { - final all = RuleRegistry.all(); - if (args['format'] == 'json') { - stdout.writeln(const JsonEncoder.withIndent(' ').convert( - all.map((meta) => meta.toJson()).toList(), - )); - return; - } - - all.sort((a, b) => a.id.compareTo(b.id)); - stdout.writeln('可用规则 (${all.length}):'); - stdout.writeln(); - for (final rule in all) { - stdout.writeln( - ' ${rule.id.padRight(32)} ${rule.domain.padRight(14)} ${rule.name}', - ); - } - stdout.writeln(); - stdout.writeln('执行 flutterguard explain 查看详情'); - } - - static void explain(ArgResults args) { - final rest = args.rest; - if (rest.isEmpty) { - stderr.writeln('Error: 请指定规则 ID'); - stderr.writeln('用法: flutterguard explain '); - stderr.writeln('可用规则: ${_ruleIds()}'); - exit(2); - } - - final ruleId = rest.first; - final meta = RuleRegistry.find(ruleId); - if (meta == null) { - stderr.writeln('Error: 未找到规则 "$ruleId"'); - stderr.writeln('可用规则: ${_ruleIds()}'); - exit(2); - } - - stdout.writeln('规则: ${meta.id}'); - stdout.writeln('名称: ${meta.name}'); - stdout.writeln('领域: ${meta.domain}'); - stdout.writeln('风险: ${meta.riskLevel}'); - stdout.writeln('优先级: ${meta.priority}'); - stdout.writeln('CI 阻断: ${meta.cicdSafe ? "是" : "否"}'); - stdout.writeln(); - stdout.writeln('检测目的:'); - stdout.writeln(' ${meta.purpose}'); - stdout.writeln(); - stdout.writeln('风险原因:'); - stdout.writeln(' ${meta.riskReason}'); - stdout.writeln(); - stdout.writeln('典型坏例子:'); - stdout.writeln(' ${meta.badExample}'); - stdout.writeln(); - stdout.writeln('推荐修复:'); - stdout.writeln(' ${meta.fixSuggestion}'); - if (meta.configKeys.isNotEmpty) { - stdout.writeln(); - stdout.writeln('配置项:'); - for (final key in meta.configKeys) { - stdout.writeln(' - $key'); - } - } - } - - static String _ruleIds() => - RuleRegistry.all().map((meta) => meta.id).join(', '); -} diff --git a/packages/flutterguard_cli/lib/src/config_loader.dart b/packages/flutterguard_cli/lib/src/config_loader.dart deleted file mode 100644 index 3d35fd1..0000000 --- a/packages/flutterguard_cli/lib/src/config_loader.dart +++ /dev/null @@ -1,347 +0,0 @@ -// ignore_for_file: avoid_dynamic_calls - -import 'dart:io'; - -import 'package:yaml/yaml.dart'; - -typedef LayerConfig = ({ - String name, - String path, - List allowedDeps, -}); - -typedef ModuleConfig = ({ - String name, - String path, - List allowedDeps, -}); - -typedef ArchitectureConfig = ({ - List layers, - List modules, - bool detectCycles, - bool layerViolationEnabled, - bool moduleViolationEnabled, -}); - -typedef RulesConfig = ({ - LargeFileRuleConfig largeFile, - LargeClassRuleConfig largeClass, - LargeBuildMethodRuleConfig largeBuildMethod, - LifecycleResourceRuleConfig lifecycleResource, - MissingConstConstructorRuleConfig missingConstConstructor, - DeviceLifecycleRuleConfig deviceLifecycle, - MqttConnectionRuleConfig mqttConnection, - BleScanningRuleConfig bleScanning, - IotSecurityRuleConfig iotSecurity, - PubspecSecurityRuleConfig pubspecSecurity, -}); - -typedef LargeFileRuleConfig = ({bool enabled, int maxLines}); -typedef LargeClassRuleConfig = ({bool enabled, int maxLines}); -typedef LargeBuildMethodRuleConfig = ({bool enabled, int maxLines}); -typedef LifecycleResourceRuleConfig = ({bool enabled}); -typedef MissingConstConstructorRuleConfig = ({bool enabled}); -typedef DeviceLifecycleRuleConfig = ({bool enabled}); -typedef MqttConnectionRuleConfig = ({bool enabled}); -typedef BleScanningRuleConfig = ({bool enabled, int maxScanDurationMs}); -typedef IotSecurityRuleConfig = ({bool enabled, bool requireTls}); -typedef PubspecSecurityRuleConfig = ({bool enabled}); - -class ScanConfig { - final List include; - final List exclude; - final RulesConfig rules; - final ArchitectureConfig architecture; - - const ScanConfig({ - required this.include, - required this.exclude, - required this.rules, - required this.architecture, - }); - - static const _knownTopLevelKeys = { - 'include', - 'exclude', - 'rules', - 'architecture', - }; - static const _knownRuleKeys = { - 'large_file', - 'large_class', - 'large_build_method', - 'lifecycle_resource', - 'missing_const_constructor', - 'device_lifecycle', - 'mqtt_connection', - 'ble_scanning', - 'iot_security', - 'pubspec_security', - }; - static const _knownLayerKeys = { - 'name', - 'path', - 'allowed_deps', - }; - static const _knownArchKeys = { - 'layers', - 'modules', - 'detect_cycles', - 'layer_violation', - 'module_violation', - }; - - factory ScanConfig.fromFile( - String path, { - bool requireFile = false, - }) { - final file = File(path); - if (!file.existsSync()) { - if (requireFile) { - throw FormatException('Config file "$path" does not exist.'); - } - return _defaultConfig(); - } - - final content = file.readAsStringSync(); - final parsed = loadYaml(content); - if (parsed == null) { - return _defaultConfig(); - } - if (parsed is! YamlMap) { - throw FormatException('Config file "$path" must contain a YAML map.'); - } - final yaml = parsed; - - _warnUnknownKeys(yaml, _knownTopLevelKeys, 'config'); - - final rules = _optionalMap(yaml['rules'], 'rules'); - _warnUnknownKeys(rules, _knownRuleKeys, 'rules'); - - final arch = _optionalMap(yaml['architecture'], 'architecture'); - _warnUnknownKeys(arch, _knownArchKeys, 'architecture'); - - if (arch['layers'] is YamlList) { - for (final layer in arch['layers'] as YamlList) { - if (layer is! YamlMap) { - throw const FormatException('Each architecture layer must be a map.'); - } - _warnUnknownKeys(layer, _knownLayerKeys, 'layer'); - } - } - if (arch['modules'] is YamlList) { - for (final module in arch['modules'] as YamlList) { - if (module is! YamlMap) { - throw const FormatException( - 'Each architecture module must be a map.'); - } - _warnUnknownKeys(module, _knownLayerKeys, 'module'); - } - } - - return ScanConfig( - include: _parseStringList(yaml['include']) ?? ['lib/**'], - exclude: _parseStringList(yaml['exclude']) ?? - [ - 'lib/generated/**', - 'lib/**.g.dart', - 'lib/**.freezed.dart', - 'lib/**.mocks.dart', - ], - rules: _parseRules(rules), - architecture: _parseArchitecture(arch), - ); - } - - static ScanConfig _defaultConfig() => const ScanConfig( - include: ['lib/**'], - exclude: [ - 'lib/generated/**', - 'lib/**.g.dart', - 'lib/**.freezed.dart', - 'lib/**.mocks.dart', - ], - rules: ( - largeFile: (enabled: true, maxLines: 500), - largeClass: (enabled: true, maxLines: 300), - largeBuildMethod: (enabled: true, maxLines: 80), - lifecycleResource: (enabled: true), - missingConstConstructor: (enabled: true), - deviceLifecycle: (enabled: true), - mqttConnection: (enabled: true), - bleScanning: (enabled: true, maxScanDurationMs: 10000), - iotSecurity: (enabled: true, requireTls: true), - pubspecSecurity: (enabled: true), - ), - architecture: ( - layers: [], - modules: [], - detectCycles: false, - layerViolationEnabled: true, - moduleViolationEnabled: true, - ), - ); - - static RulesConfig _parseRules(YamlMap rules) { - final largeFile = _optionalMap(rules['large_file'], 'rules.large_file'); - final largeClass = _optionalMap(rules['large_class'], 'rules.large_class'); - final largeBuildMethod = _optionalMap( - rules['large_build_method'], - 'rules.large_build_method', - ); - final lifecycleResource = _optionalMap( - rules['lifecycle_resource'], - 'rules.lifecycle_resource', - ); - final missingConstConstructor = _optionalMap( - rules['missing_const_constructor'], - 'rules.missing_const_constructor', - ); - final deviceLifecycle = _optionalMap( - rules['device_lifecycle'], - 'rules.device_lifecycle', - ); - final mqttConnection = _optionalMap( - rules['mqtt_connection'], - 'rules.mqtt_connection', - ); - final bleScanning = _optionalMap( - rules['ble_scanning'], - 'rules.ble_scanning', - ); - final iotSecurity = _optionalMap( - rules['iot_security'], - 'rules.iot_security', - ); - final pubspecSecurity = _optionalMap( - rules['pubspec_security'], - 'rules.pubspec_security', - ); - - return ( - largeFile: ( - enabled: _boolValue(largeFile, 'enabled', true), - maxLines: _intValue(largeFile, 'maxLines', 500), - ), - largeClass: ( - enabled: _boolValue(largeClass, 'enabled', true), - maxLines: _intValue(largeClass, 'maxLines', 300), - ), - largeBuildMethod: ( - enabled: _boolValue(largeBuildMethod, 'enabled', true), - maxLines: _intValue(largeBuildMethod, 'maxLines', 80), - ), - lifecycleResource: ( - enabled: _boolValue(lifecycleResource, 'enabled', true), - ), - missingConstConstructor: ( - enabled: _boolValue(missingConstConstructor, 'enabled', true), - ), - deviceLifecycle: (enabled: _boolValue(deviceLifecycle, 'enabled', true),), - mqttConnection: (enabled: _boolValue(mqttConnection, 'enabled', true),), - bleScanning: ( - enabled: _boolValue(bleScanning, 'enabled', true), - maxScanDurationMs: _intValue(bleScanning, 'maxScanDurationMs', 10000), - ), - iotSecurity: ( - enabled: _boolValue(iotSecurity, 'enabled', true), - requireTls: _boolValue(iotSecurity, 'requireTls', true), - ), - pubspecSecurity: (enabled: _boolValue(pubspecSecurity, 'enabled', true),), - ); - } - - static ArchitectureConfig _parseArchitecture(YamlMap arch) { - final layers = []; - if (arch['layers'] is YamlList) { - for (final l in arch['layers'] as YamlList) { - if (l is! YamlMap) { - throw const FormatException('Each architecture layer must be a map.'); - } - layers.add(( - name: _requiredString(l, 'name', 'architecture.layers'), - path: _requiredString(l, 'path', 'architecture.layers'), - allowedDeps: _parseStringList(l['allowed_deps']) ?? [], - )); - } - } - - final modules = []; - if (arch['modules'] is YamlList) { - for (final m in arch['modules'] as YamlList) { - if (m is! YamlMap) { - throw const FormatException( - 'Each architecture module must be a map.'); - } - modules.add(( - name: _requiredString(m, 'name', 'architecture.modules'), - path: _requiredString(m, 'path', 'architecture.modules'), - allowedDeps: _parseStringList(m['allowed_deps']) ?? [], - )); - } - } - - final layerViolation = _optionalMap( - arch['layer_violation'], - 'architecture.layer_violation', - ); - final moduleViolation = _optionalMap( - arch['module_violation'], - 'architecture.module_violation', - ); - - return ( - layers: layers, - modules: modules, - detectCycles: _boolValue(arch, 'detect_cycles', false), - layerViolationEnabled: _boolValue(layerViolation, 'enabled', true), - moduleViolationEnabled: _boolValue(moduleViolation, 'enabled', true), - ); - } - - static void _warnUnknownKeys(YamlMap map, Set known, String context) { - for (final key in map.keys) { - final keyStr = key.toString(); - if (!known.contains(keyStr)) { - stderr.writeln('Warning: Unknown $context key "$keyStr"'); - } - } - } - - static List? _parseStringList(dynamic value) { - if (value is YamlList) { - return value.map((e) => e.toString()).toList(); - } - if (value != null) { - throw const FormatException('Expected a YAML list of strings.'); - } - return null; - } - - static YamlMap _optionalMap(dynamic value, String path) { - if (value == null) return YamlMap(); - if (value is YamlMap) return value; - throw FormatException('$path must be a YAML map.'); - } - - static bool _boolValue(YamlMap map, String key, bool defaultValue) { - final value = map[key]; - if (value == null) return defaultValue; - if (value is bool) return value; - throw FormatException('$key must be a boolean.'); - } - - static int _intValue(YamlMap map, String key, int defaultValue) { - final value = map[key]; - if (value == null) return defaultValue; - if (value is int) return value; - throw FormatException('$key must be an integer.'); - } - - static String _requiredString(YamlMap map, String key, String path) { - final value = map[key]; - if (value is String && value.isNotEmpty) return value; - throw FormatException('$path.$key must be a non-empty string.'); - } -} diff --git a/packages/flutterguard_cli/lib/src/config_tools.dart b/packages/flutterguard_cli/lib/src/config_tools.dart deleted file mode 100644 index b0db822..0000000 --- a/packages/flutterguard_cli/lib/src/config_tools.dart +++ /dev/null @@ -1,666 +0,0 @@ -import 'dart:io'; - -import 'package:path/path.dart' as p; - -import 'config_loader.dart'; -import 'file_collector.dart'; -import 'path_utils.dart'; -import 'project_resolver.dart'; - -enum DoctorSeverity { info, warning, error } - -class DoctorMessage { - final DoctorSeverity severity; - final String message; - - const DoctorMessage(this.severity, this.message); -} - -class DoctorResult { - final String projectPath; - final String configPath; - final bool configExists; - final int fileCount; - final List messages; - - const DoctorResult({ - required this.projectPath, - required this.configPath, - required this.configExists, - required this.fileCount, - required this.messages, - }); - - bool get hasErrors => - messages.any((message) => message.severity == DoctorSeverity.error); -} - -class ConfigTools { - static const profiles = { - 'recommended', - 'strict', - 'migration', - 'iot-security', - 'architecture-only', - 'performance-only', - }; - - static const minimalConfig = ''' -include: - - lib/** - -exclude: - - lib/generated/** - - lib/**.g.dart - - lib/**.freezed.dart - - lib/**.mocks.dart - -rules: - large_file: - enabled: true - maxLines: 500 - large_class: - enabled: true - maxLines: 300 - large_build_method: - enabled: true - maxLines: 80 - lifecycle_resource: - enabled: true - missing_const_constructor: - enabled: true - device_lifecycle: - enabled: true - mqtt_connection: - enabled: true - ble_scanning: - enabled: true - maxScanDurationMs: 10000 - iot_security: - enabled: true - requireTls: true - pubspec_security: - enabled: true -'''; - - static const architectureBlock = ''' - -architecture: - layers: - - name: presentation - path: lib/presentation/** - allowed_deps: [domain, core] - - name: domain - path: lib/domain/** - allowed_deps: [core] - - name: data - path: lib/data/** - allowed_deps: [domain, core] - - name: core - path: lib/core/** - allowed_deps: [] - - modules: - - name: mqtt_feature - path: lib/features/mqtt/** - allowed_deps: [shared] - - name: ble_feature - path: lib/features/ble/** - allowed_deps: [shared] - - name: shared - path: lib/shared/** - allowed_deps: [] - - detect_cycles: true - layer_violation: - enabled: true - module_violation: - enabled: true -'''; - - static String initTemplate({ - required bool withArchitecture, - String profile = 'recommended', - }) { - if (!profiles.contains(profile)) { - throw FormatException( - 'Unknown profile "$profile". Allowed: ${profiles.join(", ")}.', - ); - } - - final config = switch (profile) { - 'strict' => _strictConfig, - 'migration' => _migrationConfig, - 'iot-security' => _iotSecurityConfig, - 'architecture-only' => _architectureOnlyConfig, - 'performance-only' => _performanceOnlyConfig, - _ => minimalConfig, - }; - return withArchitecture ? '$config$architectureBlock' : config; - } - - static String writeInitConfig({ - required String projectPath, - required String configPath, - required bool withArchitecture, - required bool force, - String profile = 'recommended', - }) { - final resolvedProjectPath = ProjectResolver.resolveProjectPath(projectPath); - final outputPath = p.isAbsolute(configPath) - ? configPath - : p.join(resolvedProjectPath, configPath); - final file = File(outputPath); - if (file.existsSync() && !force) { - throw StateError( - 'Config file already exists at "$outputPath". Use --force to overwrite.', - ); - } - file.parent.createSync(recursive: true); - file.writeAsStringSync(initTemplate( - withArchitecture: withArchitecture, - profile: profile, - )); - return outputPath; - } - - static const _strictConfig = ''' -include: - - lib/** - -exclude: - - lib/generated/** - - lib/**.g.dart - - lib/**.freezed.dart - - lib/**.mocks.dart - -rules: - large_file: - enabled: true - maxLines: 400 - large_class: - enabled: true - maxLines: 240 - large_build_method: - enabled: true - maxLines: 60 - lifecycle_resource: - enabled: true - missing_const_constructor: - enabled: true - device_lifecycle: - enabled: true - mqtt_connection: - enabled: true - ble_scanning: - enabled: true - maxScanDurationMs: 10000 - iot_security: - enabled: true - requireTls: true - pubspec_security: - enabled: true -'''; - - static const _migrationConfig = ''' -include: - - lib/** - -exclude: - - lib/generated/** - - lib/**.g.dart - - lib/**.freezed.dart - - lib/**.mocks.dart - - test/** - - example/** - -rules: - large_file: - enabled: true - maxLines: 800 - large_class: - enabled: true - maxLines: 500 - large_build_method: - enabled: true - maxLines: 120 - lifecycle_resource: - enabled: true - missing_const_constructor: - enabled: false - device_lifecycle: - enabled: true - mqtt_connection: - enabled: true - ble_scanning: - enabled: true - maxScanDurationMs: 15000 - iot_security: - enabled: true - requireTls: true - pubspec_security: - enabled: true -'''; - - static const _iotSecurityConfig = ''' -include: - - lib/** - -exclude: - - lib/generated/** - - lib/**.g.dart - - lib/**.freezed.dart - - lib/**.mocks.dart - - test/** - - example/** - -rules: - large_file: - enabled: false - maxLines: 500 - large_class: - enabled: false - maxLines: 300 - large_build_method: - enabled: false - maxLines: 80 - lifecycle_resource: - enabled: true - missing_const_constructor: - enabled: false - device_lifecycle: - enabled: true - mqtt_connection: - enabled: true - ble_scanning: - enabled: true - maxScanDurationMs: 10000 - iot_security: - enabled: true - requireTls: true - pubspec_security: - enabled: true -'''; - - static const _architectureOnlyConfig = ''' -include: - - lib/** - -exclude: - - lib/generated/** - - lib/**.g.dart - - lib/**.freezed.dart - - lib/**.mocks.dart - -rules: - large_file: - enabled: false - maxLines: 500 - large_class: - enabled: false - maxLines: 300 - large_build_method: - enabled: false - maxLines: 80 - lifecycle_resource: - enabled: false - missing_const_constructor: - enabled: false - device_lifecycle: - enabled: false - mqtt_connection: - enabled: false - ble_scanning: - enabled: false - maxScanDurationMs: 10000 - iot_security: - enabled: false - requireTls: true - pubspec_security: - enabled: false -'''; - - static const _performanceOnlyConfig = ''' -include: - - lib/** - -exclude: - - lib/generated/** - - lib/**.g.dart - - lib/**.freezed.dart - - lib/**.mocks.dart - -rules: - large_file: - enabled: true - maxLines: 500 - large_class: - enabled: true - maxLines: 300 - large_build_method: - enabled: true - maxLines: 80 - lifecycle_resource: - enabled: true - missing_const_constructor: - enabled: false - device_lifecycle: - enabled: false - mqtt_connection: - enabled: false - ble_scanning: - enabled: false - maxScanDurationMs: 10000 - iot_security: - enabled: false - requireTls: true - pubspec_security: - enabled: false -'''; - - static String resolveConfigPathForProject({ - required String projectPath, - String? configPath, - }) { - return ProjectResolver.resolveConfigPath( - projectPath: projectPath, - explicitConfig: configPath, - ); - } - - static String effectiveYaml(ScanConfig config) { - final buffer = StringBuffer() - ..writeln('include:') - ..write(_stringList(config.include, indent: 2)) - ..writeln() - ..writeln('exclude:') - ..write(_stringList(config.exclude, indent: 2)) - ..writeln() - ..writeln('rules:') - ..write(_ruleWithMaxLines('large_file', config.rules.largeFile)) - ..write(_ruleWithMaxLines('large_class', config.rules.largeClass)) - ..write(_ruleWithMaxLines( - 'large_build_method', - config.rules.largeBuildMethod, - )) - ..write(_enabledRule( - 'lifecycle_resource', - config.rules.lifecycleResource.enabled, - )) - ..write(_enabledRule( - 'missing_const_constructor', - config.rules.missingConstConstructor.enabled, - )) - ..write(_enabledRule( - 'device_lifecycle', - config.rules.deviceLifecycle.enabled, - )) - ..write(_enabledRule( - 'mqtt_connection', - config.rules.mqttConnection.enabled, - )) - ..writeln(' ble_scanning:') - ..writeln(' enabled: ${config.rules.bleScanning.enabled}') - ..writeln( - ' maxScanDurationMs: ${config.rules.bleScanning.maxScanDurationMs}', - ) - ..writeln(' iot_security:') - ..writeln(' enabled: ${config.rules.iotSecurity.enabled}') - ..writeln(' requireTls: ${config.rules.iotSecurity.requireTls}') - ..write(_enabledRule( - 'pubspec_security', - config.rules.pubspecSecurity.enabled, - )) - ..writeln() - ..writeln('architecture:') - ..write(_boundaryList('layers', config.architecture.layers)) - ..write(_boundaryList('modules', config.architecture.modules)) - ..writeln(' detect_cycles: ${config.architecture.detectCycles}') - ..writeln(' layer_violation:') - ..writeln(' enabled: ${config.architecture.layerViolationEnabled}') - ..writeln(' module_violation:') - ..writeln(' enabled: ${config.architecture.moduleViolationEnabled}'); - - return buffer.toString(); - } - - static DoctorResult doctor({ - required String projectPath, - String? configPath, - }) { - final resolvedProjectPath = ProjectResolver.resolveProjectPath(projectPath); - if (!Directory(resolvedProjectPath).existsSync()) { - throw StateError('Project path "$resolvedProjectPath" does not exist.'); - } - - final resolvedConfigPath = resolveConfigPathForProject( - projectPath: resolvedProjectPath, - configPath: configPath, - ); - final configExists = File(resolvedConfigPath).existsSync(); - final config = ScanConfig.fromFile( - resolvedConfigPath, - requireFile: configPath != null, - ); - final files = FileCollector.collect(resolvedProjectPath, config); - - final messages = []; - if (!configExists) { - messages.add(DoctorMessage( - DoctorSeverity.info, - 'No config file found. Built-in defaults are being used.', - )); - } - if (files.isEmpty) { - messages.add(DoctorMessage( - DoctorSeverity.error, - 'No Dart files matched include/exclude patterns.', - )); - } - - _checkBackslashes(config, messages); - _checkBoundaries( - kind: 'layer', - boundaries: config.architecture.layers, - enabled: config.architecture.layerViolationEnabled, - files: files, - projectPath: resolvedProjectPath, - messages: messages, - ); - _checkBoundaries( - kind: 'module', - boundaries: config.architecture.modules, - enabled: config.architecture.moduleViolationEnabled, - files: files, - projectPath: resolvedProjectPath, - messages: messages, - ); - - if (config.architecture.detectCycles && files.isEmpty) { - messages.add(DoctorMessage( - DoctorSeverity.warning, - 'Circular dependency detection is enabled, but no files matched.', - )); - } - - return DoctorResult( - projectPath: resolvedProjectPath, - configPath: resolvedConfigPath, - configExists: configExists, - fileCount: files.length, - messages: messages, - ); - } - - static String formatDoctorResult(DoctorResult result) { - final buffer = StringBuffer() - ..writeln('FlutterGuard config doctor') - ..writeln('Project: ${result.projectPath}') - ..writeln('Config: ${result.configPath}') - ..writeln('Config exists: ${result.configExists}') - ..writeln('Matched Dart files: ${result.fileCount}'); - - if (result.messages.isEmpty) { - buffer.writeln('Status: OK'); - return buffer.toString(); - } - - buffer.writeln(); - for (final message in result.messages) { - buffer.writeln( - '${_severityLabel(message.severity)}: ${message.message}', - ); - } - return buffer.toString(); - } - - static String _stringList(List values, {required int indent}) { - final spaces = ' ' * indent; - return values.map((value) => '$spaces- $value\n').join(); - } - - static String _ruleWithMaxLines( - String name, - ({bool enabled, int maxLines}) config, - ) { - return ''' - $name: - enabled: ${config.enabled} - maxLines: ${config.maxLines} -'''; - } - - static String _enabledRule(String name, bool enabled) { - return ''' - $name: - enabled: $enabled -'''; - } - - static String _boundaryList( - String key, - List<({String name, String path, List allowedDeps})> boundaries, - ) { - if (boundaries.isEmpty) return ' $key: []\n'; - final buffer = StringBuffer(); - buffer.writeln(' $key:'); - for (final boundary in boundaries) { - buffer - ..writeln(' - name: ${boundary.name}') - ..writeln(' path: ${boundary.path}') - ..writeln(' allowed_deps: [${boundary.allowedDeps.join(', ')}]'); - } - return buffer.toString(); - } - - static void _checkBackslashes( - ScanConfig config, - List messages, - ) { - for (final pattern in [...config.include, ...config.exclude]) { - if (pattern.contains(r'\')) { - messages.add(DoctorMessage( - DoctorSeverity.warning, - 'Pattern "$pattern" contains backslashes. Use forward slashes in YAML.', - )); - } - } - for (final boundary in [ - ...config.architecture.layers, - ...config.architecture.modules, - ]) { - if (boundary.path.contains(r'\')) { - messages.add(DoctorMessage( - DoctorSeverity.warning, - 'Architecture path "${boundary.path}" contains backslashes. Use forward slashes in YAML.', - )); - } - } - } - - static void _checkBoundaries({ - required String kind, - required List<({String name, String path, List allowedDeps})> - boundaries, - required bool enabled, - required List files, - required String projectPath, - required List messages, - }) { - if (enabled && boundaries.isEmpty) { - messages.add(DoctorMessage( - DoctorSeverity.info, - 'No architecture ${kind}s declared. ${kind}_violation will not report boundary issues.', - )); - return; - } - - final names = boundaries.map((boundary) => boundary.name).toSet(); - if (names.length != boundaries.length) { - messages.add(DoctorMessage( - DoctorSeverity.error, - 'Duplicate architecture $kind names found.', - )); - } - - for (final boundary in boundaries) { - for (final dep in boundary.allowedDeps) { - if (!names.contains(dep)) { - messages.add(DoctorMessage( - DoctorSeverity.error, - 'Architecture $kind "${boundary.name}" allows unknown dependency "$dep".', - )); - } - } - } - - final matchesByName = >{}; - for (final boundary in boundaries) { - final matches = files - .where((file) => _matchesBoundary(file, boundary.path, projectPath)) - .toSet(); - matchesByName[boundary.name] = matches; - if (matches.isEmpty) { - messages.add(DoctorMessage( - DoctorSeverity.warning, - 'Architecture $kind "${boundary.name}" path "${boundary.path}" matched no files.', - )); - } - } - - for (var i = 0; i < boundaries.length; i++) { - for (var j = i + 1; j < boundaries.length; j++) { - final left = boundaries[i]; - final right = boundaries[j]; - final overlap = matchesByName[left.name]!.intersection( - matchesByName[right.name]!, - ); - if (overlap.isNotEmpty) { - messages.add(DoctorMessage( - DoctorSeverity.warning, - 'Architecture $kind paths "${left.name}" and "${right.name}" overlap on ${overlap.length} file(s).', - )); - } - } - } - } - - static bool _matchesBoundary( - String file, - String pattern, - String projectPath, - ) { - return matchesProjectGlob(file, pattern, projectPath); - } - - static String _severityLabel(DoctorSeverity severity) { - switch (severity) { - case DoctorSeverity.info: - return 'INFO'; - case DoctorSeverity.warning: - return 'WARN'; - case DoctorSeverity.error: - return 'ERROR'; - } - } -} diff --git a/packages/flutterguard_cli/lib/src/domain.dart b/packages/flutterguard_cli/lib/src/domain.dart deleted file mode 100644 index ba8c057..0000000 --- a/packages/flutterguard_cli/lib/src/domain.dart +++ /dev/null @@ -1,5 +0,0 @@ -enum IssueDomain { - architecture, - performance, - standards, -} diff --git a/packages/flutterguard_cli/lib/src/install_doctor.dart b/packages/flutterguard_cli/lib/src/install_doctor.dart deleted file mode 100644 index 1739121..0000000 --- a/packages/flutterguard_cli/lib/src/install_doctor.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'dart:io'; - -class InstallDoctor { - static String generate({required String version}) { - final buffer = StringBuffer() - ..writeln('FlutterGuard install doctor') - ..writeln('Version: $version') - ..writeln('Dart executable: ${Platform.resolvedExecutable}') - ..writeln('Entrypoint: ${Platform.script}') - ..writeln('Operating system: ${Platform.operatingSystem}') - ..writeln('PATH entries named flutterguard:'); - - final matches = _findFlutterGuardCommands(); - if (matches.isEmpty) { - buffer.writeln(' none found'); - } else { - for (final match in matches) { - buffer.writeln(' $match'); - } - } - - buffer - ..writeln() - ..writeln('Recommended checks:') - ..writeln(' flutterguard --version') - ..writeln(' flutterguard --help') - ..writeln() - ..writeln( - 'If the version is stale, reinstall or use the source launcher:') - ..writeln(' dart pub global activate flutterguard_cli') - ..writeln(' ./scripts/flutterguard-dev --version'); - - return buffer.toString(); - } - - static List _findFlutterGuardCommands() { - final path = Platform.environment['PATH']; - if (path == null || path.isEmpty) return const []; - - final names = Platform.isWindows - ? const ['flutterguard.exe', 'flutterguard.bat', 'flutterguard.cmd'] - : const ['flutterguard']; - final matches = []; - - for (final dir in path.split(Platform.isWindows ? ';' : ':')) { - if (dir.isEmpty) continue; - for (final name in names) { - final candidate = File(_joinPath(dir, name)); - if (candidate.existsSync()) { - matches.add(candidate.path); - } - } - } - return matches; - } - - static String _joinPath(String left, String right) { - final separator = Platform.isWindows ? r'\' : '/'; - if (left.endsWith(separator)) return '$left$right'; - return '$left$separator$right'; - } -} diff --git a/packages/flutterguard_cli/lib/src/issue_export.dart b/packages/flutterguard_cli/lib/src/issue_export.dart deleted file mode 100644 index 643e445..0000000 --- a/packages/flutterguard_cli/lib/src/issue_export.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:path/path.dart' as p; - -import 'baseline.dart'; -import 'rules/registry.dart'; -import 'source_workspace.dart'; -import 'static_issue.dart'; - -class IssueExporter { - static String export({ - required String projectPath, - required List issues, - String? ruleId, - String? filePath, - int? line, - int contextLines = 2, - SourceWorkspace? workspace, - }) { - final issue = _findIssue( - projectPath: projectPath, - issues: issues, - ruleId: ruleId, - filePath: filePath, - line: line, - ); - if (issue == null) { - throw const FormatException('No matching issue found to export.'); - } - - final relativePath = _relativePath(issue.file, projectPath); - final meta = RuleRegistry.find(issue.id); - final payload = { - 'version': '1.0.0', - 'generatedAt': DateTime.now().toIso8601String(), - 'projectPath': projectPath, - 'fingerprint': Baseline.fingerprint(issue, projectPath), - 'issue': issue.toJson()..['file'] = relativePath, - 'rule': meta?.toJson(), - 'context': _context(issue, contextLines, workspace: workspace), - 'feedbackTemplate': { - 'whyFalsePositiveOrFalseNegative': '', - 'expectedBehavior': '', - 'sanitizedSnippetConfirmed': false, - }, - }; - - return const JsonEncoder.withIndent(' ').convert(payload); - } - - static StaticIssue? _findIssue({ - required String projectPath, - required List issues, - String? ruleId, - String? filePath, - int? line, - }) { - final normalizedFile = filePath == null - ? null - : p.normalize( - p.isAbsolute(filePath) ? filePath : p.join(projectPath, filePath)); - - for (final issue in issues) { - if (ruleId != null && issue.id != ruleId) continue; - if (line != null && issue.line != line) continue; - if (normalizedFile != null && p.normalize(issue.file) != normalizedFile) { - continue; - } - return issue; - } - return null; - } - - static Map _context( - StaticIssue issue, - int contextLines, { - SourceWorkspace? workspace, - }) { - final line = issue.line; - final file = File(issue.file); - if (line == null || !file.existsSync()) { - return { - 'available': false, - 'lines': const [], - }; - } - - final lines = - workspace?.source(issue.file)?.lines ?? file.readAsLinesSync(); - final start = (line - contextLines).clamp(1, lines.length); - final end = (line + contextLines).clamp(1, lines.length); - return { - 'available': true, - 'startLine': start, - 'endLine': end, - 'lines': [ - for (var i = start; i <= end; i++) - { - 'line': i, - 'text': lines[i - 1], - } - ], - }; - } - - static String _relativePath(String filePath, String projectPath) { - if (p.isWithin(projectPath, filePath) || p.equals(projectPath, filePath)) { - return p.relative(filePath, from: projectPath).replaceAll('\\', '/'); - } - return filePath.replaceAll('\\', '/'); - } -} diff --git a/packages/flutterguard_cli/lib/src/priority.dart b/packages/flutterguard_cli/lib/src/priority.dart deleted file mode 100644 index 77cedd1..0000000 --- a/packages/flutterguard_cli/lib/src/priority.dart +++ /dev/null @@ -1 +0,0 @@ -enum Priority { p0, p1, p2 } diff --git a/packages/flutterguard_cli/lib/src/report_generator.dart b/packages/flutterguard_cli/lib/src/report_generator.dart deleted file mode 100644 index 06d6b79..0000000 --- a/packages/flutterguard_cli/lib/src/report_generator.dart +++ /dev/null @@ -1,300 +0,0 @@ -import 'dart:convert'; - -import 'package:path/path.dart' as p; - -import 'domain.dart'; -import 'priority.dart'; -import 'source_workspace.dart'; -import 'static_issue.dart'; - -class _Ansi { - static const reset = '\x1b[0m'; - static const red = '\x1b[31m'; - static const green = '\x1b[32m'; - static const yellow = '\x1b[33m'; - static const gray = '\x1b[90m'; - static const bold = '\x1b[1m'; - static const dim = '\x1b[2m'; - static const orange = '\x1b[38;5;208m'; -} - -const _domainLabels = { - IssueDomain.architecture: '架构违规', - IssueDomain.performance: '性能问题', - IssueDomain.standards: '代码规范', -}; - -const _domainAnsi = { - IssueDomain.architecture: _Ansi.red, - IssueDomain.performance: _Ansi.yellow, - IssueDomain.standards: _Ansi.gray, -}; - -const _levelLabel = { - RiskLevel.high: 'HIGH', - RiskLevel.medium: 'MED ', - RiskLevel.low: 'LOW ', -}; - -const _levelAnsi = { - RiskLevel.high: _Ansi.red, - RiskLevel.medium: _Ansi.yellow, - RiskLevel.low: _Ansi.gray, -}; - -const _priorityLabel = { - Priority.p0: 'P0 优先', - Priority.p1: 'P1 关注', - Priority.p2: 'P2 可选', -}; - -const _priorityAnsi = { - Priority.p0: _Ansi.red, - Priority.p1: _Ansi.yellow, - Priority.p2: _Ansi.gray, -}; - -class ReportGenerator { - static String generateJson({ - required String projectPath, - required List issues, - String scanMode = 'full', - int suppressedCount = 0, - int suppressedByBaselineCount = 0, - List diagnostics = const [], - }) { - final byDomain = _buildSummaryByDomain(issues); - final score = calculateScore(issues); - - final payload = { - 'version': '1.0.0', - 'generatedAt': DateTime.now().toIso8601String(), - 'projectPath': projectPath, - 'scanMode': scanMode, - 'score': score, - 'summary': { - 'total': issues.length, - 'high': issues.where((i) => i.level == RiskLevel.high).length, - 'medium': issues.where((i) => i.level == RiskLevel.medium).length, - 'low': issues.where((i) => i.level == RiskLevel.low).length, - 'suppressed': suppressedCount, - 'suppressedByBaseline': suppressedByBaselineCount, - 'diagnostics': diagnostics.length, - 'byDomain': byDomain, - }, - 'issues': issues.map((i) => i.toJson()).toList(), - 'diagnostics': - diagnostics.map((diagnostic) => diagnostic.toJson()).toList(), - }; - - return const JsonEncoder.withIndent(' ').convert(payload); - } - - static String generateStdout({ - required String projectPath, - required List issues, - int? scannedFileCount, - bool verbose = false, - bool noColor = false, - }) { - final buf = StringBuffer(); - final score = calculateScore(issues); - final projectName = p.basename(projectPath); - - _writeHeader( - buf, - projectName, - score, - issues, - scannedFileCount ?? issues.map((i) => i.file).toSet().length, - noColor: noColor, - ); - - if (issues.isEmpty) { - final msg = noColor - ? '未发现问题,代码质量良好。' - : '${_Ansi.green}未发现问题,代码质量良好。${_Ansi.reset}'; - buf.writeln(' $msg'); - return buf.toString(); - } - - _writeDomainSummaryBar(buf, issues, noColor: noColor); - - for (final domain in IssueDomain.values) { - final domainIssues = issues.where((i) => i.domain == domain).toList(); - if (domainIssues.isEmpty) continue; - _writeDomainSection( - buf, - projectPath, - domain, - domainIssues, - verbose, - noColor: noColor, - ); - } - - return buf.toString(); - } - - static void _writeHeader( - StringBuffer buf, - String projectName, - int score, - List issues, - int scannedFileCount, { - bool noColor = false, - }) { - final scoreAnsi = noColor ? '' : _scoreAnsi(score); - final reset = noColor ? '' : _Ansi.reset; - final gray = noColor ? '' : _Ansi.gray; - final bold = noColor ? '' : _Ansi.bold; - final scoreLabel = score >= 80 - ? '优秀' - : score >= 50 - ? '需关注' - : '需整改'; - buf.writeln( - ' ${bold}FlutterGuard Report$reset $gray─$reset $projectName'); - buf.writeln( - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - buf.write(' 总评分: $scoreAnsi$score/100$reset $scoreAnsi$scoreLabel$reset'); - buf.writeln( - ' $bold扫描文件: $scannedFileCount$reset 问题总数: $bold${issues.length}$reset'); - buf.writeln( - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - buf.writeln(); - } - - static void _writeDomainSummaryBar( - StringBuffer buf, - List issues, { - bool noColor = false, - }) { - final reset = noColor ? '' : _Ansi.reset; - final gray = noColor ? '' : _Ansi.gray; - for (final domain in IssueDomain.values) { - final domainIssues = issues.where((i) => i.domain == domain).toList(); - if (domainIssues.isEmpty) continue; - - final count = domainIssues.length; - final maxLevel = domainIssues - .map((i) => i.level) - .reduce((a, b) => a.index > b.index ? a : b); - final levelLabel = _levelLabel[maxLevel]!; - final domainAnsi = noColor ? '' : _domainAnsi[domain]!; - final levelAnsi = noColor ? '' : _levelAnsi[maxLevel]!; - - buf.write(' $domainAnsi${_domainLabels[domain]}$reset'); - buf.write(' $count items '); - buf.write('$gray▰$reset '); - buf.writeln('$levelAnsi$levelLabel$reset'); - } - buf.writeln( - '────────────────────────────────────────────────────────────────────────────────────'); - buf.writeln(); - } - - static void _writeDomainSection( - StringBuffer buf, - String projectPath, - IssueDomain domain, - List issues, - bool verbose, { - bool noColor = false, - }) { - final sorted = [...issues]..sort((a, b) { - final order = { - RiskLevel.high: 0, - RiskLevel.medium: 1, - RiskLevel.low: 2, - }; - return order[a.level]!.compareTo(order[b.level]!); - }); - - final reset = noColor ? '' : _Ansi.reset; - final gray = noColor ? '' : _Ansi.gray; - final bold = noColor ? '' : _Ansi.bold; - final dim = noColor ? '' : _Ansi.dim; - final green = noColor ? '' : _Ansi.green; - - for (final issue in sorted) { - final lvlAnsi = noColor ? '' : _levelAnsi[issue.level]!; - final lvlLabel = _levelLabel[issue.level]!; - final priAnsi = noColor ? '' : _priorityAnsi[issue.priority]!; - final priLabel = _priorityLabel[issue.priority]!; - final displayPath = _displayPath(issue.file, projectPath); - final lineInfo = issue.line != null ? ':${issue.line}' : ''; - - buf.writeln(' $lvlAnsi$lvlLabel$reset $priAnsi$priLabel$reset'); - buf.writeln(' $bold${issue.title}$reset'); - buf.writeln(' $gray$displayPath$lineInfo$reset'); - buf.writeln(' ${issue.message}'); - - if (verbose && issue.detail.isNotEmpty) { - buf.writeln(); - for (final line in issue.detail.split('\n')) { - buf.writeln(' $dim$line$reset'); - } - } - buf.writeln(' 修复: $green${issue.suggestion}$reset'); - buf.writeln(); - } - buf.writeln( - ' ────────────────────────────────────────────────────────────────────────────────'); - buf.writeln(); - } - - static String _displayPath(String filePath, String projectPath) { - if (p.isWithin(projectPath, filePath) || p.equals(projectPath, filePath)) { - return p.relative(filePath, from: projectPath); - } - return filePath; - } - - static Map> _buildSummaryByDomain( - List issues) { - final byDomain = >{}; - for (final domain in IssueDomain.values) { - final domainIssues = issues.where((i) => i.domain == domain).toList(); - if (domainIssues.isEmpty) continue; - byDomain[domain.name] = { - 'high': domainIssues.where((i) => i.level == RiskLevel.high).length, - 'medium': domainIssues.where((i) => i.level == RiskLevel.medium).length, - 'low': domainIssues.where((i) => i.level == RiskLevel.low).length, - 'total': domainIssues.length, - }; - } - return byDomain; - } - - static int calculateScore(List issues) { - final high = issues.where((i) => i.level == RiskLevel.high).length; - final medium = issues.where((i) => i.level == RiskLevel.medium).length; - final low = issues.where((i) => i.level == RiskLevel.low).length; - final score = 100 - high * 10 - medium * 4 - low * 1; - return score.clamp(0, 100); - } - - static bool shouldFail(List issues, String failOn) { - final high = issues.where((i) => i.level == RiskLevel.high).length; - final medium = issues.where((i) => i.level == RiskLevel.medium).length; - final low = issues.where((i) => i.level == RiskLevel.low).length; - - switch (failOn) { - case 'high': - return high > 0; - case 'medium': - return high > 0 || medium > 0; - case 'low': - return high > 0 || medium > 0 || low > 0; - default: - return false; - } - } - - static String _scoreAnsi(int score) { - if (score >= 80) return _Ansi.green; - if (score >= 50) return _Ansi.orange; - return _Ansi.red; - } -} diff --git a/packages/flutterguard_cli/lib/src/rule_meta.dart b/packages/flutterguard_cli/lib/src/rule_meta.dart deleted file mode 100644 index d8c94cf..0000000 --- a/packages/flutterguard_cli/lib/src/rule_meta.dart +++ /dev/null @@ -1,41 +0,0 @@ -class RuleMeta { - final String id; - final String name; - final String domain; - final String riskLevel; - final String priority; - final String purpose; - final String riskReason; - final String badExample; - final String fixSuggestion; - final List configKeys; - final bool cicdSafe; - - const RuleMeta({ - required this.id, - required this.name, - required this.domain, - required this.riskLevel, - required this.priority, - required this.purpose, - required this.riskReason, - required this.badExample, - required this.fixSuggestion, - this.configKeys = const [], - this.cicdSafe = true, - }); - - Map toJson() => { - 'id': id, - 'name': name, - 'domain': domain, - 'riskLevel': riskLevel, - 'priority': priority, - 'purpose': purpose, - 'riskReason': riskReason, - 'badExample': badExample, - 'fixSuggestion': fixSuggestion, - 'configKeys': configKeys, - 'cicdSafe': cicdSafe, - }; -} diff --git a/packages/flutterguard_cli/lib/src/rules/AGENTS.md b/packages/flutterguard_cli/lib/src/rules/AGENTS.md deleted file mode 100644 index 96492b8..0000000 --- a/packages/flutterguard_cli/lib/src/rules/AGENTS.md +++ /dev/null @@ -1,36 +0,0 @@ -# Rule Layer - -## Responsibility -Each file implements one rule family and returns `List`. - -## Existing Rule Families -- `large_units.dart`: `large_file`, `large_class`, `large_build_method` -- `lifecycle_resource.dart`: undisposed controllers, streams, timers, MQTT/BLE resources -- `layer_violation.dart`: configured layer dependency breaches -- `module_violation.dart`: configured module dependency breaches -- `circular_dependency.dart`: file-level import cycles -- `missing_const_constructor.dart`: widget classes missing const constructors -- `device_lifecycle.dart`: balanced init/teardown pairs (initState↔dispose, connect↔disconnect, startScan↔stopScan, etc.) -- `mqtt_connection.dart`: MQTT connect/disconnect pairing, subscribe/unsubscribe, hardcoded broker URLs -- `ble_scanning.dart`: BLE startScan/stopScan pairing, connect/disconnect, scan timeout configuration -- `iot_security.dart`: hardcoded credentials, cleartext MQTT (port 1883), cleartext HTTP, insecure BLE -- `pubspec_security.dart`: unbounded deps, deprecated packages (flutter_blue→flutter_blue_plus), outdated IoT dependencies - -## Rule Contract -- Constructor receives typed config or explicit parameters. -- Public API is `analyze(List files, {SourceWorkspace? workspace})` for source rules; direct calls may omit the workspace. -- Scanner execution and metadata are explicitly wired in `catalog.dart`. -- Return no issues when disabled or not configured. -- Catch per-file parse/read failures so one bad file does not abort the full scan. -- Use `StaticIssue` with domain, priority, suggestion, and metadata. -- Use line numbers, not analyzer character offsets. -- IoT rules use string/pattern matching on file contents (consistent with lifecycle_resource approach). - -## New Rule Checklist -1. Add spec entry in `docs/FLUTTERGUARD_SPEC.md`. -2. Add typed config in `config_loader.dart` if configurable. -3. Implement the rule class here. -4. Add fixture(s) under `test/fixtures/`. -5. Add tests in `test/scanner_test.dart`. -6. Wire the rule and metadata in `catalog.dart`. -7. Export through `lib/flutterguard_cli.dart` if needed. diff --git a/packages/flutterguard_cli/lib/src/rules/ble_scanning.dart b/packages/flutterguard_cli/lib/src/rules/ble_scanning.dart deleted file mode 100644 index 47ce38d..0000000 --- a/packages/flutterguard_cli/lib/src/rules/ble_scanning.dart +++ /dev/null @@ -1,169 +0,0 @@ -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/source/line_info.dart'; - -import '../config_loader.dart'; -import '../domain.dart'; -import '../priority.dart'; -import '../rule_meta.dart'; -import '../source_utils.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; - -const _bleTypePatterns = ['Ble', 'Ble', 'BluetoothDevice', 'Bluetooth']; - -class BleScanningRule { - final BleScanningRuleConfig config; - - const BleScanningRule(this.config); - - List analyze( - List files, { - SourceWorkspace? workspace, - }) { - if (!config.enabled) return []; - - final issues = []; - final sources = workspace ?? SourceWorkspace(); - - for (final file in files) { - final source = sources.source(file); - if (source == null) continue; - issues.addAll( - _checkFile(file, source.content, source.unit, source.lineInfo), - ); - } - - return issues; - } - - List _checkFile( - String file, - String rawContent, - CompilationUnit unit, - LineInfo lineInfo, - ) { - final issues = []; - - for (final cls in unit.declarations.whereType()) { - final hasBleField = cls.members.whereType().where((f) { - final type = f.fields.type?.toString() ?? ''; - return _bleTypePatterns.any((t) => type.contains(t)); - }).isNotEmpty; - - final hasBleRef = cls.members.whereType().any((m) { - final body = m.toString().toLowerCase(); - return _bleTypePatterns.any((t) => body.contains(t.toLowerCase())); - }); - - if (!hasBleField && !hasBleRef) continue; - - final methods = cls.members.whereType().toList(); - final methodNames = methods.map((m) => m.name.lexeme).toSet(); - - if (methodNames.contains('startScan') && - !methodNames.contains('stopScan')) { - final startScanMethod = - methods.firstWhere((m) => m.name.lexeme == 'startScan'); - final line = lineNumberForOffset(lineInfo, startScanMethod.name.offset); - issues.add(StaticIssue( - id: 'ble_scanning', - title: 'BLE 扫描未停止', - file: file, - line: line, - level: RiskLevel.medium, - domain: IssueDomain.architecture, - priority: Priority.p1, - message: '类 "${cls.name.lexeme}" 中有 startScan() 调用但缺少 stopScan()', - detail: '类: ${cls.name.lexeme}\n' - 'BLE 扫描应在不需要时停止以节省电量', - suggestion: '在类中添加 stopScan() 方法并在 dispose 中调用', - metadata: { - 'className': cls.name.lexeme, - 'check': 'startScan_without_stopScan', - }, - )); - } - - if (methodNames.contains('connect') && - !methodNames.contains('disconnect')) { - final connectMethod = - methods.firstWhere((m) => m.name.lexeme == 'connect'); - final line = lineNumberForOffset(lineInfo, connectMethod.name.offset); - issues.add(StaticIssue( - id: 'ble_scanning', - title: 'BLE 连接未断开', - file: file, - line: line, - level: RiskLevel.medium, - domain: IssueDomain.architecture, - priority: Priority.p1, - message: '类 "${cls.name.lexeme}" 中有 BLE connect() 调用但缺少 disconnect()', - detail: '类: ${cls.name.lexeme}\n' - 'BLE 连接应在不需要时断开以节省电量', - suggestion: '在类中添加 disconnect() 方法并在 dispose 中调用', - metadata: { - 'className': cls.name.lexeme, - 'check': 'ble_connect_without_disconnect', - }, - )); - } - - _checkScanTimeout(file, - startScanMethod: methods, lineInfo: lineInfo, issues: issues); - } - - return issues; - } - - void _checkScanTimeout( - String file, { - required List startScanMethod, - required LineInfo lineInfo, - required List issues, - }) { - for (final method in startScanMethod) { - if (method.name.lexeme != 'startScan') continue; - - final body = method.toString().toLowerCase(); - final hasTimeout = body.contains('timeout') || - body.contains('duration') || - body.contains('maxscanduration'); - - if (!hasTimeout) { - final line = lineNumberForOffset(lineInfo, method.name.offset); - issues.add(StaticIssue( - id: 'ble_scanning', - title: 'BLE 扫描缺少超时配置', - file: file, - line: line, - level: RiskLevel.low, - domain: IssueDomain.architecture, - priority: Priority.p1, - message: 'startScan() 调用未配置超时参数', - detail: '方法: ${method.name.lexeme}\n' - 'BLE 扫描应设置超时以限制扫描时间,避免过度耗电', - suggestion: - '为 startScan() 添加超时参数 (推荐 < ${config.maxScanDurationMs}ms)', - metadata: { - 'check': 'scan_without_timeout', - 'maxScanDurationMs': config.maxScanDurationMs, - }, - )); - } - } - } - - static RuleMeta describe() => const RuleMeta( - id: 'ble_scanning', - name: 'BLE 扫描管理异常', - domain: 'architecture', - riskLevel: 'medium', - priority: 'p1', - purpose: '检测 BLE startScan/stopScan、connect/disconnect 配对及扫描超时配置', - riskReason: '未停止的 BLE 扫描持续消耗电量;缺少超时导致扫描无限进行', - badExample: 'startScan() 调用后无 stopScan() 和超时参数', - fixSuggestion: '在 dispose() 中添加 stopScan();为 startScan 添加超时参数', - configKeys: ['rules.ble_scanning.maxScanDurationMs'], - cicdSafe: true, - ); -} diff --git a/packages/flutterguard_cli/lib/src/rules/catalog.dart b/packages/flutterguard_cli/lib/src/rules/catalog.dart deleted file mode 100644 index dba54f7..0000000 --- a/packages/flutterguard_cli/lib/src/rules/catalog.dart +++ /dev/null @@ -1,172 +0,0 @@ -import 'package:path/path.dart' as p; - -import '../import_graph.dart'; -import '../path_utils.dart'; -import '../rule_meta.dart'; -import '../scan_context.dart'; -import '../static_issue.dart'; -import 'ble_scanning.dart'; -import 'circular_dependency.dart'; -import 'device_lifecycle.dart'; -import 'iot_security.dart'; -import 'large_units.dart'; -import 'layer_violation.dart'; -import 'lifecycle_resource.dart'; -import 'missing_const_constructor.dart'; -import 'module_violation.dart'; -import 'mqtt_connection.dart'; -import 'pubspec_security.dart'; - -typedef RuleExecutor = List Function( - ScanContext context, - ImportGraph? importGraph, -); - -class RuleRegistration { - final List metadata; - final RuleExecutor execute; - - const RuleRegistration({ - required this.metadata, - required this.execute, - }); -} - -/// Explicit source of truth for rule metadata and execution wiring. -class RuleCatalog { - static final List registrations = List.unmodifiable([ - RuleRegistration( - metadata: [ - LargeUnitsRule.describeLargeFile(), - LargeUnitsRule.describeLargeClass(), - LargeUnitsRule.describeLargeBuildMethod(), - ], - execute: (context, _) => LargeUnitsRule( - largeFileConfig: context.config.rules.largeFile, - largeClassConfig: context.config.rules.largeClass, - largeBuildMethodConfig: context.config.rules.largeBuildMethod, - ).analyze(context.targetFiles, workspace: context.sources), - ), - RuleRegistration( - metadata: [LifecycleResourceRule.describe()], - execute: (context, _) => LifecycleResourceRule( - context.config.rules.lifecycleResource, - ).analyze(context.targetFiles, workspace: context.sources), - ), - RuleRegistration( - metadata: [LayerViolationRule.describe()], - execute: (context, importGraph) { - if (!context.config.architecture.layerViolationEnabled) return []; - return LayerViolationRule( - context.config.architecture.layers, - projectPath: context.projectPath, - ).analyze( - context.targetFiles, - allFiles: context.allFiles, - workspace: context.sources, - importGraph: importGraph, - ); - }, - ), - RuleRegistration( - metadata: [ModuleViolationRule.describe()], - execute: (context, importGraph) { - if (!context.config.architecture.moduleViolationEnabled) return []; - return ModuleViolationRule( - context.config.architecture.modules, - projectPath: context.projectPath, - ).analyze( - context.targetFiles, - allFiles: context.allFiles, - workspace: context.sources, - importGraph: importGraph, - ); - }, - ), - RuleRegistration( - metadata: [MissingConstConstructorRule.describe()], - execute: (context, _) => MissingConstConstructorRule( - context.config.rules.missingConstConstructor, - ).analyze(context.targetFiles, workspace: context.sources), - ), - RuleRegistration( - metadata: [CircularDependencyRule.describe()], - execute: (context, importGraph) => CircularDependencyRule( - enabled: !context.isChanged && context.config.architecture.detectCycles, - projectPath: context.projectPath, - ).analyze( - context.targetFiles, - workspace: context.sources, - importGraph: importGraph, - ), - ), - RuleRegistration( - metadata: [DeviceLifecycleRule.describe()], - execute: (context, _) => DeviceLifecycleRule( - context.config.rules.deviceLifecycle, - ).analyze(context.targetFiles, workspace: context.sources), - ), - RuleRegistration( - metadata: [MqttConnectionRule.describe()], - execute: (context, _) => MqttConnectionRule( - context.config.rules.mqttConnection, - ).analyze(context.targetFiles, workspace: context.sources), - ), - RuleRegistration( - metadata: [BleScanningRule.describe()], - execute: (context, _) => BleScanningRule( - context.config.rules.bleScanning, - ).analyze(context.targetFiles, workspace: context.sources), - ), - RuleRegistration( - metadata: [IotSecurityRule.describe()], - execute: (context, _) => IotSecurityRule( - context.config.rules.iotSecurity, - ).analyze(context.targetFiles, workspace: context.sources), - ), - RuleRegistration( - metadata: [PubspecSecurityRule.describe()], - execute: (context, _) { - final pubspecPath = normalizePath( - p.join(context.projectPath, 'pubspec.yaml'), - ); - if (context.isChanged && !context.changedFiles.contains(pubspecPath)) { - return []; - } - return PubspecSecurityRule( - context.config.rules.pubspecSecurity, - ).analyze( - context.targetFiles, - projectPath: context.projectPath, - workspace: context.sources, - ); - }, - ), - ]); - - static List metadata() => [ - for (final registration in registrations) ...registration.metadata, - ]; - - static List analyze(ScanContext context) { - final architecture = context.config.architecture; - final needsImportGraph = - architecture.layerViolationEnabled && architecture.layers.isNotEmpty || - architecture.moduleViolationEnabled && - architecture.modules.isNotEmpty || - !context.isChanged && architecture.detectCycles; - final importGraph = needsImportGraph - ? ImportGraph.build( - files: context.allFiles, - sourceFiles: context.targetFiles, - workspace: context.sources, - projectPath: context.projectPath, - ) - : null; - - return [ - for (final registration in registrations) - ...registration.execute(context, importGraph), - ]; - } -} diff --git a/packages/flutterguard_cli/lib/src/rules/device_lifecycle.dart b/packages/flutterguard_cli/lib/src/rules/device_lifecycle.dart deleted file mode 100644 index c48cab5..0000000 --- a/packages/flutterguard_cli/lib/src/rules/device_lifecycle.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/source/line_info.dart'; - -import '../config_loader.dart'; -import '../domain.dart'; -import '../priority.dart'; -import '../rule_meta.dart'; -import '../source_utils.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; - -const _lifecyclePairs = { - 'initState': 'dispose', - 'connect': 'disconnect', - 'startScan': 'stopScan', - 'start': 'stop', - 'listen': 'cancel', - 'subscribe': 'unsubscribe', -}; - -class DeviceLifecycleRule { - final DeviceLifecycleRuleConfig config; - - const DeviceLifecycleRule(this.config); - - List analyze( - List files, { - SourceWorkspace? workspace, - }) { - if (!config.enabled) return []; - - final issues = []; - final sources = workspace ?? SourceWorkspace(); - - for (final file in files) { - final source = sources.source(file); - if (source == null) continue; - issues.addAll(_checkFile(file, source.unit, source.lineInfo)); - } - - return issues; - } - - List _checkFile( - String file, - CompilationUnit unit, - LineInfo lineInfo, - ) { - final issues = []; - - for (final cls in unit.declarations.whereType()) { - final methods = cls.members.whereType().toList(); - final methodNames = methods.map((m) => m.name.lexeme).toSet(); - - for (final initMethod in _lifecyclePairs.keys) { - if (!methodNames.contains(initMethod)) continue; - - final teardownMethod = _lifecyclePairs[initMethod]!; - if (!methodNames.contains(teardownMethod)) { - final initDecl = - methods.firstWhere((m) => m.name.lexeme == initMethod); - final line = lineNumberForOffset(lineInfo, initDecl.name.offset); - - issues.add(StaticIssue( - id: 'device_lifecycle', - title: '设备生命周期不完整', - file: file, - line: line, - level: RiskLevel.high, - domain: IssueDomain.architecture, - priority: Priority.p0, - message: - '类 "${cls.name.lexeme}" 中存在 "$initMethod" 但缺少对应的 "$teardownMethod"', - detail: '类: ${cls.name.lexeme}\n' - '存在方法: $initMethod\n' - '缺少方法: $teardownMethod\n' - '设备生命周期方法应成对出现 (init/teardown)', - suggestion: '在类 "${cls.name.lexeme}" 中添加 "$teardownMethod" 方法', - metadata: { - 'className': cls.name.lexeme, - 'initMethod': initMethod, - 'teardownMethod': teardownMethod, - }, - )); - } - } - } - - return issues; - } - - static RuleMeta describe() => const RuleMeta( - id: 'device_lifecycle', - name: '设备生命周期不匹配', - domain: 'architecture', - riskLevel: 'high', - priority: 'p0', - purpose: '检测 initState/connect 等初始化方法是否有对应的 dispose/disconnect 销毁方法', - riskReason: '不匹配的生命周期导致资源泄漏和设备连接未正常关闭', - badExample: 'connect() 调用存在但 disconnect() 不存在', - fixSuggestion: '确保每个初始化方法有对应的销毁方法', - cicdSafe: true, - ); -} diff --git a/packages/flutterguard_cli/lib/src/rules/iot_security.dart b/packages/flutterguard_cli/lib/src/rules/iot_security.dart deleted file mode 100644 index eca57a0..0000000 --- a/packages/flutterguard_cli/lib/src/rules/iot_security.dart +++ /dev/null @@ -1,190 +0,0 @@ -import '../config_loader.dart'; -import '../domain.dart'; -import '../priority.dart'; -import '../rule_meta.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; - -final _secretPattern = RegExp( - r"""(password|token|secret|api[_]?key)\s*[:=]\s*["']""", - caseSensitive: false, -); -const _cleartextMqttPatterns = ['tcp://', 'port: 1883', 'port:1883']; -const _insecureBleKeywords = ['withoutBonding', 'withoutPairing']; -final _httpUrlPattern = RegExp(r"""['"]http://[^'"]+['"]"""); - -class IotSecurityRule { - final IotSecurityRuleConfig config; - - const IotSecurityRule(this.config); - - List analyze( - List files, { - SourceWorkspace? workspace, - }) { - if (!config.enabled) return []; - - final issues = []; - final sources = workspace ?? SourceWorkspace(); - - for (final file in files) { - final source = sources.source(file); - if (source == null) continue; - issues.addAll(_checkFile(file, source.content)); - } - - return issues; - } - - List _checkFile(String file, String content) { - final issues = []; - final lines = content.split('\n'); - - _checkHardcodedSecrets(file, lines, issues); - - if (config.requireTls) { - _checkCleartextMqtt(file, lines, issues); - _checkCleartextHttp(file, lines, issues); - } - - _checkInsecureBle(file, lines, issues); - - return issues; - } - - void _checkHardcodedSecrets( - String file, - List lines, - List issues, - ) { - for (var i = 0; i < lines.length; i++) { - if (_secretPattern.hasMatch(lines[i])) { - issues.add(StaticIssue( - id: 'iot_security', - title: 'IoT 安全 — 硬编码凭证', - file: file, - line: i + 1, - level: RiskLevel.high, - domain: IssueDomain.architecture, - priority: Priority.p0, - message: '检测到可疑的硬编码凭证', - detail: '行 ${i + 1}: ${lines[i].trim()}\n' - '硬编码凭证可能导致安全泄露,应使用环境变量或安全存储', - suggestion: '使用环境变量或安全存储方案替代硬编码凭证', - metadata: { - 'securityCheck': 'hardcoded_secret', - 'line': i + 1, - }, - )); - } - } - } - - void _checkCleartextMqtt( - String file, - List lines, - List issues, - ) { - for (var i = 0; i < lines.length; i++) { - final lower = lines[i].toLowerCase(); - for (final pattern in _cleartextMqttPatterns) { - if (lower.contains(pattern)) { - issues.add(StaticIssue( - id: 'iot_security', - title: 'IoT 安全 — 明文 MQTT 连接', - file: file, - line: i + 1, - level: RiskLevel.high, - domain: IssueDomain.architecture, - priority: Priority.p0, - message: '检测到明文 MQTT 连接配置: "$pattern"', - detail: '行 ${i + 1}: ${lines[i].trim()}\n' - '明文 MQTT 连接不安全,应使用 mqtts:// (TLS) 或端口 8883', - suggestion: '将 MQTT 连接升级为 mqtts:// 并使用端口 8883', - metadata: { - 'securityCheck': 'cleartext_mqtt', - 'pattern': pattern, - 'line': i + 1, - }, - )); - } - } - } - } - - void _checkCleartextHttp( - String file, - List lines, - List issues, - ) { - for (var i = 0; i < lines.length; i++) { - for (final match in _httpUrlPattern.allMatches(lines[i])) { - final url = match.group(0) ?? ''; - if (url.contains('localhost') || url.contains('127.0.0.1')) continue; - - issues.add(StaticIssue( - id: 'iot_security', - title: 'IoT 安全 — 明文 HTTP 连接', - file: file, - line: i + 1, - level: RiskLevel.medium, - domain: IssueDomain.architecture, - priority: Priority.p0, - message: '检测到明文 HTTP URL: $url', - detail: '行 ${i + 1}: ${lines[i].trim()}\n明文 HTTP 不安全,应使用 HTTPS', - suggestion: '将 HTTP 连接升级为 HTTPS', - metadata: { - 'securityCheck': 'cleartext_http', - 'url': url, - }, - )); - } - } - } - - void _checkInsecureBle( - String file, - List lines, - List issues, - ) { - for (var i = 0; i < lines.length; i++) { - for (final keyword in _insecureBleKeywords) { - if (lines[i].contains(keyword)) { - issues.add(StaticIssue( - id: 'iot_security', - title: 'IoT 安全 — 不安全 BLE 配置', - file: file, - line: i + 1, - level: RiskLevel.medium, - domain: IssueDomain.architecture, - priority: Priority.p0, - message: '检测到不安全的 BLE 连接配置: "$keyword"', - detail: '行 ${i + 1}: ${lines[i].trim()}\n' - 'BLE 连接应启用配对和加密 (bond / pair)', - suggestion: '启用 BLE 配对和加密配置', - metadata: { - 'securityCheck': 'insecure_ble', - 'keyword': keyword, - 'line': i + 1, - }, - )); - } - } - } - } - - static RuleMeta describe() => const RuleMeta( - id: 'iot_security', - name: 'IoT 安全风险', - domain: 'architecture', - riskLevel: 'high', - priority: 'p0', - purpose: '检测硬编码凭据、明文 MQTT/HTTP、不安全的 BLE 配置', - riskReason: '硬编码凭据泄露导致设备被入侵;明文传输导致数据窃听', - badExample: - 'password: "123456";tcp://broker:1883;BLE 使用 withoutBonding', - fixSuggestion: '使用环境变量或安全存储管理凭据;使用 TLS 加密通信', - configKeys: ['rules.iot_security.requireTls'], - cicdSafe: true, - ); -} diff --git a/packages/flutterguard_cli/lib/src/rules/large_units.dart b/packages/flutterguard_cli/lib/src/rules/large_units.dart deleted file mode 100644 index fce94a0..0000000 --- a/packages/flutterguard_cli/lib/src/rules/large_units.dart +++ /dev/null @@ -1,195 +0,0 @@ -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:path/path.dart' as p; - -import '../config_loader.dart'; -import '../domain.dart'; -import '../priority.dart'; -import '../rule_meta.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; - -class LargeUnitsRule { - final LargeFileRuleConfig largeFileConfig; - final LargeClassRuleConfig largeClassConfig; - final LargeBuildMethodRuleConfig largeBuildMethodConfig; - - const LargeUnitsRule({ - required this.largeFileConfig, - required this.largeClassConfig, - required this.largeBuildMethodConfig, - }); - - List analyze( - List files, { - SourceWorkspace? workspace, - }) { - final issues = []; - final sources = workspace ?? SourceWorkspace(); - - for (final file in files) { - final source = sources.source(file); - if (source == null) continue; - if (largeFileConfig.enabled) { - issues.addAll(_checkLargeFile(file, source.lines.length)); - } - - if (largeClassConfig.enabled || largeBuildMethodConfig.enabled) { - if (largeClassConfig.enabled) { - issues.addAll(_checkLargeClass(file, source.content, source.unit)); - } - if (largeBuildMethodConfig.enabled) { - issues.addAll(_checkLargeBuild(file, source.content, source.unit)); - } - } - } - - return issues; - } - - List _checkLargeFile(String file, int lineCount) { - if (lineCount > largeFileConfig.maxLines) { - return [ - StaticIssue( - id: 'large_file', - title: '文件过大', - file: file, - line: null, - level: RiskLevel.low, - domain: IssueDomain.standards, - priority: Priority.p2, - message: '文件 $lineCount 行(阈值: ${largeFileConfig.maxLines} 行)', - detail: '', - suggestion: '建议将 ${p.basename(file)} 拆分为更小的模块文件', - metadata: { - 'actual': lineCount, - 'threshold': largeFileConfig.maxLines, - }, - ), - ]; - } - return []; - } - - List _checkLargeClass( - String file, - String content, - CompilationUnit unit, - ) { - final issues = []; - final classes = unit.declarations.whereType(); - - for (final cls in classes) { - final startLine = content.substring(0, cls.offset).split('\n').length; - final endLine = content.substring(0, cls.end).split('\n').length; - final lineCount = endLine - startLine + 1; - - if (lineCount > largeClassConfig.maxLines) { - issues.add(StaticIssue( - id: 'large_class', - title: '类过大', - file: file, - line: startLine, - level: RiskLevel.low, - domain: IssueDomain.standards, - priority: Priority.p2, - message: - '类 "${cls.name.lexeme}" $lineCount 行(阈值: ${largeClassConfig.maxLines} 行)', - detail: '', - suggestion: '建议将 "${cls.name.lexeme}" 的职责提取到更小的类中', - metadata: { - 'actual': lineCount, - 'threshold': largeClassConfig.maxLines, - 'className': cls.name.lexeme, - }, - )); - } - } - return issues; - } - - List _checkLargeBuild( - String file, - String content, - CompilationUnit unit, - ) { - final issues = []; - final classes = unit.declarations.whereType(); - - for (final cls in classes) { - for (final member in cls.members) { - if (member is MethodDeclaration && - member.name.lexeme == 'build' && - member.returnType?.toString() == 'Widget') { - final startLine = - content.substring(0, member.offset).split('\n').length; - final endLine = content.substring(0, member.end).split('\n').length; - final lineCount = endLine - startLine + 1; - - if (lineCount > largeBuildMethodConfig.maxLines) { - issues.add(StaticIssue( - id: 'large_build_method', - title: '构建方法过长', - file: file, - line: startLine, - level: RiskLevel.medium, - domain: IssueDomain.performance, - priority: Priority.p1, - message: - '${cls.name.lexeme}.build() $lineCount 行(阈值: ${largeBuildMethodConfig.maxLines} 行)', - detail: '', - suggestion: '将 build 方法中的部分提取为更小的子组件或方法', - metadata: { - 'actual': lineCount, - 'threshold': largeBuildMethodConfig.maxLines, - 'className': cls.name.lexeme, - }, - )); - } - } - } - } - return issues; - } - - static RuleMeta describeLargeFile() => const RuleMeta( - id: 'large_file', - name: '文件过大', - domain: 'standards', - riskLevel: 'low', - priority: 'p2', - purpose: '检测单文件行数超过阈值', - riskReason: '文件过大增加认知负载,难以维护和审查', - badExample: '500+ 行的单文件包含多个不相关类', - fixSuggestion: '将逻辑独立的类或函数提取到单独文件', - configKeys: ['rules.large_file.maxLines'], - cicdSafe: true, - ); - - static RuleMeta describeLargeClass() => const RuleMeta( - id: 'large_class', - name: '类过大', - domain: 'standards', - riskLevel: 'low', - priority: 'p2', - purpose: '检测类声明行数超过阈值', - riskReason: '大类违反单一职责原则,降低可测试性', - badExample: '300+ 行的类处理多种不相关逻辑', - fixSuggestion: '将类按职责拆分为多个更小的类', - configKeys: ['rules.large_class.maxLines'], - cicdSafe: true, - ); - - static RuleMeta describeLargeBuildMethod() => const RuleMeta( - id: 'large_build_method', - name: '构建方法过长', - domain: 'performance', - riskLevel: 'medium', - priority: 'p1', - purpose: '检测 Widget build 方法行数超过阈值', - riskReason: '过长的 build 方法难以维护,且可能包含重复渲染逻辑', - badExample: '80+ 行的 build 方法包含内联样式和嵌套 widget', - fixSuggestion: '将 build 中的子 UI 提取为独立 widget 或方法', - configKeys: ['rules.large_build_method.maxLines'], - cicdSafe: true, - ); -} diff --git a/packages/flutterguard_cli/lib/src/rules/layer_violation.dart b/packages/flutterguard_cli/lib/src/rules/layer_violation.dart deleted file mode 100644 index 2f9109f..0000000 --- a/packages/flutterguard_cli/lib/src/rules/layer_violation.dart +++ /dev/null @@ -1,90 +0,0 @@ -import '../boundary_engine.dart'; -import '../config_loader.dart'; -import '../domain.dart'; -import '../import_graph.dart'; -import '../priority.dart'; -import '../rule_meta.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; - -class LayerViolationRule { - final List layers; - final String? projectPath; - - const LayerViolationRule(this.layers, {this.projectPath}); - - List analyze( - List files, { - List? allFiles, - SourceWorkspace? workspace, - ImportGraph? importGraph, - }) { - if (layers.isEmpty) return []; - - final sources = workspace ?? SourceWorkspace(); - final availableFiles = allFiles ?? files; - final graph = importGraph ?? - ImportGraph.build( - files: availableFiles, - sourceFiles: files, - workspace: sources, - projectPath: projectPath, - ); - final boundaries = [ - for (final layer in layers) - BoundaryDefinition( - name: layer.name, - path: layer.path, - allowedDeps: layer.allowedDeps, - ), - ]; - final violations = DependencyBoundaryEngine.analyze( - sourceFiles: files, - allFiles: availableFiles, - boundaries: boundaries, - graph: graph, - workspace: sources, - projectPath: projectPath, - ); - - return [ - for (final violation in violations) - StaticIssue( - id: 'layer_violation', - title: '层间依赖违规', - file: violation.edge.source, - line: violation.edge.line, - level: RiskLevel.high, - domain: IssueDomain.architecture, - priority: Priority.p0, - message: '${violation.source.name} 层不可依赖 ${violation.target.name} 层', - detail: '导入: ${violation.edge.uri}\n' - '源层: ${violation.source.name} (${violation.source.path})\n' - '目标层: ${violation.target.name} (${violation.target.path})\n' - '允许依赖: ${violation.source.allowedDeps.isEmpty ? '无' : violation.source.allowedDeps.join(', ')}', - suggestion: - '将导入的内容移至 ${violation.source.allowedDeps.isEmpty ? 'core 或更抽象层' : '${violation.source.allowedDeps.join(' 或 ')}层'}', - metadata: { - 'sourceLayer': violation.source.name, - 'targetLayer': violation.target.name, - 'imported': violation.edge.uri, - 'allowedDeps': violation.source.allowedDeps, - }, - ), - ]; - } - - static RuleMeta describe() => const RuleMeta( - id: 'layer_violation', - name: '层间依赖违规', - domain: 'architecture', - riskLevel: 'high', - priority: 'p0', - purpose: '检测架构层之间的非法依赖', - riskReason: '违反分层架构导致耦合增加,难以维护和替换', - badExample: 'presentation 层直接导入 data 层的实现', - fixSuggestion: '将跨层调用通过抽象接口进行,或调整 allowed_deps 配置', - configKeys: ['architecture.layers'], - cicdSafe: true, - ); -} diff --git a/packages/flutterguard_cli/lib/src/rules/missing_const_constructor.dart b/packages/flutterguard_cli/lib/src/rules/missing_const_constructor.dart deleted file mode 100644 index a126a0e..0000000 --- a/packages/flutterguard_cli/lib/src/rules/missing_const_constructor.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/source/line_info.dart'; - -import '../config_loader.dart'; -import '../domain.dart'; -import '../priority.dart'; -import '../rule_meta.dart'; -import '../source_utils.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; - -final _widgetTypes = {'StatelessWidget', 'StatefulWidget'}; - -class MissingConstConstructorRule { - final MissingConstConstructorRuleConfig config; - - const MissingConstConstructorRule(this.config); - - List analyze( - List files, { - SourceWorkspace? workspace, - }) { - if (!config.enabled) return []; - - final issues = []; - final sources = workspace ?? SourceWorkspace(); - - for (final file in files) { - final source = sources.source(file); - if (source == null) continue; - issues.addAll(_checkFile(file, source.unit, source.lineInfo)); - } - - return issues; - } - - List _checkFile( - String file, - CompilationUnit unit, - LineInfo lineInfo, - ) { - final issues = []; - final classes = unit.declarations.whereType(); - - for (final cls in classes) { - final extendsClause = cls.extendsClause; - if (extendsClause == null) continue; - - final superClassName = extendsClause.superclass.name2.lexeme; - if (!_widgetTypes.contains(superClassName)) { - continue; - } - - final hasConstConstructor = cls.members.any((m) { - if (m is ConstructorDeclaration) { - return m.constKeyword != null; - } - return false; - }); - - if (hasConstConstructor) continue; - - final line = lineNumberForOffset(lineInfo, cls.name.offset); - issues.add(StaticIssue( - id: 'missing_const_constructor', - title: 'Widget 缺少 const 构造函数', - file: file, - line: line, - level: RiskLevel.low, - domain: IssueDomain.standards, - priority: Priority.p2, - message: '"${cls.name.lexeme}" 是 $superClassName 子类但缺少 const 构造函数', - detail: '类: ${cls.name.lexeme}\n超类: $superClassName', - suggestion: '添加 const 构造函数: "const ${cls.name.lexeme}({super.key});"', - metadata: { - 'className': cls.name.lexeme, - 'superClass': superClassName, - }, - )); - } - - return issues; - } - - static RuleMeta describe() => const RuleMeta( - id: 'missing_const_constructor', - name: '缺少 const 构造函数', - domain: 'standards', - riskLevel: 'low', - priority: 'p2', - purpose: '检测 Widget 子类是否缺少 const 构造函数', - riskReason: '缺少 const 构造函数导致 widget 无法复用实例,影响渲染性能', - badExample: - 'class MyWidget extends StatelessWidget { ... } 无 const 构造函数', - fixSuggestion: '为 Widget 类添加 const 构造函数', - cicdSafe: true, - ); -} diff --git a/packages/flutterguard_cli/lib/src/rules/module_violation.dart b/packages/flutterguard_cli/lib/src/rules/module_violation.dart deleted file mode 100644 index 8a89f24..0000000 --- a/packages/flutterguard_cli/lib/src/rules/module_violation.dart +++ /dev/null @@ -1,90 +0,0 @@ -import '../boundary_engine.dart'; -import '../config_loader.dart'; -import '../domain.dart'; -import '../import_graph.dart'; -import '../priority.dart'; -import '../rule_meta.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; - -class ModuleViolationRule { - final List modules; - final String? projectPath; - - const ModuleViolationRule(this.modules, {this.projectPath}); - - List analyze( - List files, { - List? allFiles, - SourceWorkspace? workspace, - ImportGraph? importGraph, - }) { - if (modules.isEmpty) return []; - final sources = workspace ?? SourceWorkspace(); - final availableFiles = allFiles ?? files; - final graph = importGraph ?? - ImportGraph.build( - files: availableFiles, - sourceFiles: files, - workspace: sources, - projectPath: projectPath, - ); - final boundaries = [ - for (final module in modules) - BoundaryDefinition( - name: module.name, - path: module.path, - allowedDeps: module.allowedDeps, - ), - ]; - final violations = DependencyBoundaryEngine.analyze( - sourceFiles: files, - allFiles: availableFiles, - boundaries: boundaries, - graph: graph, - workspace: sources, - projectPath: projectPath, - ); - - return [ - for (final violation in violations) - StaticIssue( - id: 'module_violation', - title: '模块间非法依赖', - file: violation.edge.source, - line: violation.edge.line, - level: RiskLevel.high, - domain: IssueDomain.architecture, - priority: Priority.p0, - message: - '模块 ${violation.source.name} 不可依赖模块 ${violation.target.name}', - detail: '导入: ${violation.edge.uri}\n' - '源模块: ${violation.source.name} (${violation.source.path})\n' - '目标模块: ${violation.target.name} (${violation.target.path})\n' - '允许依赖: ${violation.source.allowedDeps.isEmpty ? '无' : violation.source.allowedDeps.join(', ')}', - suggestion: - '通过 ${violation.source.allowedDeps.isEmpty ? 'core 层共享接口解耦' : '${violation.source.allowedDeps.join(' 或 ')}解耦'}', - metadata: { - 'sourceModule': violation.source.name, - 'targetModule': violation.target.name, - 'imported': violation.edge.uri, - 'allowedDeps': violation.source.allowedDeps, - }, - ), - ]; - } - - static RuleMeta describe() => const RuleMeta( - id: 'module_violation', - name: '模块间依赖违规', - domain: 'architecture', - riskLevel: 'high', - priority: 'p0', - purpose: '检测业务模块之间的非法依赖', - riskReason: '模块间非法依赖破坏隔离性,导致耦合和回归风险', - badExample: 'mqtt 模块直接导入 ble 模块的类', - fixSuggestion: '提取公共依赖到 shared 模块,或通过事件总线解耦', - configKeys: ['architecture.modules'], - cicdSafe: true, - ); -} diff --git a/packages/flutterguard_cli/lib/src/rules/mqtt_connection.dart b/packages/flutterguard_cli/lib/src/rules/mqtt_connection.dart deleted file mode 100644 index cf84788..0000000 --- a/packages/flutterguard_cli/lib/src/rules/mqtt_connection.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/source/line_info.dart'; - -import '../config_loader.dart'; -import '../domain.dart'; -import '../priority.dart'; -import '../rule_meta.dart'; -import '../source_utils.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; - -const _mqttClientTypes = ['MqttClient', 'MQTT', 'MqttConnect']; -const _brokerUrlPrefixes = ['tcp://', 'mqtt://', 'mqtts://']; - -class MqttConnectionRule { - final MqttConnectionRuleConfig config; - - const MqttConnectionRule(this.config); - - List analyze( - List files, { - SourceWorkspace? workspace, - }) { - if (!config.enabled) return []; - - final issues = []; - final sources = workspace ?? SourceWorkspace(); - - for (final file in files) { - final source = sources.source(file); - if (source == null) continue; - issues.addAll( - _checkFile(file, source.content, source.unit, source.lineInfo), - ); - } - - return issues; - } - - List _checkFile( - String file, - String rawContent, - CompilationUnit unit, - LineInfo lineInfo, - ) { - final issues = []; - - _checkHardcodedBroker(file, rawContent, lineInfo, issues); - - for (final cls in unit.declarations.whereType()) { - final hasMqttField = cls.members.whereType().where((f) { - final type = f.fields.type?.toString() ?? ''; - return _mqttClientTypes.any((t) => type.contains(t)); - }).isNotEmpty; - - if (!hasMqttField) continue; - - final methods = cls.members.whereType().toList(); - final methodNames = methods.map((m) => m.name.lexeme).toSet(); - - if (methodNames.contains('connect') && - !methodNames.contains('disconnect')) { - final connectMethod = - methods.firstWhere((m) => m.name.lexeme == 'connect'); - final line = lineNumberForOffset(lineInfo, connectMethod.name.offset); - issues.add(StaticIssue( - id: 'mqtt_connection', - title: 'MQTT 连接未断开', - file: file, - line: line, - level: RiskLevel.high, - domain: IssueDomain.architecture, - priority: Priority.p0, - message: - '类 "${cls.name.lexeme}" 包含 MQTT connect() 调用但缺少 disconnect()', - detail: '类: ${cls.name.lexeme}\n' - 'MqttClient 需要连接与断开配对', - suggestion: '在类中添加 disconnect() 方法并在 dispose 中调用', - metadata: { - 'className': cls.name.lexeme, - 'check': 'connect_without_disconnect', - }, - )); - } - - if (methodNames.contains('subscribe') && - !methodNames.contains('unsubscribe')) { - final subscribeMethod = - methods.firstWhere((m) => m.name.lexeme == 'subscribe'); - final line = lineNumberForOffset(lineInfo, subscribeMethod.name.offset); - issues.add(StaticIssue( - id: 'mqtt_connection', - title: 'MQTT 订阅未取消', - file: file, - line: line, - level: RiskLevel.medium, - domain: IssueDomain.architecture, - priority: Priority.p0, - message: - '类 "${cls.name.lexeme}" 包含 MQTT subscribe() 调用但缺少 unsubscribe()', - detail: '类: ${cls.name.lexeme}\n' - 'MQTT 订阅应在不需要时取消', - suggestion: '在类中添加 unsubscribe() 方法并在 dispose 中调用', - metadata: { - 'className': cls.name.lexeme, - 'check': 'subscribe_without_unsubscribe', - }, - )); - } - } - - return issues; - } - - void _checkHardcodedBroker( - String file, - String content, - LineInfo lineInfo, - List issues, - ) { - final lines = content.split('\n'); - for (var i = 0; i < lines.length; i++) { - for (final prefix in _brokerUrlPrefixes) { - if (lines[i].contains(prefix)) { - issues.add(StaticIssue( - id: 'mqtt_connection', - title: 'MQTT — 硬编码 Broker URL', - file: file, - line: i + 1, - level: RiskLevel.medium, - domain: IssueDomain.architecture, - priority: Priority.p0, - message: '检测到硬编码的 MQTT broker URL', - detail: '行 ${i + 1}: ${lines[i].trim()}\n' - '硬编码 broker URL 降低灵活性和可维护性', - suggestion: '将 MQTT broker URL 移至配置文件中', - metadata: { - 'check': 'hardcoded_broker_url', - 'line': i + 1, - }, - )); - } - } - } - } - - static RuleMeta describe() => const RuleMeta( - id: 'mqtt_connection', - name: 'MQTT 连接管理异常', - domain: 'architecture', - riskLevel: 'high', - priority: 'p0', - purpose: - '检测 MQTT connect/disconnect、subscribe/unsubscribe 配对以及硬编码 broker URL', - riskReason: '未配对的 MQTT 调用导致连接泄漏;硬编码 URL 导致安全风险和配置问题', - badExample: - '调用 connect() 后无 disconnect();URL 直接写 tcp://mqtt.example.com:1883', - fixSuggestion: '在 dispose 中添加 disconnect();将 broker URL 提取到配置文件', - cicdSafe: true, - ); -} diff --git a/packages/flutterguard_cli/lib/src/rules/pubspec_security.dart b/packages/flutterguard_cli/lib/src/rules/pubspec_security.dart deleted file mode 100644 index 152c6bc..0000000 --- a/packages/flutterguard_cli/lib/src/rules/pubspec_security.dart +++ /dev/null @@ -1,200 +0,0 @@ -import 'dart:io'; - -import 'package:path/path.dart' as p; -import 'package:yaml/yaml.dart'; - -import '../config_loader.dart'; -import '../domain.dart'; -import '../priority.dart'; -import '../rule_meta.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; - -const _vulnerableDeps = { - 'mqtt_client': '10.0.0', - 'http': '1.0.0', -}; - -const _deprecatedPackages = { - 'flutter_blue': 'flutter_blue_plus', -}; - -class PubspecSecurityRule { - final PubspecSecurityRuleConfig config; - - const PubspecSecurityRule(this.config); - - List analyze( - List files, { - String? projectPath, - SourceWorkspace? workspace, - }) { - if (!config.enabled) return []; - - final issues = []; - final candidateDirectories = projectPath == null - ? {for (final file in files) p.dirname(file)} - : {projectPath}; - - for (final directory in candidateDirectories) { - final pubspec = p.join(directory, 'pubspec.yaml'); - if (!File(pubspec).existsSync()) continue; - - try { - final content = File(pubspec).readAsStringSync(); - final yaml = loadYaml(content); - if (yaml is! YamlMap) continue; - issues.addAll(_checkPubspec(pubspec, yaml)); - } on Object catch (error) { - workspace?.addDiagnostic(ScanDiagnostic( - stage: 'pubspec_parse', - file: pubspec, - message: error.toString(), - severity: ScanDiagnosticSeverity.error, - )); - } - } - - return _deduplicate(issues); - } - - List _checkPubspec(String pubspecPath, YamlMap root) { - final issues = []; - - final dependencies = {}; - final devDependencies = {}; - - _collectDeps(root['dependencies'], dependencies); - _collectDeps(root['dev_dependencies'], devDependencies); - - for (final dep in {...dependencies.keys, ...devDependencies.keys}) { - final version = dependencies[dep] ?? devDependencies[dep] ?? ''; - - if (version.isEmpty || version == 'any') { - issues.add(StaticIssue( - id: 'pubspec_security', - title: '依赖安全 — 无界版本', - file: pubspecPath, - line: null, - level: RiskLevel.medium, - domain: IssueDomain.standards, - priority: Priority.p2, - message: '依赖 "$dep" 没有版本约束 ($version)', - detail: '包: $dep\n' - '版本: ${version.isEmpty ? "未指定" : version}\n' - '无界依赖可能导致不兼容的版本被引入', - suggestion: '为 "$dep" 添加具体的版本约束 (如 ^1.0.0)', - metadata: { - 'package': dep, - 'version': version, - 'check': 'unbounded_dependency', - }, - )); - } - - if (_deprecatedPackages.containsKey(dep)) { - final replacement = _deprecatedPackages[dep]!; - issues.add(StaticIssue( - id: 'pubspec_security', - title: '依赖安全 — 已废弃包', - file: pubspecPath, - line: null, - level: RiskLevel.high, - domain: IssueDomain.standards, - priority: Priority.p2, - message: '"$dep" 已废弃,应迁移至 "$replacement"', - detail: '包: $dep\n' - '替代: $replacement\n' - '$dep 已不再维护,存在安全风险', - suggestion: '将 "$dep" 替换为 "$replacement"', - metadata: { - 'package': dep, - 'replacement': replacement, - 'check': 'deprecated_package', - }, - )); - } - - if (_vulnerableDeps.containsKey(dep)) { - final minVersion = _vulnerableDeps[dep]!; - final currentVersion = _cleanVersion(version); - if (currentVersion.isNotEmpty && - _compareVersion(currentVersion, minVersion) < 0) { - issues.add(StaticIssue( - id: 'pubspec_security', - title: '依赖安全 — 过旧版本', - file: pubspecPath, - line: null, - level: RiskLevel.high, - domain: IssueDomain.standards, - priority: Priority.p2, - message: '"$dep" 版本 $currentVersion 低于推荐的最低版本 $minVersion', - detail: '包: $dep\n' - '当前: $currentVersion\n' - '最低推荐: $minVersion\n' - '旧版本可能包含已知安全漏洞', - suggestion: '将 "$dep" 升级至至少 $minVersion', - metadata: { - 'package': dep, - 'currentVersion': currentVersion, - 'minVersion': minVersion, - 'check': 'outdated_dependency', - }, - )); - } - } - } - - return issues; - } - - void _collectDeps(dynamic deps, Map target) { - if (deps is! YamlMap) return; - for (final entry in deps.entries) { - final name = entry.key.toString(); - final value = entry.value; - if (value is String) { - target[name] = value; - } else if (value is YamlMap) { - target[name] = value['version']?.toString() ?? ''; - } - } - } - - String _cleanVersion(String version) { - return version.replaceAll(RegExp(r'[\^~]'), '').trim(); - } - - int _compareVersion(String a, String b) { - final aParts = a.split('.').map((e) => int.tryParse(e) ?? 0).toList(); - final bParts = b.split('.').map((e) => int.tryParse(e) ?? 0).toList(); - for (var i = 0; i < aParts.length && i < bParts.length; i++) { - final cmp = aParts[i].compareTo(bParts[i]); - if (cmp != 0) return cmp; - } - return aParts.length.compareTo(bParts.length); - } - - List _deduplicate(List issues) { - final seen = {}; - return issues.where((i) { - final key = '${i.id}|${i.file}|${i.line}|${i.message}'; - if (seen.contains(key)) return false; - seen.add(key); - return true; - }).toList(); - } - - static RuleMeta describe() => const RuleMeta( - id: 'pubspec_security', - name: '依赖安全风险', - domain: 'standards', - riskLevel: 'medium', - priority: 'p2', - purpose: '检测依赖版本无界、已废弃包、过旧版本', - riskReason: '无界依赖引入不兼容更新;废弃包存在安全漏洞', - badExample: 'mqtt_client: ^9.0.0(低于 10.0.0);flutter_blue(已废弃)', - fixSuggestion: '固定大版本号;将 flutter_blue 迁移至 flutter_blue_plus', - cicdSafe: true, - ); -} diff --git a/packages/flutterguard_cli/lib/src/rules/registry.dart b/packages/flutterguard_cli/lib/src/rules/registry.dart deleted file mode 100644 index 30777d5..0000000 --- a/packages/flutterguard_cli/lib/src/rules/registry.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:flutterguard_cli/src/rule_meta.dart'; -import 'package:flutterguard_cli/src/rules/catalog.dart'; - -class RuleRegistry { - static final Map _registry = () { - final list = RuleCatalog.metadata(); - return {for (final m in list) m.id: m}; - }(); - - static List all() => _registry.values.toList(); - - static RuleMeta? find(String id) => _registry[id]; -} diff --git a/packages/flutterguard_cli/lib/src/source_utils.dart b/packages/flutterguard_cli/lib/src/source_utils.dart deleted file mode 100644 index 89d49e0..0000000 --- a/packages/flutterguard_cli/lib/src/source_utils.dart +++ /dev/null @@ -1,4 +0,0 @@ -import 'package:analyzer/source/line_info.dart'; - -int lineNumberForOffset(LineInfo lineInfo, int offset) => - lineInfo.getLocation(offset).lineNumber; diff --git a/packages/flutterguard_cli/pubspec.yaml b/packages/flutterguard_cli/pubspec.yaml deleted file mode 100644 index 89f78ea..0000000 --- a/packages/flutterguard_cli/pubspec.yaml +++ /dev/null @@ -1,28 +0,0 @@ -name: flutterguard_cli -description: IoT Flutter static analysis CLI for architecture enforcement, code quality, and CI gating. Scans Dart source for layer violations, lifecycle resource leaks, circular dependencies, and more. -version: 0.5.0 -repository: https://github.com/lizy-coding/flutterguard -issue_tracker: https://github.com/lizy-coding/flutterguard/issues -topics: - - static-analysis - - flutter - - iot - - architecture - - cli - -environment: - sdk: ">=3.3.0 <4.0.0" - -dependencies: - args: ^2.5.0 - analyzer: ^7.3.0 - glob: ^2.1.2 - path: ^1.9.0 - yaml: ^3.1.2 - -executables: - flutterguard: flutterguard - -dev_dependencies: - test: ^1.25.8 - lints: ^3.0.0 diff --git a/packages/flutterguard_cli/test/AGENTS.md b/packages/flutterguard_cli/test/AGENTS.md deleted file mode 100644 index fd15f84..0000000 --- a/packages/flutterguard_cli/test/AGENTS.md +++ /dev/null @@ -1,31 +0,0 @@ -# Test Layer - -## Responsibility -Tests verify rule behavior, scanner orchestration, report generation, and cross-platform path handling. - -## Test Files -- `scanner_test.dart`: reusable scanner/rule integration suite (57 tests, 7 groups). -- `cli_test.dart`: process-level CLI exit and report behavior (4 tests). - -## Test Groups -| Group | Tests | Coverage | -|-------|-------|---------| -| Static Rules | 18 | 8 existing rules + 5 IoT rules + config parsing + wiring | -| Report Generation | 3 | JSON, stdout, and suppression summary output | -| Scanner Orchestration | 16 | Scan policy, root pubspec, diagnostics, suppression, baseline, SARIF, issue export | -| Changed-only | 7 | Git filtering, project rules, architecture target resolution, clean scans, invalid refs, non-Git fallback, cycle behavior | -| Registry | 3 | Rule metadata lookup | -| Config Tools | 7 | Init profiles, effective config, doctor, install diagnostics | -| Path Handling | 3 | Windows globs, package imports, cross-platform import resolution | -| CLI Process | 4 | Exit codes, config scoping/enforcement, empty changed JSON report | - -## Rules -- Add a fixture for every new rule or regression case. -- Keep fixtures small unless testing size thresholds. -- Test Windows path behavior using `package:path` contexts instead of requiring Windows. -- Prefer testing reusable `lib/src/` behavior directly; only shell out to the CLI when validating argument/exit behavior. -- Temporary files created by tests must be deleted with `addTearDown`. -- pubspec_security tests use `Directory.systemTemp.createTempSync()` for isolated pubspec.yaml. - -## Required Command -Run `dart run melos run test:cli` after test changes. diff --git a/packages/flutterguard_cli/test/fixtures/AGENTS.md b/packages/flutterguard_cli/test/fixtures/AGENTS.md deleted file mode 100644 index 1bc56f2..0000000 --- a/packages/flutterguard_cli/test/fixtures/AGENTS.md +++ /dev/null @@ -1,28 +0,0 @@ -# Fixture Layer - -## Responsibility -This directory contains intentionally imperfect Dart/YAML files used by CLI rule tests (17 fixture files). - -## Fixture Inventory -| File | Rule | -|------|------| -| `large_file.dart` | large_file | -| `large_class.dart` | large_class | -| `large_build.dart` | large_build_method | -| `lifecycle_issue.dart` | lifecycle_resource_not_disposed | -| `boundary_issue.dart` + `forbidden_file.dart` | layer_violation, module_violation | -| `cycle_a/b/c.dart` | circular_dependency | -| `missing_const.dart` | missing_const_constructor | -| `iot_security_issue.dart` | iot_security | -| `device_lifecycle_issue.dart` | device_lifecycle | -| `mqtt_connection_issue.dart` | mqtt_connection | -| `ble_scanning_issue.dart` | ble_scanning | -| `architecture_config.yaml` / `architecture_disabled.yaml` | architecture config parsing | - -## Rules -- Fixtures may intentionally violate style or architecture rules. -- Keep fixture names tied to the rule or scenario they exercise. -- Do not import app dependencies; fixtures should remain plain Dart snippets where possible. -- When adding architecture fixtures, update or add a matching YAML config. -- Avoid broad fixture changes because many tests can depend on the same file. -- pubspec_security tests create isolated YAML fixtures in temp directories. diff --git a/packages/flutterguard_cli/test/fixtures/device_lifecycle_issue.dart b/packages/flutterguard_cli/test/fixtures/device_lifecycle_issue.dart deleted file mode 100644 index 477932c..0000000 --- a/packages/flutterguard_cli/test/fixtures/device_lifecycle_issue.dart +++ /dev/null @@ -1,8 +0,0 @@ -// Fixture: device lifecycle issue — initState without dispose -class DeviceWidget { - void initState() { - // connect to device - } - - // dispose() is missing -} diff --git a/packages/flutterguard_cli/test/fixtures/large_build.dart b/packages/flutterguard_cli/test/fixtures/large_build.dart deleted file mode 100644 index 859a000..0000000 --- a/packages/flutterguard_cli/test/fixtures/large_build.dart +++ /dev/null @@ -1,105 +0,0 @@ -class Widget {} -class BuildContext {} -class StatelessWidget {} -class SizedBox { - const SizedBox({double? height}); -} -class Column { - const Column({List children}); -} -class Text { - const Text(String data); -} - -class LargeBuildWidget extends StatelessWidget { - Widget build(BuildContext context) { - return Column( - children: const [ - SizedBox(height: 1), - SizedBox(height: 2), - SizedBox(height: 3), - SizedBox(height: 4), - SizedBox(height: 5), - SizedBox(height: 6), - SizedBox(height: 7), - SizedBox(height: 8), - SizedBox(height: 9), - SizedBox(height: 10), - SizedBox(height: 11), - SizedBox(height: 12), - SizedBox(height: 13), - SizedBox(height: 14), - SizedBox(height: 15), - SizedBox(height: 16), - SizedBox(height: 17), - SizedBox(height: 18), - SizedBox(height: 19), - SizedBox(height: 20), - SizedBox(height: 21), - SizedBox(height: 22), - SizedBox(height: 23), - SizedBox(height: 24), - SizedBox(height: 25), - SizedBox(height: 26), - SizedBox(height: 27), - SizedBox(height: 28), - SizedBox(height: 29), - SizedBox(height: 30), - SizedBox(height: 31), - SizedBox(height: 32), - SizedBox(height: 33), - SizedBox(height: 34), - SizedBox(height: 35), - SizedBox(height: 36), - SizedBox(height: 37), - SizedBox(height: 38), - SizedBox(height: 39), - SizedBox(height: 40), - SizedBox(height: 41), - SizedBox(height: 42), - SizedBox(height: 43), - SizedBox(height: 44), - SizedBox(height: 45), - SizedBox(height: 46), - SizedBox(height: 47), - SizedBox(height: 48), - SizedBox(height: 49), - SizedBox(height: 50), - SizedBox(height: 51), - SizedBox(height: 52), - SizedBox(height: 53), - SizedBox(height: 54), - SizedBox(height: 55), - SizedBox(height: 56), - SizedBox(height: 57), - SizedBox(height: 58), - SizedBox(height: 59), - SizedBox(height: 60), - SizedBox(height: 61), - SizedBox(height: 62), - SizedBox(height: 63), - SizedBox(height: 64), - SizedBox(height: 65), - SizedBox(height: 66), - SizedBox(height: 67), - SizedBox(height: 68), - SizedBox(height: 69), - SizedBox(height: 70), - SizedBox(height: 71), - SizedBox(height: 72), - SizedBox(height: 73), - SizedBox(height: 74), - SizedBox(height: 75), - SizedBox(height: 76), - SizedBox(height: 77), - SizedBox(height: 78), - SizedBox(height: 79), - SizedBox(height: 80), - SizedBox(height: 81), - SizedBox(height: 82), - SizedBox(height: 83), - Text('end'), - ], - ); - } -} diff --git a/packages/flutterguard_cli/test/fixtures/large_class.dart b/packages/flutterguard_cli/test/fixtures/large_class.dart deleted file mode 100644 index 2707bac..0000000 --- a/packages/flutterguard_cli/test/fixtures/large_class.dart +++ /dev/null @@ -1,303 +0,0 @@ -class LargeClass { - // filler line 1 to make this class very large at least 300 lines long for testing purposes - // filler line 2 to make this class very large at least 300 lines long for testing purposes - // filler line 3 to make this class very large at least 300 lines long for testing purposes - // filler line 4 to make this class very large at least 300 lines long for testing purposes - // filler line 5 to make this class very large at least 300 lines long for testing purposes - // filler line 6 to make this class very large at least 300 lines long for testing purposes - // filler line 7 to make this class very large at least 300 lines long for testing purposes - // filler line 8 to make this class very large at least 300 lines long for testing purposes - // filler line 9 to make this class very large at least 300 lines long for testing purposes - // filler line 10 to make this class very large at least 300 lines long for testing purposes - // filler line 11 to make this class very large at least 300 lines long for testing purposes - // filler line 12 to make this class very large at least 300 lines long for testing purposes - // filler line 13 to make this class very large at least 300 lines long for testing purposes - // filler line 14 to make this class very large at least 300 lines long for testing purposes - // filler line 15 to make this class very large at least 300 lines long for testing purposes - // filler line 16 to make this class very large at least 300 lines long for testing purposes - // filler line 17 to make this class very large at least 300 lines long for testing purposes - // filler line 18 to make this class very large at least 300 lines long for testing purposes - // filler line 19 to make this class very large at least 300 lines long for testing purposes - // filler line 20 to make this class very large at least 300 lines long for testing purposes - // filler line 21 to make this class very large at least 300 lines long for testing purposes - // filler line 22 to make this class very large at least 300 lines long for testing purposes - // filler line 23 to make this class very large at least 300 lines long for testing purposes - // filler line 24 to make this class very large at least 300 lines long for testing purposes - // filler line 25 to make this class very large at least 300 lines long for testing purposes - // filler line 26 to make this class very large at least 300 lines long for testing purposes - // filler line 27 to make this class very large at least 300 lines long for testing purposes - // filler line 28 to make this class very large at least 300 lines long for testing purposes - // filler line 29 to make this class very large at least 300 lines long for testing purposes - // filler line 30 to make this class very large at least 300 lines long for testing purposes - // filler line 31 to make this class very large at least 300 lines long for testing purposes - // filler line 32 to make this class very large at least 300 lines long for testing purposes - // filler line 33 to make this class very large at least 300 lines long for testing purposes - // filler line 34 to make this class very large at least 300 lines long for testing purposes - // filler line 35 to make this class very large at least 300 lines long for testing purposes - // filler line 36 to make this class very large at least 300 lines long for testing purposes - // filler line 37 to make this class very large at least 300 lines long for testing purposes - // filler line 38 to make this class very large at least 300 lines long for testing purposes - // filler line 39 to make this class very large at least 300 lines long for testing purposes - // filler line 40 to make this class very large at least 300 lines long for testing purposes - // filler line 41 to make this class very large at least 300 lines long for testing purposes - // filler line 42 to make this class very large at least 300 lines long for testing purposes - // filler line 43 to make this class very large at least 300 lines long for testing purposes - // filler line 44 to make this class very large at least 300 lines long for testing purposes - // filler line 45 to make this class very large at least 300 lines long for testing purposes - // filler line 46 to make this class very large at least 300 lines long for testing purposes - // filler line 47 to make this class very large at least 300 lines long for testing purposes - // filler line 48 to make this class very large at least 300 lines long for testing purposes - // filler line 49 to make this class very large at least 300 lines long for testing purposes - // filler line 50 to make this class very large at least 300 lines long for testing purposes - // filler line 51 to make this class very large at least 300 lines long for testing purposes - // filler line 52 to make this class very large at least 300 lines long for testing purposes - // filler line 53 to make this class very large at least 300 lines long for testing purposes - // filler line 54 to make this class very large at least 300 lines long for testing purposes - // filler line 55 to make this class very large at least 300 lines long for testing purposes - // filler line 56 to make this class very large at least 300 lines long for testing purposes - // filler line 57 to make this class very large at least 300 lines long for testing purposes - // filler line 58 to make this class very large at least 300 lines long for testing purposes - // filler line 59 to make this class very large at least 300 lines long for testing purposes - // filler line 60 to make this class very large at least 300 lines long for testing purposes - // filler line 61 to make this class very large at least 300 lines long for testing purposes - // filler line 62 to make this class very large at least 300 lines long for testing purposes - // filler line 63 to make this class very large at least 300 lines long for testing purposes - // filler line 64 to make this class very large at least 300 lines long for testing purposes - // filler line 65 to make this class very large at least 300 lines long for testing purposes - // filler line 66 to make this class very large at least 300 lines long for testing purposes - // filler line 67 to make this class very large at least 300 lines long for testing purposes - // filler line 68 to make this class very large at least 300 lines long for testing purposes - // filler line 69 to make this class very large at least 300 lines long for testing purposes - // filler line 70 to make this class very large at least 300 lines long for testing purposes - // filler line 71 to make this class very large at least 300 lines long for testing purposes - // filler line 72 to make this class very large at least 300 lines long for testing purposes - // filler line 73 to make this class very large at least 300 lines long for testing purposes - // filler line 74 to make this class very large at least 300 lines long for testing purposes - // filler line 75 to make this class very large at least 300 lines long for testing purposes - // filler line 76 to make this class very large at least 300 lines long for testing purposes - // filler line 77 to make this class very large at least 300 lines long for testing purposes - // filler line 78 to make this class very large at least 300 lines long for testing purposes - // filler line 79 to make this class very large at least 300 lines long for testing purposes - // filler line 80 to make this class very large at least 300 lines long for testing purposes - // filler line 81 to make this class very large at least 300 lines long for testing purposes - // filler line 82 to make this class very large at least 300 lines long for testing purposes - // filler line 83 to make this class very large at least 300 lines long for testing purposes - // filler line 84 to make this class very large at least 300 lines long for testing purposes - // filler line 85 to make this class very large at least 300 lines long for testing purposes - // filler line 86 to make this class very large at least 300 lines long for testing purposes - // filler line 87 to make this class very large at least 300 lines long for testing purposes - // filler line 88 to make this class very large at least 300 lines long for testing purposes - // filler line 89 to make this class very large at least 300 lines long for testing purposes - // filler line 90 to make this class very large at least 300 lines long for testing purposes - // filler line 91 to make this class very large at least 300 lines long for testing purposes - // filler line 92 to make this class very large at least 300 lines long for testing purposes - // filler line 93 to make this class very large at least 300 lines long for testing purposes - // filler line 94 to make this class very large at least 300 lines long for testing purposes - // filler line 95 to make this class very large at least 300 lines long for testing purposes - // filler line 96 to make this class very large at least 300 lines long for testing purposes - // filler line 97 to make this class very large at least 300 lines long for testing purposes - // filler line 98 to make this class very large at least 300 lines long for testing purposes - // filler line 99 to make this class very large at least 300 lines long for testing purposes - // filler line 100 to make this class very large at least 300 lines long for testing purposes - // filler line 101 to make this class very large at least 300 lines long for testing purposes - // filler line 102 to make this class very large at least 300 lines long for testing purposes - // filler line 103 to make this class very large at least 300 lines long for testing purposes - // filler line 104 to make this class very large at least 300 lines long for testing purposes - // filler line 105 to make this class very large at least 300 lines long for testing purposes - // filler line 106 to make this class very large at least 300 lines long for testing purposes - // filler line 107 to make this class very large at least 300 lines long for testing purposes - // filler line 108 to make this class very large at least 300 lines long for testing purposes - // filler line 109 to make this class very large at least 300 lines long for testing purposes - // filler line 110 to make this class very large at least 300 lines long for testing purposes - // filler line 111 to make this class very large at least 300 lines long for testing purposes - // filler line 112 to make this class very large at least 300 lines long for testing purposes - // filler line 113 to make this class very large at least 300 lines long for testing purposes - // filler line 114 to make this class very large at least 300 lines long for testing purposes - // filler line 115 to make this class very large at least 300 lines long for testing purposes - // filler line 116 to make this class very large at least 300 lines long for testing purposes - // filler line 117 to make this class very large at least 300 lines long for testing purposes - // filler line 118 to make this class very large at least 300 lines long for testing purposes - // filler line 119 to make this class very large at least 300 lines long for testing purposes - // filler line 120 to make this class very large at least 300 lines long for testing purposes - // filler line 121 to make this class very large at least 300 lines long for testing purposes - // filler line 122 to make this class very large at least 300 lines long for testing purposes - // filler line 123 to make this class very large at least 300 lines long for testing purposes - // filler line 124 to make this class very large at least 300 lines long for testing purposes - // filler line 125 to make this class very large at least 300 lines long for testing purposes - // filler line 126 to make this class very large at least 300 lines long for testing purposes - // filler line 127 to make this class very large at least 300 lines long for testing purposes - // filler line 128 to make this class very large at least 300 lines long for testing purposes - // filler line 129 to make this class very large at least 300 lines long for testing purposes - // filler line 130 to make this class very large at least 300 lines long for testing purposes - // filler line 131 to make this class very large at least 300 lines long for testing purposes - // filler line 132 to make this class very large at least 300 lines long for testing purposes - // filler line 133 to make this class very large at least 300 lines long for testing purposes - // filler line 134 to make this class very large at least 300 lines long for testing purposes - // filler line 135 to make this class very large at least 300 lines long for testing purposes - // filler line 136 to make this class very large at least 300 lines long for testing purposes - // filler line 137 to make this class very large at least 300 lines long for testing purposes - // filler line 138 to make this class very large at least 300 lines long for testing purposes - // filler line 139 to make this class very large at least 300 lines long for testing purposes - // filler line 140 to make this class very large at least 300 lines long for testing purposes - // filler line 141 to make this class very large at least 300 lines long for testing purposes - // filler line 142 to make this class very large at least 300 lines long for testing purposes - // filler line 143 to make this class very large at least 300 lines long for testing purposes - // filler line 144 to make this class very large at least 300 lines long for testing purposes - // filler line 145 to make this class very large at least 300 lines long for testing purposes - // filler line 146 to make this class very large at least 300 lines long for testing purposes - // filler line 147 to make this class very large at least 300 lines long for testing purposes - // filler line 148 to make this class very large at least 300 lines long for testing purposes - // filler line 149 to make this class very large at least 300 lines long for testing purposes - // filler line 150 to make this class very large at least 300 lines long for testing purposes - // filler line 151 to make this class very large at least 300 lines long for testing purposes - // filler line 152 to make this class very large at least 300 lines long for testing purposes - // filler line 153 to make this class very large at least 300 lines long for testing purposes - // filler line 154 to make this class very large at least 300 lines long for testing purposes - // filler line 155 to make this class very large at least 300 lines long for testing purposes - // filler line 156 to make this class very large at least 300 lines long for testing purposes - // filler line 157 to make this class very large at least 300 lines long for testing purposes - // filler line 158 to make this class very large at least 300 lines long for testing purposes - // filler line 159 to make this class very large at least 300 lines long for testing purposes - // filler line 160 to make this class very large at least 300 lines long for testing purposes - // filler line 161 to make this class very large at least 300 lines long for testing purposes - // filler line 162 to make this class very large at least 300 lines long for testing purposes - // filler line 163 to make this class very large at least 300 lines long for testing purposes - // filler line 164 to make this class very large at least 300 lines long for testing purposes - // filler line 165 to make this class very large at least 300 lines long for testing purposes - // filler line 166 to make this class very large at least 300 lines long for testing purposes - // filler line 167 to make this class very large at least 300 lines long for testing purposes - // filler line 168 to make this class very large at least 300 lines long for testing purposes - // filler line 169 to make this class very large at least 300 lines long for testing purposes - // filler line 170 to make this class very large at least 300 lines long for testing purposes - // filler line 171 to make this class very large at least 300 lines long for testing purposes - // filler line 172 to make this class very large at least 300 lines long for testing purposes - // filler line 173 to make this class very large at least 300 lines long for testing purposes - // filler line 174 to make this class very large at least 300 lines long for testing purposes - // filler line 175 to make this class very large at least 300 lines long for testing purposes - // filler line 176 to make this class very large at least 300 lines long for testing purposes - // filler line 177 to make this class very large at least 300 lines long for testing purposes - // filler line 178 to make this class very large at least 300 lines long for testing purposes - // filler line 179 to make this class very large at least 300 lines long for testing purposes - // filler line 180 to make this class very large at least 300 lines long for testing purposes - // filler line 181 to make this class very large at least 300 lines long for testing purposes - // filler line 182 to make this class very large at least 300 lines long for testing purposes - // filler line 183 to make this class very large at least 300 lines long for testing purposes - // filler line 184 to make this class very large at least 300 lines long for testing purposes - // filler line 185 to make this class very large at least 300 lines long for testing purposes - // filler line 186 to make this class very large at least 300 lines long for testing purposes - // filler line 187 to make this class very large at least 300 lines long for testing purposes - // filler line 188 to make this class very large at least 300 lines long for testing purposes - // filler line 189 to make this class very large at least 300 lines long for testing purposes - // filler line 190 to make this class very large at least 300 lines long for testing purposes - // filler line 191 to make this class very large at least 300 lines long for testing purposes - // filler line 192 to make this class very large at least 300 lines long for testing purposes - // filler line 193 to make this class very large at least 300 lines long for testing purposes - // filler line 194 to make this class very large at least 300 lines long for testing purposes - // filler line 195 to make this class very large at least 300 lines long for testing purposes - // filler line 196 to make this class very large at least 300 lines long for testing purposes - // filler line 197 to make this class very large at least 300 lines long for testing purposes - // filler line 198 to make this class very large at least 300 lines long for testing purposes - // filler line 199 to make this class very large at least 300 lines long for testing purposes - // filler line 200 to make this class very large at least 300 lines long for testing purposes - // filler line 201 to make this class very large at least 300 lines long for testing purposes - // filler line 202 to make this class very large at least 300 lines long for testing purposes - // filler line 203 to make this class very large at least 300 lines long for testing purposes - // filler line 204 to make this class very large at least 300 lines long for testing purposes - // filler line 205 to make this class very large at least 300 lines long for testing purposes - // filler line 206 to make this class very large at least 300 lines long for testing purposes - // filler line 207 to make this class very large at least 300 lines long for testing purposes - // filler line 208 to make this class very large at least 300 lines long for testing purposes - // filler line 209 to make this class very large at least 300 lines long for testing purposes - // filler line 210 to make this class very large at least 300 lines long for testing purposes - // filler line 211 to make this class very large at least 300 lines long for testing purposes - // filler line 212 to make this class very large at least 300 lines long for testing purposes - // filler line 213 to make this class very large at least 300 lines long for testing purposes - // filler line 214 to make this class very large at least 300 lines long for testing purposes - // filler line 215 to make this class very large at least 300 lines long for testing purposes - // filler line 216 to make this class very large at least 300 lines long for testing purposes - // filler line 217 to make this class very large at least 300 lines long for testing purposes - // filler line 218 to make this class very large at least 300 lines long for testing purposes - // filler line 219 to make this class very large at least 300 lines long for testing purposes - // filler line 220 to make this class very large at least 300 lines long for testing purposes - // filler line 221 to make this class very large at least 300 lines long for testing purposes - // filler line 222 to make this class very large at least 300 lines long for testing purposes - // filler line 223 to make this class very large at least 300 lines long for testing purposes - // filler line 224 to make this class very large at least 300 lines long for testing purposes - // filler line 225 to make this class very large at least 300 lines long for testing purposes - // filler line 226 to make this class very large at least 300 lines long for testing purposes - // filler line 227 to make this class very large at least 300 lines long for testing purposes - // filler line 228 to make this class very large at least 300 lines long for testing purposes - // filler line 229 to make this class very large at least 300 lines long for testing purposes - // filler line 230 to make this class very large at least 300 lines long for testing purposes - // filler line 231 to make this class very large at least 300 lines long for testing purposes - // filler line 232 to make this class very large at least 300 lines long for testing purposes - // filler line 233 to make this class very large at least 300 lines long for testing purposes - // filler line 234 to make this class very large at least 300 lines long for testing purposes - // filler line 235 to make this class very large at least 300 lines long for testing purposes - // filler line 236 to make this class very large at least 300 lines long for testing purposes - // filler line 237 to make this class very large at least 300 lines long for testing purposes - // filler line 238 to make this class very large at least 300 lines long for testing purposes - // filler line 239 to make this class very large at least 300 lines long for testing purposes - // filler line 240 to make this class very large at least 300 lines long for testing purposes - // filler line 241 to make this class very large at least 300 lines long for testing purposes - // filler line 242 to make this class very large at least 300 lines long for testing purposes - // filler line 243 to make this class very large at least 300 lines long for testing purposes - // filler line 244 to make this class very large at least 300 lines long for testing purposes - // filler line 245 to make this class very large at least 300 lines long for testing purposes - // filler line 246 to make this class very large at least 300 lines long for testing purposes - // filler line 247 to make this class very large at least 300 lines long for testing purposes - // filler line 248 to make this class very large at least 300 lines long for testing purposes - // filler line 249 to make this class very large at least 300 lines long for testing purposes - // filler line 250 to make this class very large at least 300 lines long for testing purposes - // filler line 251 to make this class very large at least 300 lines long for testing purposes - // filler line 252 to make this class very large at least 300 lines long for testing purposes - // filler line 253 to make this class very large at least 300 lines long for testing purposes - // filler line 254 to make this class very large at least 300 lines long for testing purposes - // filler line 255 to make this class very large at least 300 lines long for testing purposes - // filler line 256 to make this class very large at least 300 lines long for testing purposes - // filler line 257 to make this class very large at least 300 lines long for testing purposes - // filler line 258 to make this class very large at least 300 lines long for testing purposes - // filler line 259 to make this class very large at least 300 lines long for testing purposes - // filler line 260 to make this class very large at least 300 lines long for testing purposes - // filler line 261 to make this class very large at least 300 lines long for testing purposes - // filler line 262 to make this class very large at least 300 lines long for testing purposes - // filler line 263 to make this class very large at least 300 lines long for testing purposes - // filler line 264 to make this class very large at least 300 lines long for testing purposes - // filler line 265 to make this class very large at least 300 lines long for testing purposes - // filler line 266 to make this class very large at least 300 lines long for testing purposes - // filler line 267 to make this class very large at least 300 lines long for testing purposes - // filler line 268 to make this class very large at least 300 lines long for testing purposes - // filler line 269 to make this class very large at least 300 lines long for testing purposes - // filler line 270 to make this class very large at least 300 lines long for testing purposes - // filler line 271 to make this class very large at least 300 lines long for testing purposes - // filler line 272 to make this class very large at least 300 lines long for testing purposes - // filler line 273 to make this class very large at least 300 lines long for testing purposes - // filler line 274 to make this class very large at least 300 lines long for testing purposes - // filler line 275 to make this class very large at least 300 lines long for testing purposes - // filler line 276 to make this class very large at least 300 lines long for testing purposes - // filler line 277 to make this class very large at least 300 lines long for testing purposes - // filler line 278 to make this class very large at least 300 lines long for testing purposes - // filler line 279 to make this class very large at least 300 lines long for testing purposes - // filler line 280 to make this class very large at least 300 lines long for testing purposes - // filler line 281 to make this class very large at least 300 lines long for testing purposes - // filler line 282 to make this class very large at least 300 lines long for testing purposes - // filler line 283 to make this class very large at least 300 lines long for testing purposes - // filler line 284 to make this class very large at least 300 lines long for testing purposes - // filler line 285 to make this class very large at least 300 lines long for testing purposes - // filler line 286 to make this class very large at least 300 lines long for testing purposes - // filler line 287 to make this class very large at least 300 lines long for testing purposes - // filler line 288 to make this class very large at least 300 lines long for testing purposes - // filler line 289 to make this class very large at least 300 lines long for testing purposes - // filler line 290 to make this class very large at least 300 lines long for testing purposes - // filler line 291 to make this class very large at least 300 lines long for testing purposes - // filler line 292 to make this class very large at least 300 lines long for testing purposes - // filler line 293 to make this class very large at least 300 lines long for testing purposes - // filler line 294 to make this class very large at least 300 lines long for testing purposes - // filler line 295 to make this class very large at least 300 lines long for testing purposes - // filler line 296 to make this class very large at least 300 lines long for testing purposes - // filler line 297 to make this class very large at least 300 lines long for testing purposes - // filler line 298 to make this class very large at least 300 lines long for testing purposes - // filler line 299 to make this class very large at least 300 lines long for testing purposes - // filler line 300 to make this class very large at least 300 lines long for testing purposes - // filler line 301 to make this class very large at least 300 lines long for testing purposes -} diff --git a/packages/flutterguard_cli/test/fixtures/large_file.dart b/packages/flutterguard_cli/test/fixtures/large_file.dart deleted file mode 100644 index 1a20974..0000000 --- a/packages/flutterguard_cli/test/fixtures/large_file.dart +++ /dev/null @@ -1,501 +0,0 @@ -// Line 1 of a very large file -// Line 2 of a very large file -// Line 3 of a very large file -// Line 4 of a very large file -// Line 5 of a very large file -// Line 6 of a very large file -// Line 7 of a very large file -// Line 8 of a very large file -// Line 9 of a very large file -// Line 10 of a very large file -// Line 11 of a very large file -// Line 12 of a very large file -// Line 13 of a very large file -// Line 14 of a very large file -// Line 15 of a very large file -// Line 16 of a very large file -// Line 17 of a very large file -// Line 18 of a very large file -// Line 19 of a very large file -// Line 20 of a very large file -// Line 21 of a very large file -// Line 22 of a very large file -// Line 23 of a very large file -// Line 24 of a very large file -// Line 25 of a very large file -// Line 26 of a very large file -// Line 27 of a very large file -// Line 28 of a very large file -// Line 29 of a very large file -// Line 30 of a very large file -// Line 31 of a very large file -// Line 32 of a very large file -// Line 33 of a very large file -// Line 34 of a very large file -// Line 35 of a very large file -// Line 36 of a very large file -// Line 37 of a very large file -// Line 38 of a very large file -// Line 39 of a very large file -// Line 40 of a very large file -// Line 41 of a very large file -// Line 42 of a very large file -// Line 43 of a very large file -// Line 44 of a very large file -// Line 45 of a very large file -// Line 46 of a very large file -// Line 47 of a very large file -// Line 48 of a very large file -// Line 49 of a very large file -// Line 50 of a very large file -// Line 51 of a very large file -// Line 52 of a very large file -// Line 53 of a very large file -// Line 54 of a very large file -// Line 55 of a very large file -// Line 56 of a very large file -// Line 57 of a very large file -// Line 58 of a very large file -// Line 59 of a very large file -// Line 60 of a very large file -// Line 61 of a very large file -// Line 62 of a very large file -// Line 63 of a very large file -// Line 64 of a very large file -// Line 65 of a very large file -// Line 66 of a very large file -// Line 67 of a very large file -// Line 68 of a very large file -// Line 69 of a very large file -// Line 70 of a very large file -// Line 71 of a very large file -// Line 72 of a very large file -// Line 73 of a very large file -// Line 74 of a very large file -// Line 75 of a very large file -// Line 76 of a very large file -// Line 77 of a very large file -// Line 78 of a very large file -// Line 79 of a very large file -// Line 80 of a very large file -// Line 81 of a very large file -// Line 82 of a very large file -// Line 83 of a very large file -// Line 84 of a very large file -// Line 85 of a very large file -// Line 86 of a very large file -// Line 87 of a very large file -// Line 88 of a very large file -// Line 89 of a very large file -// Line 90 of a very large file -// Line 91 of a very large file -// Line 92 of a very large file -// Line 93 of a very large file -// Line 94 of a very large file -// Line 95 of a very large file -// Line 96 of a very large file -// Line 97 of a very large file -// Line 98 of a very large file -// Line 99 of a very large file -// Line 100 of a very large file -// Line 101 of a very large file -// Line 102 of a very large file -// Line 103 of a very large file -// Line 104 of a very large file -// Line 105 of a very large file -// Line 106 of a very large file -// Line 107 of a very large file -// Line 108 of a very large file -// Line 109 of a very large file -// Line 110 of a very large file -// Line 111 of a very large file -// Line 112 of a very large file -// Line 113 of a very large file -// Line 114 of a very large file -// Line 115 of a very large file -// Line 116 of a very large file -// Line 117 of a very large file -// Line 118 of a very large file -// Line 119 of a very large file -// Line 120 of a very large file -// Line 121 of a very large file -// Line 122 of a very large file -// Line 123 of a very large file -// Line 124 of a very large file -// Line 125 of a very large file -// Line 126 of a very large file -// Line 127 of a very large file -// Line 128 of a very large file -// Line 129 of a very large file -// Line 130 of a very large file -// Line 131 of a very large file -// Line 132 of a very large file -// Line 133 of a very large file -// Line 134 of a very large file -// Line 135 of a very large file -// Line 136 of a very large file -// Line 137 of a very large file -// Line 138 of a very large file -// Line 139 of a very large file -// Line 140 of a very large file -// Line 141 of a very large file -// Line 142 of a very large file -// Line 143 of a very large file -// Line 144 of a very large file -// Line 145 of a very large file -// Line 146 of a very large file -// Line 147 of a very large file -// Line 148 of a very large file -// Line 149 of a very large file -// Line 150 of a very large file -// Line 151 of a very large file -// Line 152 of a very large file -// Line 153 of a very large file -// Line 154 of a very large file -// Line 155 of a very large file -// Line 156 of a very large file -// Line 157 of a very large file -// Line 158 of a very large file -// Line 159 of a very large file -// Line 160 of a very large file -// Line 161 of a very large file -// Line 162 of a very large file -// Line 163 of a very large file -// Line 164 of a very large file -// Line 165 of a very large file -// Line 166 of a very large file -// Line 167 of a very large file -// Line 168 of a very large file -// Line 169 of a very large file -// Line 170 of a very large file -// Line 171 of a very large file -// Line 172 of a very large file -// Line 173 of a very large file -// Line 174 of a very large file -// Line 175 of a very large file -// Line 176 of a very large file -// Line 177 of a very large file -// Line 178 of a very large file -// Line 179 of a very large file -// Line 180 of a very large file -// Line 181 of a very large file -// Line 182 of a very large file -// Line 183 of a very large file -// Line 184 of a very large file -// Line 185 of a very large file -// Line 186 of a very large file -// Line 187 of a very large file -// Line 188 of a very large file -// Line 189 of a very large file -// Line 190 of a very large file -// Line 191 of a very large file -// Line 192 of a very large file -// Line 193 of a very large file -// Line 194 of a very large file -// Line 195 of a very large file -// Line 196 of a very large file -// Line 197 of a very large file -// Line 198 of a very large file -// Line 199 of a very large file -// Line 200 of a very large file -// Line 201 of a very large file -// Line 202 of a very large file -// Line 203 of a very large file -// Line 204 of a very large file -// Line 205 of a very large file -// Line 206 of a very large file -// Line 207 of a very large file -// Line 208 of a very large file -// Line 209 of a very large file -// Line 210 of a very large file -// Line 211 of a very large file -// Line 212 of a very large file -// Line 213 of a very large file -// Line 214 of a very large file -// Line 215 of a very large file -// Line 216 of a very large file -// Line 217 of a very large file -// Line 218 of a very large file -// Line 219 of a very large file -// Line 220 of a very large file -// Line 221 of a very large file -// Line 222 of a very large file -// Line 223 of a very large file -// Line 224 of a very large file -// Line 225 of a very large file -// Line 226 of a very large file -// Line 227 of a very large file -// Line 228 of a very large file -// Line 229 of a very large file -// Line 230 of a very large file -// Line 231 of a very large file -// Line 232 of a very large file -// Line 233 of a very large file -// Line 234 of a very large file -// Line 235 of a very large file -// Line 236 of a very large file -// Line 237 of a very large file -// Line 238 of a very large file -// Line 239 of a very large file -// Line 240 of a very large file -// Line 241 of a very large file -// Line 242 of a very large file -// Line 243 of a very large file -// Line 244 of a very large file -// Line 245 of a very large file -// Line 246 of a very large file -// Line 247 of a very large file -// Line 248 of a very large file -// Line 249 of a very large file -// Line 250 of a very large file -// Line 251 of a very large file -// Line 252 of a very large file -// Line 253 of a very large file -// Line 254 of a very large file -// Line 255 of a very large file -// Line 256 of a very large file -// Line 257 of a very large file -// Line 258 of a very large file -// Line 259 of a very large file -// Line 260 of a very large file -// Line 261 of a very large file -// Line 262 of a very large file -// Line 263 of a very large file -// Line 264 of a very large file -// Line 265 of a very large file -// Line 266 of a very large file -// Line 267 of a very large file -// Line 268 of a very large file -// Line 269 of a very large file -// Line 270 of a very large file -// Line 271 of a very large file -// Line 272 of a very large file -// Line 273 of a very large file -// Line 274 of a very large file -// Line 275 of a very large file -// Line 276 of a very large file -// Line 277 of a very large file -// Line 278 of a very large file -// Line 279 of a very large file -// Line 280 of a very large file -// Line 281 of a very large file -// Line 282 of a very large file -// Line 283 of a very large file -// Line 284 of a very large file -// Line 285 of a very large file -// Line 286 of a very large file -// Line 287 of a very large file -// Line 288 of a very large file -// Line 289 of a very large file -// Line 290 of a very large file -// Line 291 of a very large file -// Line 292 of a very large file -// Line 293 of a very large file -// Line 294 of a very large file -// Line 295 of a very large file -// Line 296 of a very large file -// Line 297 of a very large file -// Line 298 of a very large file -// Line 299 of a very large file -// Line 300 of a very large file -// Line 301 of a very large file -// Line 302 of a very large file -// Line 303 of a very large file -// Line 304 of a very large file -// Line 305 of a very large file -// Line 306 of a very large file -// Line 307 of a very large file -// Line 308 of a very large file -// Line 309 of a very large file -// Line 310 of a very large file -// Line 311 of a very large file -// Line 312 of a very large file -// Line 313 of a very large file -// Line 314 of a very large file -// Line 315 of a very large file -// Line 316 of a very large file -// Line 317 of a very large file -// Line 318 of a very large file -// Line 319 of a very large file -// Line 320 of a very large file -// Line 321 of a very large file -// Line 322 of a very large file -// Line 323 of a very large file -// Line 324 of a very large file -// Line 325 of a very large file -// Line 326 of a very large file -// Line 327 of a very large file -// Line 328 of a very large file -// Line 329 of a very large file -// Line 330 of a very large file -// Line 331 of a very large file -// Line 332 of a very large file -// Line 333 of a very large file -// Line 334 of a very large file -// Line 335 of a very large file -// Line 336 of a very large file -// Line 337 of a very large file -// Line 338 of a very large file -// Line 339 of a very large file -// Line 340 of a very large file -// Line 341 of a very large file -// Line 342 of a very large file -// Line 343 of a very large file -// Line 344 of a very large file -// Line 345 of a very large file -// Line 346 of a very large file -// Line 347 of a very large file -// Line 348 of a very large file -// Line 349 of a very large file -// Line 350 of a very large file -// Line 351 of a very large file -// Line 352 of a very large file -// Line 353 of a very large file -// Line 354 of a very large file -// Line 355 of a very large file -// Line 356 of a very large file -// Line 357 of a very large file -// Line 358 of a very large file -// Line 359 of a very large file -// Line 360 of a very large file -// Line 361 of a very large file -// Line 362 of a very large file -// Line 363 of a very large file -// Line 364 of a very large file -// Line 365 of a very large file -// Line 366 of a very large file -// Line 367 of a very large file -// Line 368 of a very large file -// Line 369 of a very large file -// Line 370 of a very large file -// Line 371 of a very large file -// Line 372 of a very large file -// Line 373 of a very large file -// Line 374 of a very large file -// Line 375 of a very large file -// Line 376 of a very large file -// Line 377 of a very large file -// Line 378 of a very large file -// Line 379 of a very large file -// Line 380 of a very large file -// Line 381 of a very large file -// Line 382 of a very large file -// Line 383 of a very large file -// Line 384 of a very large file -// Line 385 of a very large file -// Line 386 of a very large file -// Line 387 of a very large file -// Line 388 of a very large file -// Line 389 of a very large file -// Line 390 of a very large file -// Line 391 of a very large file -// Line 392 of a very large file -// Line 393 of a very large file -// Line 394 of a very large file -// Line 395 of a very large file -// Line 396 of a very large file -// Line 397 of a very large file -// Line 398 of a very large file -// Line 399 of a very large file -// Line 400 of a very large file -// Line 401 of a very large file -// Line 402 of a very large file -// Line 403 of a very large file -// Line 404 of a very large file -// Line 405 of a very large file -// Line 406 of a very large file -// Line 407 of a very large file -// Line 408 of a very large file -// Line 409 of a very large file -// Line 410 of a very large file -// Line 411 of a very large file -// Line 412 of a very large file -// Line 413 of a very large file -// Line 414 of a very large file -// Line 415 of a very large file -// Line 416 of a very large file -// Line 417 of a very large file -// Line 418 of a very large file -// Line 419 of a very large file -// Line 420 of a very large file -// Line 421 of a very large file -// Line 422 of a very large file -// Line 423 of a very large file -// Line 424 of a very large file -// Line 425 of a very large file -// Line 426 of a very large file -// Line 427 of a very large file -// Line 428 of a very large file -// Line 429 of a very large file -// Line 430 of a very large file -// Line 431 of a very large file -// Line 432 of a very large file -// Line 433 of a very large file -// Line 434 of a very large file -// Line 435 of a very large file -// Line 436 of a very large file -// Line 437 of a very large file -// Line 438 of a very large file -// Line 439 of a very large file -// Line 440 of a very large file -// Line 441 of a very large file -// Line 442 of a very large file -// Line 443 of a very large file -// Line 444 of a very large file -// Line 445 of a very large file -// Line 446 of a very large file -// Line 447 of a very large file -// Line 448 of a very large file -// Line 449 of a very large file -// Line 450 of a very large file -// Line 451 of a very large file -// Line 452 of a very large file -// Line 453 of a very large file -// Line 454 of a very large file -// Line 455 of a very large file -// Line 456 of a very large file -// Line 457 of a very large file -// Line 458 of a very large file -// Line 459 of a very large file -// Line 460 of a very large file -// Line 461 of a very large file -// Line 462 of a very large file -// Line 463 of a very large file -// Line 464 of a very large file -// Line 465 of a very large file -// Line 466 of a very large file -// Line 467 of a very large file -// Line 468 of a very large file -// Line 469 of a very large file -// Line 470 of a very large file -// Line 471 of a very large file -// Line 472 of a very large file -// Line 473 of a very large file -// Line 474 of a very large file -// Line 475 of a very large file -// Line 476 of a very large file -// Line 477 of a very large file -// Line 478 of a very large file -// Line 479 of a very large file -// Line 480 of a very large file -// Line 481 of a very large file -// Line 482 of a very large file -// Line 483 of a very large file -// Line 484 of a very large file -// Line 485 of a very large file -// Line 486 of a very large file -// Line 487 of a very large file -// Line 488 of a very large file -// Line 489 of a very large file -// Line 490 of a very large file -// Line 491 of a very large file -// Line 492 of a very large file -// Line 493 of a very large file -// Line 494 of a very large file -// Line 495 of a very large file -// Line 496 of a very large file -// Line 497 of a very large file -// Line 498 of a very large file -// Line 499 of a very large file -// Line 500 of a very large file -// Line 501 of a very large file diff --git a/packages/flutterguard_cli/test/fixtures/missing_const.dart b/packages/flutterguard_cli/test/fixtures/missing_const.dart deleted file mode 100644 index e02e99b..0000000 --- a/packages/flutterguard_cli/test/fixtures/missing_const.dart +++ /dev/null @@ -1,27 +0,0 @@ -class Widget {} -class BuildContext {} -class StatelessWidget {} -class StatefulWidget {} -class State {} - -class ValidWidget extends StatelessWidget { - const ValidWidget(); - - Widget build(BuildContext context) => Widget(); -} - -class MissingConstWidget extends StatelessWidget { - MissingConstWidget(); - - Widget build(BuildContext context) => Widget(); -} - -class MyStatefulWidget extends StatefulWidget { - MyStatefulWidget(); - - State createState() => _MyStatefulWidgetState(); -} - -class _MyStatefulWidgetState extends State { - Widget build(BuildContext context) => Widget(); -} diff --git a/packages/flutterguard_cli/test/fixtures/mqtt_connection_issue.dart b/packages/flutterguard_cli/test/fixtures/mqtt_connection_issue.dart deleted file mode 100644 index 947459f..0000000 --- a/packages/flutterguard_cli/test/fixtures/mqtt_connection_issue.dart +++ /dev/null @@ -1,14 +0,0 @@ -// ignore_for_file: unused_field -// Fixture: MQTT connection issues -class MqttClient {} - -class MqttService { - late MqttClient _client; - - void connect() { - // broker URL hardcoded - final url = 'tcp://broker.iot.local:1883'; - } - - // disconnect() is missing -} diff --git a/packages/flutterguard_cli/test/scanner_test.dart b/packages/flutterguard_cli/test/scanner_test.dart deleted file mode 100644 index 3610eac..0000000 --- a/packages/flutterguard_cli/test/scanner_test.dart +++ /dev/null @@ -1,1200 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutterguard_cli/src/baseline.dart'; -import 'package:flutterguard_cli/src/config_loader.dart'; -import 'package:flutterguard_cli/src/config_tools.dart'; -import 'package:flutterguard_cli/src/domain.dart'; -import 'package:flutterguard_cli/src/import_utils.dart'; -import 'package:flutterguard_cli/src/install_doctor.dart'; -import 'package:flutterguard_cli/src/issue_export.dart'; -import 'package:flutterguard_cli/src/path_utils.dart'; -import 'package:flutterguard_cli/src/priority.dart'; -import 'package:flutterguard_cli/src/report_generator.dart'; -import 'package:flutterguard_cli/src/rules/circular_dependency.dart'; -import 'package:flutterguard_cli/src/rules/large_units.dart'; -import 'package:flutterguard_cli/src/rules/layer_violation.dart'; -import 'package:flutterguard_cli/src/rules/ble_scanning.dart'; -import 'package:flutterguard_cli/src/rules/device_lifecycle.dart'; -import 'package:flutterguard_cli/src/rules/iot_security.dart'; -import 'package:flutterguard_cli/src/rules/lifecycle_resource.dart'; -import 'package:flutterguard_cli/src/rules/missing_const_constructor.dart'; -import 'package:flutterguard_cli/src/rules/module_violation.dart'; -import 'package:flutterguard_cli/src/rules/mqtt_connection.dart'; -import 'package:flutterguard_cli/src/rules/pubspec_security.dart'; -import 'package:flutterguard_cli/src/rules/registry.dart'; -import 'package:flutterguard_cli/src/sarif_report.dart'; -import 'package:flutterguard_cli/src/scanner.dart'; -import 'package:flutterguard_cli/src/source_workspace.dart'; -import 'package:flutterguard_cli/src/static_issue.dart'; -import 'package:path/path.dart' as p; -import 'package:test/test.dart'; - -String get fixturesPath => p.join(Directory.current.path, 'test', 'fixtures'); - -void _runGit(Directory dir, List args) { - final result = Process.runSync('git', args, workingDirectory: dir.path); - if (result.exitCode != 0) { - throw StateError('git ${args.join(' ')} failed: ${result.stderr}'); - } -} - -void _writeMinimalProjectConfig(Directory dir) { - File(p.join(dir.path, 'flutterguard.yaml')).writeAsStringSync(''' -include: - - lib/** -architecture: - detect_cycles: true -'''); -} - -void _writeWidgetIssue(String path, String className) { - File(path).writeAsStringSync(''' -class StatelessWidget {} -class $className extends StatelessWidget {} -'''); -} - -void main() { - group('Static Rules', () { - test('scan detects large file', () { - final config = - ScanConfig.fromFile(p.join(fixturesPath, 'architecture_config.yaml')); - final files = [p.join(fixturesPath, 'large_file.dart')]; - - final issues = LargeUnitsRule( - largeFileConfig: config.rules.largeFile, - largeClassConfig: config.rules.largeClass, - largeBuildMethodConfig: config.rules.largeBuildMethod, - ).analyze(files); - - final largeFileIssue = issues.where((i) => i.id == 'large_file').toList(); - expect(largeFileIssue, isNotEmpty); - expect(largeFileIssue.first.metadata['actual'], greaterThan(500)); - }); - - test('scan detects large class', () { - final config = - ScanConfig.fromFile(p.join(fixturesPath, 'architecture_config.yaml')); - final files = [p.join(fixturesPath, 'large_class.dart')]; - - final issues = LargeUnitsRule( - largeFileConfig: config.rules.largeFile, - largeClassConfig: config.rules.largeClass, - largeBuildMethodConfig: config.rules.largeBuildMethod, - ).analyze(files); - - final largeClassIssue = - issues.where((i) => i.id == 'large_class').toList(); - expect(largeClassIssue, isNotEmpty); - }); - - test('scan detects large build method', () { - final config = - ScanConfig.fromFile(p.join(fixturesPath, 'architecture_config.yaml')); - final files = [p.join(fixturesPath, 'large_build.dart')]; - - final issues = LargeUnitsRule( - largeFileConfig: config.rules.largeFile, - largeClassConfig: config.rules.largeClass, - largeBuildMethodConfig: config.rules.largeBuildMethod, - ).analyze(files); - - final largeBuildIssue = - issues.where((i) => i.id == 'large_build_method').toList(); - expect(largeBuildIssue, isNotEmpty); - }); - - test('scan detects lifecycle resource not disposed', () { - final config = - ScanConfig.fromFile(p.join(fixturesPath, 'architecture_config.yaml')); - final files = [p.join(fixturesPath, 'lifecycle_issue.dart')]; - - final issues = - LifecycleResourceRule(config.rules.lifecycleResource).analyze(files); - - expect(issues, isNotEmpty); - expect( - issues.any((i) => i.id == 'lifecycle_resource_not_disposed'), isTrue); - }); - - test('scan detects layer violation', () { - final config = - ScanConfig.fromFile(p.join(fixturesPath, 'architecture_config.yaml')); - final files = [ - p.join(fixturesPath, 'boundary_issue.dart'), - p.join(fixturesPath, 'forbidden_file.dart'), - ]; - - final issues = - LayerViolationRule(config.architecture.layers).analyze(files); - - expect(issues, isNotEmpty); - expect(issues.any((i) => i.id == 'layer_violation'), isTrue); - }); - - test('layer violation matches project-relative architecture paths', () { - final files = [ - p.join(fixturesPath, 'boundary_issue.dart'), - p.join(fixturesPath, 'forbidden_file.dart'), - ]; - - final issues = LayerViolationRule( - const [ - ( - name: 'ui', - path: 'test/fixtures/boundary_issue.dart', - allowedDeps: [] - ), - ( - name: 'model', - path: 'test/fixtures/forbidden_file.dart', - allowedDeps: [], - ), - ], - projectPath: Directory.current.path, - ).analyze(files); - - expect(issues, isNotEmpty); - expect(issues.any((i) => i.id == 'layer_violation'), isTrue); - }); - - test('scan detects module violation', () { - final config = - ScanConfig.fromFile(p.join(fixturesPath, 'architecture_config.yaml')); - final files = [ - p.join(fixturesPath, 'boundary_issue.dart'), - p.join(fixturesPath, 'forbidden_file.dart'), - ]; - - final issues = - ModuleViolationRule(config.architecture.modules).analyze(files); - - expect(issues, isNotEmpty); - expect(issues.any((i) => i.id == 'module_violation'), isTrue); - }); - - test('scan detects circular dependency', () { - final files = [ - p.join(fixturesPath, 'cycle_a.dart'), - p.join(fixturesPath, 'cycle_b.dart'), - p.join(fixturesPath, 'cycle_c.dart'), - ]; - - final issues = const CircularDependencyRule(enabled: true).analyze(files); - - expect(issues, isNotEmpty); - expect(issues.any((i) => i.id == 'circular_dependency'), isTrue); - }); - - test('scan detects missing const constructor in widgets', () { - final config = - ScanConfig.fromFile(p.join(fixturesPath, 'architecture_config.yaml')); - final files = [p.join(fixturesPath, 'missing_const.dart')]; - - final issues = MissingConstConstructorRule( - config.rules.missingConstConstructor, - ).analyze(files); - - expect(issues, hasLength(2)); - expect(issues.any((i) => i.id == 'missing_const_constructor'), isTrue); - expect(issues.any((i) => i.metadata['className'] == 'MissingConstWidget'), - isTrue); - expect(issues.any((i) => i.metadata['className'] == 'MyStatefulWidget'), - isTrue); - }); - - test('scan detects iot security issues', () { - final files = [p.join(fixturesPath, 'iot_security_issue.dart')]; - final config = (enabled: true, requireTls: true); - - final issues = IotSecurityRule(config).analyze(files); - - expect( - issues.any((i) => i.metadata['securityCheck'] == 'hardcoded_secret'), - isTrue); - expect(issues.any((i) => i.metadata['securityCheck'] == 'cleartext_mqtt'), - isTrue); - expect(issues.any((i) => i.metadata['securityCheck'] == 'cleartext_http'), - isTrue); - expect(issues.any((i) => i.metadata['securityCheck'] == 'insecure_ble'), - isTrue); - }); - - test('scan detects device lifecycle issues', () { - final files = [p.join(fixturesPath, 'device_lifecycle_issue.dart')]; - final config = (enabled: true); - - final issues = DeviceLifecycleRule(config).analyze(files); - - expect(issues.any((i) => i.id == 'device_lifecycle'), isTrue); - expect( - issues.any((i) => i.metadata['initMethod'] == 'initState'), isTrue); - expect( - issues.any((i) => i.metadata['teardownMethod'] == 'dispose'), isTrue); - }); - - test('scan detects mqtt connection issues', () { - final files = [p.join(fixturesPath, 'mqtt_connection_issue.dart')]; - final config = (enabled: true); - - final issues = MqttConnectionRule(config).analyze(files); - - expect(issues.any((i) => i.id == 'mqtt_connection'), isTrue); - expect( - issues - .any((i) => i.metadata['check'] == 'connect_without_disconnect'), - isTrue); - expect(issues.any((i) => i.metadata['check'] == 'hardcoded_broker_url'), - isTrue); - }); - - test('scan detects ble scanning issues', () { - final files = [p.join(fixturesPath, 'ble_scanning_issue.dart')]; - final config = (enabled: true, maxScanDurationMs: 10000); - - final issues = BleScanningRule(config).analyze(files); - - expect(issues.any((i) => i.id == 'ble_scanning'), isTrue); - expect( - issues - .any((i) => i.metadata['check'] == 'startScan_without_stopScan'), - isTrue); - }); - - test('scan detects pubspec security issues', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_test_'); - addTearDown(() => dir.deleteSync(recursive: true)); - - File(p.join(dir.path, 'pubspec.yaml')).writeAsStringSync(''' -name: test_app -dependencies: - mqtt_client: ^9.0.0 - flutter_blue: ^0.8.0 - path: any -'''); - - File(p.join(dir.path, 'dummy.dart')).writeAsStringSync('// dummy'); - final files = [p.join(dir.path, 'dummy.dart')]; - final config = (enabled: true); - - final issues = PubspecSecurityRule(config).analyze(files); - - expect(issues.any((i) => i.id == 'pubspec_security'), isTrue); - expect(issues.any((i) => i.metadata['check'] == 'outdated_dependency'), - isTrue); - expect(issues.any((i) => i.metadata['check'] == 'deprecated_package'), - isTrue); - expect(issues.any((i) => i.metadata['check'] == 'unbounded_dependency'), - isTrue); - }); - - test('IoT rules respect disabled config', () { - final files = [p.join(fixturesPath, 'iot_security_issue.dart')]; - final config = (enabled: false, requireTls: true); - - final issues = IotSecurityRule(config).analyze(files); - - expect(issues, isEmpty); - }); - - test('architecture config parses layer/module enabled flags', () { - final enabledConfig = - ScanConfig.fromFile(p.join(fixturesPath, 'architecture_config.yaml')); - expect(enabledConfig.architecture.layerViolationEnabled, isTrue); - expect(enabledConfig.architecture.moduleViolationEnabled, isTrue); - - final disabledConfig = ScanConfig.fromFile( - p.join(fixturesPath, 'architecture_disabled.yaml')); - expect(disabledConfig.architecture.layerViolationEnabled, isFalse); - expect(disabledConfig.architecture.moduleViolationEnabled, isFalse); - }); - - test('wiring: disabled layer/module violations produce no issues', () { - final config = ScanConfig.fromFile( - p.join(fixturesPath, 'architecture_disabled.yaml')); - final files = [ - p.join(fixturesPath, 'boundary_issue.dart'), - p.join(fixturesPath, 'forbidden_file.dart'), - ]; - - List issues = []; - if (config.architecture.layerViolationEnabled) { - issues.addAll( - LayerViolationRule(config.architecture.layers).analyze(files)); - } - if (config.architecture.moduleViolationEnabled) { - issues.addAll( - ModuleViolationRule(config.architecture.modules).analyze(files)); - } - expect(issues, isEmpty); - }); - - test('ci fail on high returns exit 1 scenario', () { - final issues = [ - StaticIssue( - id: 'test_high', - title: 'Test high', - file: 'test.dart', - level: RiskLevel.high, - domain: IssueDomain.architecture, - priority: Priority.p0, - message: 'High severity issue', - detail: '', - suggestion: 'Fix it', - ), - ]; - - expect(ReportGenerator.shouldFail(issues, 'high'), isTrue); - expect(ReportGenerator.shouldFail(issues, 'medium'), isTrue); - }); - }); - - group('Report Generation', () { - test('json report is generated', () { - final issues = [ - StaticIssue( - id: 'test_issue', - title: 'Test issue', - file: '/test/file.dart', - line: 42, - level: RiskLevel.medium, - domain: IssueDomain.architecture, - priority: Priority.p1, - message: 'A medium architecture issue', - detail: 'Detailed description', - suggestion: 'Try fixing it', - ), - ]; - - final json = ReportGenerator.generateJson( - projectPath: '/test', - issues: issues, - ); - - expect(json, contains('"version"')); - expect(json, contains('"projectPath"')); - expect(json, contains('"scanMode"')); - expect(json, contains('"score"')); - expect(json, contains('"issues"')); - expect(json, contains('"byDomain"')); - expect(json, contains('test_issue')); - }); - - test('stdout report uses scanned file count when provided', () { - final report = ReportGenerator.generateStdout( - projectPath: '/test', - issues: const [], - scannedFileCount: 3, - ); - - expect(report, contains('扫描文件: 3')); - expect(report, contains('问题总数: ')); - }); - - test('json summary includes suppression counters', () { - final json = ReportGenerator.generateJson( - projectPath: '/test', - issues: const [], - suppressedCount: 2, - suppressedByBaselineCount: 3, - diagnostics: const [ - ScanDiagnostic(stage: 'read', message: 'unreadable'), - ], - ); - final decoded = jsonDecode(json) as Map; - final summary = decoded['summary'] as Map; - - expect(summary['suppressed'], 2); - expect(summary['suppressedByBaseline'], 3); - expect(summary['diagnostics'], 1); - expect(decoded['diagnostics'], hasLength(1)); - }); - }); - - group('Scanner Orchestration', () { - test('scanner allows built-in defaults when the default config is absent', - () { - final dir = - Directory.systemTemp.createTempSync('flutterguard_no_config_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(); - File(p.join(dir.path, 'lib', 'plain.dart')) - .writeAsStringSync('class Plain {}\n'); - - final result = FlutterGuardScanner.scan(projectPath: dir.path); - - expect(result.files, [p.join(dir.path, 'lib', 'plain.dart')]); - }); - - test('scanner analyzes the project-root pubspec', () { - final dir = - Directory.systemTemp.createTempSync('flutterguard_root_pubspec_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(); - File(p.join(dir.path, 'lib', 'plain.dart')) - .writeAsStringSync('class Plain {}\n'); - File(p.join(dir.path, 'pubspec.yaml')).writeAsStringSync(''' -name: root_pubspec_test -dependencies: - mqtt_client: ^9.0.0 -'''); - - final result = FlutterGuardScanner.scan(projectPath: dir.path); - - expect( - result.issues.where((issue) => issue.id == 'pubspec_security'), - isNotEmpty, - ); - }); - - test('source workspace retains read failures as diagnostics', () { - final workspace = SourceWorkspace(); - - expect(workspace.source(p.join(fixturesPath, 'missing.dart')), isNull); - expect(workspace.diagnostics, hasLength(1)); - expect(workspace.diagnostics.single.stage, 'read'); - }); - - test('scanner rejects a missing custom config', () { - final dir = - Directory.systemTemp.createTempSync('flutterguard_custom_config_'); - addTearDown(() => dir.deleteSync(recursive: true)); - - expect( - () => FlutterGuardScanner.scan( - projectPath: dir.path, - configPath: 'policy/flutterguard.yaml', - ), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('does not exist'), - ), - ), - ); - }); - - test('scanner rejects a missing explicitly selected default config', () { - final dir = - Directory.systemTemp.createTempSync('flutterguard_required_config_'); - addTearDown(() => dir.deleteSync(recursive: true)); - - expect( - () => FlutterGuardScanner.scan( - projectPath: dir.path, - configPath: 'flutterguard.yaml', - ), - throwsA(isA()), - ); - }); - - test('scanner runs all configured rules and returns sorted result', () { - final result = FlutterGuardScanner.scan( - projectPath: Directory.current.path, - configPath: p.join('test', 'fixtures', 'architecture_config.yaml'), - ); - - expect(result.files, isNotEmpty); - expect(result.issues, isNotEmpty); - expect(result.score, inInclusiveRange(0, 100)); - expect(result.issues.first.level, RiskLevel.high); - }); - - test('scanner reports missing project path as scan exception', () { - expect( - () => FlutterGuardScanner.scan( - projectPath: p.join(fixturesPath, 'does_not_exist'), - ), - throwsA(isA()), - ); - }); - - test('config parser rejects invalid rule values', () { - final file = File(p.join(fixturesPath, 'invalid_config.yaml')); - file.writeAsStringSync('rules:\n large_file: false\n'); - addTearDown(() { - if (file.existsSync()) file.deleteSync(); - }); - - expect( - () => ScanConfig.fromFile(file.path), - throwsA(isA()), - ); - }); - - test('config parser rejects a required file that is absent', () { - expect( - () => ScanConfig.fromFile( - p.join(fixturesPath, 'does_not_exist.yaml'), - requireFile: true, - ), - throwsA(isA()), - ); - }); - - test('same-line suppression filters matching rule only', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_suppress_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - File(p.join(dir.path, 'lib', 'ignored.dart')).writeAsStringSync(''' -class StatelessWidget {} -class IgnoredWidget extends StatelessWidget {} // flutterguard: ignore missing_const_constructor -'''); - File(p.join(dir.path, 'lib', 'visible.dart')).writeAsStringSync(''' -class StatelessWidget {} -class VisibleWidget extends StatelessWidget {} // flutterguard: ignore large_file -'''); - - final result = FlutterGuardScanner.scan(projectPath: dir.path); - - expect(result.rawIssues, hasLength(2)); - expect(result.issues, hasLength(1)); - expect(result.suppressedCount, 1); - expect(result.issues.single.metadata['className'], 'VisibleWidget'); - }); - - test('previous-line ignore all filters next line issues', () { - final dir = - Directory.systemTemp.createTempSync('flutterguard_ignore_all_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - File(p.join(dir.path, 'lib', 'widget.dart')).writeAsStringSync(''' -class StatelessWidget {} -// flutterguard: ignore all -class IgnoredWidget extends StatelessWidget {} -'''); - - final result = FlutterGuardScanner.scan(projectPath: dir.path); - - expect(result.rawIssues, hasLength(1)); - expect(result.issues, isEmpty); - expect(result.suppressedCount, 1); - }); - - test('baseline filters matching fingerprints and leaves new issues visible', - () { - final dir = Directory.systemTemp.createTempSync('flutterguard_baseline_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - _writeWidgetIssue(p.join(dir.path, 'lib', 'old.dart'), 'OldWidget'); - - final initial = FlutterGuardScanner.scan( - projectPath: dir.path, - applySuppression: false, - ); - final baselinePath = p.join(dir.path, '.flutterguard', 'baseline.json'); - Directory(p.dirname(baselinePath)).createSync(recursive: true); - File(baselinePath).writeAsStringSync(Baseline.encode( - projectPath: initial.projectPath, - issues: initial.rawIssues, - )); - - _writeWidgetIssue(p.join(dir.path, 'lib', 'new.dart'), 'NewWidget'); - final result = FlutterGuardScanner.scan( - projectPath: dir.path, - baselinePath: '.flutterguard/baseline.json', - ); - - expect(result.rawIssues, hasLength(2)); - expect(result.suppressedByBaselineCount, 1); - expect(result.issues, hasLength(1)); - expect(result.issues.single.file, endsWith('new.dart')); - }); - - test('baseline stats prune and no-growth helpers work', () { - final dir = - Directory.systemTemp.createTempSync('flutterguard_baseline_tools_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - _writeWidgetIssue(p.join(dir.path, 'lib', 'old.dart'), 'OldWidget'); - - final initial = FlutterGuardScanner.scan( - projectPath: dir.path, - applySuppression: false, - ); - final staleIssue = StaticIssue( - id: 'missing_const_constructor', - title: 'Stale', - file: p.join(dir.path, 'lib', 'deleted.dart'), - line: 2, - level: RiskLevel.low, - domain: IssueDomain.standards, - priority: Priority.p2, - message: 'stale issue', - suggestion: 'fix', - ); - final baselineJson = Baseline.encode( - projectPath: initial.projectPath, - issues: [...initial.rawIssues, staleIssue], - ); - final baseline = Baseline.loadFromString(baselineJson); - - expect(baseline.fingerprints, hasLength(2)); - - final pruned = Baseline.loadFromString(Baseline.prune( - projectPath: initial.projectPath, - baseline: baseline, - issues: initial.rawIssues, - )); - expect(pruned.fingerprints, hasLength(1)); - - _writeWidgetIssue(p.join(dir.path, 'lib', 'new.dart'), 'NewWidget'); - final current = FlutterGuardScanner.scan( - projectPath: dir.path, - applySuppression: false, - ); - final newFingerprints = Baseline.newFingerprints( - projectPath: current.projectPath, - baseline: pruned, - issues: current.rawIssues, - ); - expect(newFingerprints, hasLength(1)); - }); - - test('missing baseline file fails the scan', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_no_base_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - _writeWidgetIssue(p.join(dir.path, 'lib', 'one.dart'), 'OneWidget'); - - expect( - () => FlutterGuardScanner.scan( - projectPath: dir.path, - baselinePath: '.flutterguard/missing.json', - ), - throwsA(isA()), - ); - }); - - test('sarif report contains rules results severity and line fallback', () { - final issues = [ - StaticIssue( - id: 'iot_security', - title: 'High', - file: p.join('/repo', 'lib', 'a.dart'), - line: 12, - level: RiskLevel.high, - domain: IssueDomain.standards, - priority: Priority.p0, - message: 'high issue', - suggestion: 'fix', - ), - StaticIssue( - id: 'ble_scanning', - title: 'Medium', - file: p.join('/repo', 'lib', 'b.dart'), - level: RiskLevel.medium, - domain: IssueDomain.performance, - priority: Priority.p1, - message: 'medium issue', - suggestion: 'fix', - ), - StaticIssue( - id: 'missing_const_constructor', - title: 'Low', - file: p.join('/repo', 'lib', 'c.dart'), - line: 3, - level: RiskLevel.low, - domain: IssueDomain.standards, - priority: Priority.p2, - message: 'low issue', - suggestion: 'fix', - ), - ]; - - final decoded = jsonDecode(SarifReport.generate( - projectPath: '/repo', - issues: issues, - )) as Map; - final runs = decoded['runs'] as List; - final run = runs.single as Map; - final results = run['results'] as List; - - expect(decoded['version'], '2.1.0'); - expect(jsonEncode(run), contains('"rules"')); - expect(results.map((r) => (r as Map)['level']), [ - 'error', - 'warning', - 'note', - ]); - final second = results[1] as Map; - final locations = second['locations'] as List; - final physical = (locations.single as Map)['physicalLocation'] as Map; - final region = physical['region'] as Map; - expect(region['startLine'], 1); - expect(jsonEncode(second), contains('lib/b.dart')); - }); - - test('issue export includes rule metadata context and fingerprint', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_export_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - final file = p.join(dir.path, 'lib', 'widget.dart'); - _writeWidgetIssue(file, 'ExportedWidget'); - - final result = FlutterGuardScanner.scan( - projectPath: dir.path, - applySuppression: false, - ); - final exported = IssueExporter.export( - projectPath: result.projectPath, - issues: result.rawIssues, - ruleId: 'missing_const_constructor', - filePath: 'lib/widget.dart', - line: 2, - ); - final decoded = jsonDecode(exported) as Map; - - expect(decoded['fingerprint'], isA()); - expect( - jsonEncode(decoded['rule']), contains('missing_const_constructor')); - expect(jsonEncode(decoded['context']), contains('ExportedWidget')); - expect(jsonEncode(decoded['issue']), contains('lib/widget.dart')); - }); - }); - - group('Changed-only mode', () { - test('changed_only_filters_files', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_changed_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - final changedFile = p.join(dir.path, 'lib', 'changed 设备.dart'); - final unchangedFile = p.join(dir.path, 'lib', 'unchanged.dart'); - _writeWidgetIssue(changedFile, 'ChangedWidget'); - _writeWidgetIssue(unchangedFile, 'UnchangedWidget'); - - _runGit(dir, ['init', '-b', 'main']); - _runGit(dir, ['config', 'user.email', 'test@example.com']); - _runGit(dir, ['config', 'user.name', 'FlutterGuard Test']); - _runGit(dir, ['add', '.']); - _runGit(dir, ['commit', '-m', 'initial']); - File(changedFile).writeAsStringSync(''' -class StatelessWidget {} -class ChangedWidget extends StatelessWidget {} -class AnotherChangedWidget extends StatelessWidget {} -'''); - - final result = FlutterGuardScanner.scan( - projectPath: dir.path, - changedOnly: true, - base: 'main', - ); - - expect(result.scanMode, 'changed'); - expect(result.files, [changedFile]); - expect(result.issues, isNotEmpty); - expect(result.issues.every((i) => i.file == changedFile), isTrue); - }); - - test('changed_only resolves imports to unchanged architecture targets', () { - final dir = Directory.systemTemp.createTempSync( - 'flutterguard_changed_boundary_', - ); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib', 'ui')).createSync(recursive: true); - Directory(p.join(dir.path, 'lib', 'data')).createSync(recursive: true); - File(p.join(dir.path, 'flutterguard.yaml')).writeAsStringSync(''' -include: - - lib/** -architecture: - layers: - - name: ui - path: lib/ui/** - allowed_deps: [] - - name: data - path: lib/data/** - allowed_deps: [] -'''); - final source = p.join(dir.path, 'lib', 'ui', 'screen.dart'); - final target = p.join(dir.path, 'lib', 'data', 'repository.dart'); - File(source).writeAsStringSync( - "import '../data/repository.dart';\nclass Screen {}\n", - ); - File(target).writeAsStringSync('class Repository {}\n'); - _runGit(dir, ['init', '-b', 'main']); - _runGit(dir, ['config', 'user.email', 'test@example.com']); - _runGit(dir, ['config', 'user.name', 'FlutterGuard Test']); - _runGit(dir, ['add', '.']); - _runGit(dir, ['commit', '-m', 'initial']); - File(source).writeAsStringSync( - "import '../data/repository.dart';\nclass Screen {}\n// changed\n", - ); - - final result = FlutterGuardScanner.scan( - projectPath: dir.path, - changedOnly: true, - base: 'main', - ); - - expect(result.files, [source]); - expect( - result.issues.where((issue) => issue.id == 'layer_violation'), - hasLength(1), - ); - }); - - test('changed_only_full_scan_when_no_git', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_no_git_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - File(p.join(dir.path, 'lib', 'one.dart')).writeAsStringSync( - "import 'two.dart';\nclass One {}\n", - ); - File(p.join(dir.path, 'lib', 'two.dart')).writeAsStringSync( - "import 'one.dart';\nclass Two {}\n", - ); - - final result = FlutterGuardScanner.scan( - projectPath: dir.path, - changedOnly: true, - ); - - expect(result.scanMode, 'full'); - expect(result.files, hasLength(2)); - expect( - result.issues.where((issue) => issue.id == 'circular_dependency'), - isNotEmpty, - ); - }); - - test('changed_only clean repository returns an empty changed scan', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_clean_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - File(p.join(dir.path, 'lib', 'clean.dart')) - .writeAsStringSync('class Clean {}\n'); - _runGit(dir, ['init', '-b', 'main']); - _runGit(dir, ['config', 'user.email', 'test@example.com']); - _runGit(dir, ['config', 'user.name', 'FlutterGuard Test']); - _runGit(dir, ['add', '.']); - _runGit(dir, ['commit', '-m', 'initial']); - - final result = FlutterGuardScanner.scan( - projectPath: dir.path, - changedOnly: true, - base: 'main', - ); - - expect(result.scanMode, 'changed'); - expect(result.files, isEmpty); - expect(result.issues, isEmpty); - }); - - test('changed_only runs project rules when pubspec changes', () { - final dir = Directory.systemTemp.createTempSync( - 'flutterguard_changed_pubspec_', - ); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - File(p.join(dir.path, 'lib', 'clean.dart')) - .writeAsStringSync('class Clean {}\n'); - final pubspec = File(p.join(dir.path, 'pubspec.yaml')) - ..writeAsStringSync('name: changed_pubspec_test\n'); - _runGit(dir, ['init', '-b', 'main']); - _runGit(dir, ['config', 'user.email', 'test@example.com']); - _runGit(dir, ['config', 'user.name', 'FlutterGuard Test']); - _runGit(dir, ['add', '.']); - _runGit(dir, ['commit', '-m', 'initial']); - pubspec.writeAsStringSync(''' -name: changed_pubspec_test -dependencies: - mqtt_client: ^9.0.0 -'''); - - final result = FlutterGuardScanner.scan( - projectPath: dir.path, - changedOnly: true, - base: 'main', - ); - - expect(result.files, isEmpty); - expect( - result.issues.where((issue) => issue.id == 'pubspec_security'), - isNotEmpty, - ); - }); - - test('changed_only rejects invalid or option-like base refs', () { - final dir = Directory.systemTemp.createTempSync( - 'flutterguard_invalid_base_', - ); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - File(p.join(dir.path, 'lib', 'tracked.dart')) - .writeAsStringSync('class Tracked {}\n'); - _runGit(dir, ['init', '-b', 'main']); - _runGit(dir, ['config', 'user.email', 'test@example.com']); - _runGit(dir, ['config', 'user.name', 'FlutterGuard Test']); - _runGit(dir, ['add', '.']); - _runGit(dir, ['commit', '-m', 'initial']); - - expect( - () => FlutterGuardScanner.scan( - projectPath: dir.path, - changedOnly: true, - base: 'missing-ref', - ), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('Invalid Git base'), - ), - ), - ); - expect( - () => FlutterGuardScanner.scan( - projectPath: dir.path, - changedOnly: true, - base: '--cached', - ), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('Invalid Git base'), - ), - ), - ); - }); - - test('changed_only_skips_circular_dependency', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_cycle_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - File(p.join(dir.path, 'lib', 'a.dart')).writeAsStringSync( - "import 'b.dart';\nclass A {}\n", - ); - File(p.join(dir.path, 'lib', 'b.dart')).writeAsStringSync( - "import 'c.dart';\nclass B {}\n", - ); - File(p.join(dir.path, 'lib', 'c.dart')).writeAsStringSync( - "import 'a.dart';\nclass C {}\n", - ); - _runGit(dir, ['init', '-b', 'main']); - _runGit(dir, ['config', 'user.email', 'test@example.com']); - _runGit(dir, ['config', 'user.name', 'FlutterGuard Test']); - _runGit(dir, ['commit', '--allow-empty', '-m', 'initial']); - - final result = FlutterGuardScanner.scan( - projectPath: dir.path, - changedOnly: true, - base: 'main', - ); - - expect(result.scanMode, 'changed'); - expect(result.files, hasLength(3)); - expect( - result.issues.where((i) => i.id == 'circular_dependency'), - isEmpty, - ); - }); - }); - - group('Rules Registry', () { - test('registry_contains_all_13_rules', () { - expect(RuleRegistry.all(), hasLength(13)); - }); - - test('registry_find_returns_correct_meta', () { - final meta = RuleRegistry.find('large_file'); - - expect(meta, isNotNull); - expect(meta!.id, 'large_file'); - expect(meta.domain, 'standards'); - }); - - test('registry_find_unknown_returns_null', () { - expect(RuleRegistry.find('nonexistent'), isNull); - }); - }); - - group('Config Tools', () { - test('init template includes optional architecture block', () { - final basic = ConfigTools.initTemplate(withArchitecture: false); - final withArchitecture = ConfigTools.initTemplate(withArchitecture: true); - - expect(basic, contains('large_file:')); - expect(basic, isNot(contains('architecture:'))); - expect(withArchitecture, contains('architecture:')); - expect(withArchitecture, contains('mqtt_feature')); - }); - - test('init template profiles tune rule defaults', () { - final migration = ConfigTools.initTemplate( - withArchitecture: false, - profile: 'migration', - ); - final security = ConfigTools.initTemplate( - withArchitecture: false, - profile: 'iot-security', - ); - - expect(migration, contains('maxLines: 800')); - expect(migration, contains('missing_const_constructor:')); - expect(migration, contains('enabled: false')); - expect(security, contains('iot_security:')); - expect(security, contains('requireTls: true')); - expect( - () => ConfigTools.initTemplate( - withArchitecture: false, - profile: 'unknown', - ), - throwsA(isA()), - ); - }); - - test('effective config print includes merged defaults', () { - final config = ScanConfig.fromFile( - p.join(fixturesPath, 'does_not_exist.yaml'), - ); - - final yaml = ConfigTools.effectiveYaml(config); - - expect(yaml, contains('include:')); - expect(yaml, contains('iot_security:')); - expect(yaml, contains('maxScanDurationMs: 10000')); - expect(yaml, contains('detect_cycles: false')); - }); - - test('doctor reports unknown architecture dependencies', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_doctor_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib', 'presentation')) - .createSync(recursive: true); - File(p.join(dir.path, 'lib', 'presentation', 'page.dart')) - .writeAsStringSync('class Page {}\n'); - File(p.join(dir.path, 'flutterguard.yaml')).writeAsStringSync(''' -include: - - lib/** -architecture: - layers: - - name: presentation - path: lib/presentation/** - allowed_deps: [domain] -'''); - - final result = ConfigTools.doctor( - projectPath: dir.path, - configPath: 'flutterguard.yaml', - ); - - expect(result.hasErrors, isTrue); - expect( - result.messages.any((message) => - message.severity == DoctorSeverity.error && - message.message.contains('unknown dependency "domain"')), - isTrue, - ); - }); - - test('doctor rejects configs that match no Dart files', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_empty_'); - addTearDown(() => dir.deleteSync(recursive: true)); - File(p.join(dir.path, 'pubspec.yaml')).writeAsStringSync('name: app\n'); - File(p.join(dir.path, 'flutterguard.yaml')).writeAsStringSync(''' -include: - - lib/** -'''); - - final result = ConfigTools.doctor( - projectPath: dir.path, - configPath: 'flutterguard.yaml', - ); - - expect(result.hasErrors, isTrue); - expect( - result.messages.any((message) => - message.severity == DoctorSeverity.error && - message.message.contains('No Dart files matched')), - isTrue, - ); - }); - - test('config tools prefer project config for default config name', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_config_'); - addTearDown(() => dir.deleteSync(recursive: true)); - File(p.join(dir.path, 'pubspec.yaml')).writeAsStringSync('name: app\n'); - File(p.join(dir.path, 'flutterguard.yaml')).writeAsStringSync(''' -include: - - custom_lib/** -'''); - - final resolved = ConfigTools.resolveConfigPathForProject( - projectPath: dir.path, - configPath: 'flutterguard.yaml', - ); - - expect(resolved, p.join(dir.path, 'flutterguard.yaml')); - }); - - test('install doctor reports version and path checks', () { - final report = InstallDoctor.generate(version: '0.4.0-test'); - - expect(report, contains('FlutterGuard install doctor')); - expect(report, contains('0.4.0-test')); - expect(report, contains('PATH entries named flutterguard')); - }); - }); - - group('Path handling', () { - test('matches project-relative globs against Windows paths', () { - final windows = p.Context(style: p.Style.windows, current: r'C:\repo'); - - expect( - matchesProjectGlob( - r'C:\repo\lib\presentation\device_page.dart', - 'lib/presentation/**', - r'C:\repo', - context: windows, - ), - isTrue, - ); - }); - - test('resolves nested package imports from project lib root', () { - final source = - p.join(Directory.current.path, 'lib', 'presentation', 'page.dart'); - final target = p.join(Directory.current.path, 'lib', 'data', 'repo.dart'); - - final resolved = resolveImport( - source, - 'package:app/data/repo.dart', - {source, target}, - projectPath: Directory.current.path, - ); - - expect(resolved, target); - }); - - test('resolves Windows package imports from project lib root', () { - final windows = p.Context(style: p.Style.windows, current: r'C:\repo'); - const source = r'C:\repo\lib\presentation\page.dart'; - const target = r'C:\repo\lib\data\repo.dart'; - - final resolved = resolveImport( - source, - 'package:app/data/repo.dart', - {source, target}, - projectPath: r'C:\repo', - context: windows, - ); - - expect(resolved, target); - }); - }); -} diff --git a/pubspec.yaml b/pubspec.yaml index 535975c..22a7e0f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,12 +1,28 @@ -name: flutterguard -publish_to: none +name: flutterguard_cli +description: IoT Flutter static analysis CLI for architecture, lifecycle, state, security, and CI enforcement. +version: 0.7.1 +repository: https://github.com/lizy-coding/flutterguard +issue_tracker: https://github.com/lizy-coding/flutterguard/issues +topics: + - static-analysis + - flutter + - iot + - architecture + - cli environment: sdk: ^3.11.5 dependencies: - flutterguard_cli: - path: packages/flutterguard_cli + analyzer: ^7.3.0 + args: ^2.5.0 + glob: ^2.1.2 + path: ^1.9.0 + yaml: ^3.1.2 + +executables: + flutterguard: flutterguard dev_dependencies: - melos: ^6.3.2 + lints: ^3.0.0 + test: ^1.25.8 diff --git a/scripts/compile.ps1 b/scripts/compile.ps1 deleted file mode 100644 index 69c470c..0000000 --- a/scripts/compile.ps1 +++ /dev/null @@ -1,16 +0,0 @@ -$ErrorActionPreference = "Stop" - -$rootDir = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) -$src = Join-Path $rootDir "packages\flutterguard_cli\bin\flutterguard.dart" -$out = Join-Path $rootDir "flutterguard.exe" - -Write-Host "==> Compiling FlutterGuard CLI..." -ForegroundColor Cyan -dart compile exe $src -o $out - -if (Test-Path $out) { - Write-Host "==> Done: $out" -ForegroundColor Green - & $out --version -} else { - Write-Host "==> Error: compilation failed" -ForegroundColor Red - exit 1 -} diff --git a/scripts/compile.sh b/scripts/compile.sh deleted file mode 100755 index 8d86a11..0000000 --- a/scripts/compile.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -SRC="$ROOT_DIR/packages/flutterguard_cli/bin/flutterguard.dart" -OUT="$ROOT_DIR/flutterguard" - -echo "==> Compiling FlutterGuard CLI..." -dart compile exe "$SRC" -o "$OUT" - -if [ -f "$OUT" ]; then - echo "==> Done: $OUT" - "$OUT" --version -else - echo "==> Error: compilation failed" - exit 1 -fi diff --git a/scripts/flutterguard-dev b/scripts/flutterguard-dev deleted file mode 100755 index dc742c2..0000000 --- a/scripts/flutterguard-dev +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -exec dart "$ROOT_DIR/packages/flutterguard_cli/bin/flutterguard.dart" "$@" diff --git a/scripts/flutterguard-dev.ps1 b/scripts/flutterguard-dev.ps1 deleted file mode 100644 index e452889..0000000 --- a/scripts/flutterguard-dev.ps1 +++ /dev/null @@ -1,5 +0,0 @@ -$ErrorActionPreference = "Stop" - -$rootDir = Split-Path -Parent $PSScriptRoot -$entrypoint = Join-Path $rootDir "packages\flutterguard_cli\bin\flutterguard.dart" -dart $entrypoint @args diff --git a/scripts/package_release.ps1 b/scripts/package_release.ps1 index 24a038f..9891da9 100644 --- a/scripts/package_release.ps1 +++ b/scripts/package_release.ps1 @@ -1,10 +1,10 @@ $ErrorActionPreference = "Stop" $rootDir = Split-Path -Parent $PSScriptRoot -$pubspec = Join-Path $rootDir "packages\flutterguard_cli\pubspec.yaml" +$pubspec = Join-Path $rootDir "pubspec.yaml" $versionLine = Select-String -Path $pubspec -Pattern "^version:" | Select-Object -First 1 $version = ($versionLine.Line -split "\s+")[1] -$src = Join-Path $rootDir "packages\flutterguard_cli\bin\flutterguard.dart" +$src = Join-Path $rootDir "bin\flutterguard.dart" $dist = Join-Path $rootDir "dist" $name = "flutterguard-$version-windows-x64" $outDir = Join-Path $dist $name diff --git a/scripts/package_release.sh b/scripts/package_release.sh index ac0caf7..289b7ed 100755 --- a/scripts/package_release.sh +++ b/scripts/package_release.sh @@ -2,8 +2,8 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -VERSION="$(grep '^version:' "$ROOT_DIR/packages/flutterguard_cli/pubspec.yaml" | awk '{print $2}')" -SRC="$ROOT_DIR/packages/flutterguard_cli/bin/flutterguard.dart" +VERSION="$(grep '^version:' "$ROOT_DIR/pubspec.yaml" | awk '{print $2}')" +SRC="$ROOT_DIR/bin/flutterguard.dart" DIST="$ROOT_DIR/dist" case "$(uname -s)" in diff --git a/scripts/release_preflight.sh b/scripts/release_preflight.sh new file mode 100644 index 0000000..878acf2 --- /dev/null +++ b/scripts/release_preflight.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Runs the publish checks that can be automated before creating a pub.dev release. +# It never uploads a package. +set -u -o pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +failures=() + +pass() { + printf 'PASS %s\n' "$1" +} + +fail() { + printf 'FAIL %s\n' "$1" >&2 + failures+=("$1") +} + +run_gate() { + local label="$1" + shift + printf '\n== %s ==\n' "$label" + if "$@"; then + pass "$label" + else + fail "$label" + fi +} + +printf 'FlutterGuard release preflight (no package upload)\n' +printf 'Repository: %s\n\n' "$ROOT_DIR" + +for required_file in pubspec.yaml README.md CHANGELOG.md LICENSE; do + if [[ -s "$required_file" ]]; then + pass "required file: $required_file" + else + fail "required file: $required_file" + fi +done + +if git diff --check; then + pass 'no whitespace errors' +else + fail 'no whitespace errors' +fi + +if [[ -z "$(git status --porcelain)" ]]; then + pass 'clean Git worktree' +else + fail 'clean Git worktree (commit, stash, or remove local changes before publishing)' +fi + +run_gate 'resolve dependencies' dart pub get +run_gate 'format gate' dart format --output=none --set-exit-if-changed bin lib test +run_gate 'static analysis' dart analyze +run_gate 'test suite' dart test +run_gate 'CLI smoke scan' dart run bin/flutterguard.dart scan example --format json --no-color --fail-on high +run_gate 'pub.dev dry run' dart pub publish --server https://pub.dev --dry-run + +VERSION="$(awk '/^version:[[:space:]]*/ { print $2; exit }' pubspec.yaml)" +EXPECTED_TAG="v$VERSION" + +printf '\n== Human release checklist ==\n' +printf '[ ] Inspect the file list and warnings printed by "pub.dev dry run"; do not publish unwanted files.\n' +printf '[ ] Confirm that every included file may legally be redistributed and the LICENSE is correct.\n' +printf '[ ] Confirm %s is a new pub.dev version and CHANGELOG.md describes this release.\n' "$VERSION" +printf '[ ] Create and push tag %s at the intended release commit.\n' "$EXPECTED_TAG" +printf '[ ] Confirm the publishing Google account has access to the intended pub.dev publisher.\n' + +if git rev-parse -q --verify "refs/tags/$EXPECTED_TAG" >/dev/null; then + tag_commit="$(git rev-list -n 1 "$EXPECTED_TAG")" + head_commit="$(git rev-parse HEAD)" + if [[ "$tag_commit" == "$head_commit" ]]; then + pass "local tag $EXPECTED_TAG points at HEAD" + else + printf 'INFO local tag %s does not point at HEAD; verify the release commit before pushing it.\n' "$EXPECTED_TAG" + fi +else + printf 'INFO local tag %s has not been created yet.\n' "$EXPECTED_TAG" +fi + +if (( ${#failures[@]} > 0 )); then + printf '\nRelease preflight failed (%d gate(s)):\n' "${#failures[@]}" >&2 + for failure in "${failures[@]}"; do + printf ' - %s\n' "$failure" >&2 + done + exit 1 +fi + +printf '\nAutomated preflight passed. Complete every human checklist item before running dart pub publish.\n' diff --git a/scripts/scan_ci.ps1 b/scripts/scan_ci.ps1 deleted file mode 100644 index 2c0bd08..0000000 --- a/scripts/scan_ci.ps1 +++ /dev/null @@ -1,29 +0,0 @@ -$ErrorActionPreference = "Stop" - -$failOn = $env:FLUTTERGUARD_FAIL_ON ?? "high" -$minScore = $env:FLUTTERGUARD_MIN_SCORE ?? "80" -$rootDir = Split-Path -Parent $PSScriptRoot -$target = if ($args.Count -gt 0) { $args[0] } else { Join-Path $rootDir "examples\scan_demo" } -$launcher = Join-Path $PSScriptRoot "flutterguard-dev.ps1" - -Write-Host "==> FlutterGuard CI Scan" -ForegroundColor Cyan -Write-Host " Target: $target" -Write-Host " Fail-on: $failOn" -Write-Host " Min-score: $minScore" -Write-Host "" - -& $launcher scan $target --format json --fail-on $failOn --min-score $minScore -$status = $LASTEXITCODE - -if ($status -eq 0) { - Write-Host "" - Write-Host "All checks passed!" -ForegroundColor Green -} else { - Write-Host "" - if ($status -eq 1) { - Write-Host "CI gate failed! Check $target/.flutterguard/report.json for details." -ForegroundColor Red - } else { - Write-Host "FlutterGuard scan setup failed with exit code $status." -ForegroundColor Red - } - exit $status -} diff --git a/scripts/scan_ci.sh b/scripts/scan_ci.sh deleted file mode 100755 index 2c64ead..0000000 --- a/scripts/scan_ci.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -FAIL_ON="${FLUTTERGUARD_FAIL_ON:-high}" -MIN_SCORE="${FLUTTERGUARD_MIN_SCORE:-80}" -TARGET="${1:-$ROOT_DIR/examples/scan_demo}" - -echo "==> FlutterGuard CI Scan" -echo " Target: $TARGET" -echo " Fail-on: $FAIL_ON" -echo " Min-score: $MIN_SCORE" -echo "" - -if "$SCRIPT_DIR/flutterguard-dev" scan "$TARGET" --format json --fail-on "$FAIL_ON" --min-score "$MIN_SCORE"; then - echo "" - echo "All checks passed!" -else - status=$? - echo "" - if [ "$status" -eq 1 ]; then - echo "CI gate failed! Check $TARGET/.flutterguard/report.json for details." - else - echo "FlutterGuard scan setup failed with exit code $status." - fi - exit "$status" -fi diff --git a/test/AGENTS.md b/test/AGENTS.md new file mode 100644 index 0000000..bdedddf --- /dev/null +++ b/test/AGENTS.md @@ -0,0 +1,13 @@ +# Tests + +Tests are organized by contract rather than one monolithic suite: + +- `cli_test.dart`: process-level exit/path/report behavior. +- `config_test.dart`: generic config and registry-driven templates. +- `rules_test.dart`: detector ownership and positive cases. +- `scanner_test.dart`: orchestration, reports, baseline, and CI gates. +- `fixtures/`: parser inputs excluded from repository analysis. + +Prefer temporary directories for project/config/Git behavior. Every external +contract change must update a focused assertion; do not preserve deleted +features merely to keep the historical test count. diff --git a/packages/flutterguard_cli/test/cli_test.dart b/test/cli_test.dart similarity index 54% rename from packages/flutterguard_cli/test/cli_test.dart rename to test/cli_test.dart index 72a879e..2254499 100644 --- a/packages/flutterguard_cli/test/cli_test.dart +++ b/test/cli_test.dart @@ -5,32 +5,49 @@ import 'package:path/path.dart' as p; import 'package:test/test.dart'; void _runGit(Directory directory, List args) { - final result = Process.runSync( - 'git', - args, - workingDirectory: directory.path, - ); + final result = Process.runSync('git', args, workingDirectory: directory.path); if (result.exitCode != 0) { throw StateError('git ${args.join(' ')} failed: ${result.stderr}'); } } void main() { - test('scan exits with setup error when the project matches no Dart files', - () { - final project = Directory.systemTemp.createTempSync( - 'flutterguard_cli_empty_project_', - ); - addTearDown(() => project.deleteSync(recursive: true)); - + test('CLI version matches package version', () { + final pubspec = File( + p.join(Directory.current.path, 'pubspec.yaml'), + ).readAsStringSync(); + final version = RegExp( + r'^version:\s*(\S+)', + multiLine: true, + ).firstMatch(pubspec)!.group(1); final entrypoint = p.join( Directory.current.path, 'bin', 'flutterguard.dart', ); - final result = Process.runSync( - Platform.resolvedExecutable, - [ + final result = Process.runSync(Platform.resolvedExecutable, [ + entrypoint, + '--version', + ], workingDirectory: Directory.current.path); + + expect(result.exitCode, 0); + expect((result.stdout as String).trim(), 'flutterguard $version'); + }); + + test( + 'scan exits with setup error when the project matches no Dart files', + () { + final project = Directory.systemTemp.createTempSync( + 'flutterguard_cli_empty_project_', + ); + addTearDown(() => project.deleteSync(recursive: true)); + + final entrypoint = p.join( + Directory.current.path, + 'bin', + 'flutterguard.dart', + ); + final result = Process.runSync(Platform.resolvedExecutable, [ entrypoint, 'scan', project.path, @@ -39,18 +56,18 @@ void main() { '--output', '.flutterguard/test', '--no-color', - ], - workingDirectory: Directory.current.path, - ); + ], workingDirectory: Directory.current.path); - expect(result.exitCode, 2); - expect(result.stderr, contains('No Dart files matched')); - expect( - File(p.join(project.path, '.flutterguard', 'test', 'report.json')) - .existsSync(), - isFalse, - ); - }); + expect(result.exitCode, 2); + expect(result.stderr, contains('No Dart files matched')); + expect( + File( + p.join(project.path, '.flutterguard', 'test', 'report.json'), + ).existsSync(), + isFalse, + ); + }, + ); test('changed-only clean scan succeeds and writes an empty JSON report', () { final project = Directory.systemTemp.createTempSync( @@ -58,8 +75,9 @@ void main() { ); addTearDown(() => project.deleteSync(recursive: true)); Directory(p.join(project.path, 'lib')).createSync(); - File(p.join(project.path, 'lib', 'clean.dart')) - .writeAsStringSync('class Clean {}\n'); + File( + p.join(project.path, 'lib', 'clean.dart'), + ).writeAsStringSync('class Clean {}\n'); _runGit(project, ['init', '-b', 'main']); _runGit(project, ['config', 'user.email', 'test@example.com']); _runGit(project, ['config', 'user.name', 'FlutterGuard Test']); @@ -71,23 +89,19 @@ void main() { 'bin', 'flutterguard.dart', ); - final result = Process.runSync( - Platform.resolvedExecutable, - [ - entrypoint, - 'scan', - project.path, - '--changed-only', - '--base', - 'main', - '--format', - 'json', - '--output', - '.flutterguard/test', - '--no-color', - ], - workingDirectory: Directory.current.path, - ); + final result = Process.runSync(Platform.resolvedExecutable, [ + entrypoint, + 'scan', + project.path, + '--changed-only', + '--base', + 'main', + '--format', + 'json', + '--output', + '.flutterguard/test', + '--no-color', + ], workingDirectory: Directory.current.path); expect(result.exitCode, 0); expect(result.stdout, contains('No changed Dart files matched')); @@ -107,26 +121,23 @@ void main() { ); addTearDown(() => project.deleteSync(recursive: true)); Directory(p.join(project.path, 'lib')).createSync(); - File(p.join(project.path, 'lib', 'plain.dart')) - .writeAsStringSync('class Plain {}\n'); + File( + p.join(project.path, 'lib', 'plain.dart'), + ).writeAsStringSync('class Plain {}\n'); final entrypoint = p.join( Directory.current.path, 'bin', 'flutterguard.dart', ); - final result = Process.runSync( - Platform.resolvedExecutable, - [ - entrypoint, - 'scan', - project.path, - '--config', - 'flutterguard.yaml', - '--no-color', - ], - workingDirectory: Directory.current.path, - ); + final result = Process.runSync(Platform.resolvedExecutable, [ + entrypoint, + 'scan', + project.path, + '--config', + 'flutterguard.yaml', + '--no-color', + ], workingDirectory: Directory.current.path); expect(result.exitCode, 2); expect(result.stderr, contains('Config file')); @@ -143,8 +154,9 @@ void main() { final targetProject = Directory(p.join(sandbox.path, 'target')) ..createSync(recursive: true); Directory(p.join(targetProject.path, 'lib')).createSync(); - File(p.join(targetProject.path, 'lib', 'target.dart')) - .writeAsStringSync('class Target {}\n'); + File( + p.join(targetProject.path, 'lib', 'target.dart'), + ).writeAsStringSync('class Target {}\n'); File(p.join(workingProject.path, 'flutterguard.yaml')).writeAsStringSync(''' include: - cwd_only/** @@ -159,29 +171,22 @@ include: 'bin', 'flutterguard.dart', ); - final result = Process.runSync( - Platform.resolvedExecutable, - [ - entrypoint, - 'scan', - targetProject.path, - '--format', - 'json', - '--output', - '.flutterguard/test', - '--no-color', - ], - workingDirectory: workingProject.path, - ); + final result = Process.runSync(Platform.resolvedExecutable, [ + entrypoint, + 'scan', + targetProject.path, + '--format', + 'json', + '--output', + '.flutterguard/test', + '--no-color', + ], workingDirectory: workingProject.path); expect(result.exitCode, 0); expect( - File(p.join( - targetProject.path, - '.flutterguard', - 'test', - 'report.json', - )).existsSync(), + File( + p.join(targetProject.path, '.flutterguard', 'test', 'report.json'), + ).existsSync(), isTrue, ); }); diff --git a/test/config_test.dart b/test/config_test.dart new file mode 100644 index 0000000..6cd4d56 --- /dev/null +++ b/test/config_test.dart @@ -0,0 +1,103 @@ +import 'dart:io'; + +import 'package:flutterguard_cli/src/config_loader.dart'; +import 'package:flutterguard_cli/src/config_tools.dart'; +import 'package:flutterguard_cli/src/rules/registry.dart'; +import 'package:flutterguard_cli/src/static_issue.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + test('zero-config defaults remain small and deterministic', () { + final config = ScanConfig.defaults(); + expect(config.include, ['lib/**']); + expect(config.architecture.layers, isEmpty); + expect(config.configuredRuleIds, isEmpty); + }); + + test('generic rule config parses severity and typed options', () { + final directory = Directory.systemTemp.createTempSync('fg_config_'); + addTearDown(() => directory.deleteSync(recursive: true)); + final file = File(p.join(directory.path, 'flutterguard.yaml')) + ..writeAsStringSync(''' +rules: + iot_security: + enabled: true + severity: high + requireTls: false +'''); + final config = ScanConfig.fromFile(file.path); + final definition = RuleRegistry.find('iot_security')!; + final rule = config.rule( + definition.id, + defaultSeverity: definition.defaultSeverity, + defaultOptions: definition.defaultOptions, + ); + expect(rule.severity, RiskLevel.high); + expect(rule.boolOption('requireTls'), isFalse); + }); + + test('rule registry drives the generated starter config', () { + final template = ConfigTools.initTemplate(withArchitecture: false); + expect(RuleRegistry.all(), hasLength(16)); + for (final rule in RuleRegistry.all()) { + expect(template, contains(' ${rule.id}:')); + } + expect(template, isNot(contains('confidence_threshold'))); + expect(template, isNot(contains('large_file:'))); + expect(template, isNot(contains('mqtt_connection:'))); + expect(template, isNot(contains('pubspec_security:'))); + }); + + test('invalid config values fail fast', () { + final directory = Directory.systemTemp.createTempSync('fg_bad_config_'); + addTearDown(() => directory.deleteSync(recursive: true)); + final file = File(p.join(directory.path, 'flutterguard.yaml')) + ..writeAsStringSync('rules:\n ble_scanning: false\n'); + expect(() => ScanConfig.fromFile(file.path), throwsFormatException); + }); + + test('config check rejects unknown rule IDs', () { + final project = Directory.systemTemp.createTempSync('fg_doctor_'); + addTearDown(() => project.deleteSync(recursive: true)); + Directory(p.join(project.path, 'lib')).createSync(); + File( + p.join(project.path, 'lib', 'main.dart'), + ).writeAsStringSync('class A {}'); + File(p.join(project.path, 'flutterguard.yaml')).writeAsStringSync(''' +rules: + removed_rule: + enabled: true +'''); + final result = ConfigTools.doctor(projectPath: project.path); + expect(result.hasErrors, isTrue); + expect(result.messages.single.message, contains('removed_rule')); + }); + + test('config check rejects options absent from the rule definition', () { + final project = Directory.systemTemp.createTempSync('fg_options_'); + addTearDown(() => project.deleteSync(recursive: true)); + Directory(p.join(project.path, 'lib')).createSync(); + File( + p.join(project.path, 'lib', 'main.dart'), + ).writeAsStringSync('class A {}'); + File(p.join(project.path, 'flutterguard.yaml')).writeAsStringSync(''' +rules: + ble_scanning: + allowlist: [LegacyScanner] +'''); + expect( + () => ConfigTools.doctor(projectPath: project.path), + throwsFormatException, + ); + + File(p.join(project.path, 'flutterguard.yaml')).writeAsStringSync(''' +rules: + ble_scanning: + timeoutAlias: 5000 +'''); + final result = ConfigTools.doctor(projectPath: project.path); + expect(result.hasErrors, isTrue); + expect(result.messages.single.message, contains('timeoutAlias')); + }); +} diff --git a/test/fixtures/AGENTS.md b/test/fixtures/AGENTS.md new file mode 100644 index 0000000..825948b --- /dev/null +++ b/test/fixtures/AGENTS.md @@ -0,0 +1,13 @@ +# Rule fixtures + +Fixtures are intentionally small parser inputs and may reference Flutter or +third-party types that are not dependencies of this Dart CLI package. + +- architecture and cycle files exercise import graphs and boundaries; +- lifecycle/BLE/security files exercise IoT ownership; +- generic/Riverpod/Bloc/Provider files exercise state rules; +- `state_suppression.dart` exercises inline suppression. + +Keep one behavior per fixture where practical. Add imports when framework +auto-detection depends on them. Do not add generated outputs or full sample +applications here. diff --git a/packages/flutterguard_cli/test/fixtures/architecture_config.yaml b/test/fixtures/architecture_config.yaml similarity index 76% rename from packages/flutterguard_cli/test/fixtures/architecture_config.yaml rename to test/fixtures/architecture_config.yaml index c74f5d5..70978a4 100644 --- a/packages/flutterguard_cli/test/fixtures/architecture_config.yaml +++ b/test/fixtures/architecture_config.yaml @@ -2,17 +2,15 @@ include: - "**/*.dart" exclude: [] rules: - large_file: + lifecycle_resource_not_disposed: enabled: true - maxLines: 500 - large_class: + severity: medium + layer_violation: enabled: true - maxLines: 300 - large_build_method: - enabled: true - maxLines: 80 - lifecycle_resource: + severity: high + module_violation: enabled: true + severity: high architecture: layers: - name: ui diff --git a/packages/flutterguard_cli/test/fixtures/architecture_disabled.yaml b/test/fixtures/architecture_disabled.yaml similarity index 71% rename from packages/flutterguard_cli/test/fixtures/architecture_disabled.yaml rename to test/fixtures/architecture_disabled.yaml index 0681672..6a5f3c8 100644 --- a/packages/flutterguard_cli/test/fixtures/architecture_disabled.yaml +++ b/test/fixtures/architecture_disabled.yaml @@ -2,17 +2,10 @@ include: - "**/*.dart" exclude: [] rules: - large_file: - enabled: true - maxLines: 500 - large_class: - enabled: true - maxLines: 300 - large_build_method: - enabled: true - maxLines: 80 - lifecycle_resource: - enabled: true + layer_violation: + enabled: false + module_violation: + enabled: false architecture: layers: - name: ui @@ -29,7 +22,3 @@ architecture: path: "**/forbidden_file.dart" allowed_deps: [] detect_cycles: false - layer_violation: - enabled: false - module_violation: - enabled: false diff --git a/packages/flutterguard_cli/test/fixtures/ble_scanning_issue.dart b/test/fixtures/ble_scanning_issue.dart similarity index 88% rename from packages/flutterguard_cli/test/fixtures/ble_scanning_issue.dart rename to test/fixtures/ble_scanning_issue.dart index c6fdaf7..62b5a57 100644 --- a/packages/flutterguard_cli/test/fixtures/ble_scanning_issue.dart +++ b/test/fixtures/ble_scanning_issue.dart @@ -6,7 +6,7 @@ class BleService { late BleDevice _device; void startScan() { - // scanning without timeout + // scanning indefinitely } void connect() { diff --git a/test/fixtures/ble_scanning_negative.dart b/test/fixtures/ble_scanning_negative.dart new file mode 100644 index 0000000..0532fea --- /dev/null +++ b/test/fixtures/ble_scanning_negative.dart @@ -0,0 +1,20 @@ +// Fixture: BLE scanning with proper timeout configuration +class BleDevice {} + +class ScanResult {} + +class BleService { + late BleDevice _device; + + void startScan({Duration? timeout}) { + // configured with timeout parameter + } + + void startScanWithTimeout() { + FlutterBluePlus.startScan(timeout: const Duration(seconds: 30)); + } + + void stopAndDisconnect() { + _device.disconnect(); + } +} diff --git a/test/fixtures/ble_scanning_suppression.dart b/test/fixtures/ble_scanning_suppression.dart new file mode 100644 index 0000000..cb5439d --- /dev/null +++ b/test/fixtures/ble_scanning_suppression.dart @@ -0,0 +1,14 @@ +// ignore_for_file: unused_local_variable +// Fixture: BLE scanning with suppression comments +class BleSuppressedDevice {} + +class BleSuppressedService { + late BleSuppressedDevice _device; + + // flutterguard: ignore ble_scanning + void startScan() { + // scanning without timeout, suppressed + } + + void connect() {} +} diff --git a/test/fixtures/bloc_state.dart b/test/fixtures/bloc_state.dart new file mode 100644 index 0000000..9467542 --- /dev/null +++ b/test/fixtures/bloc_state.dart @@ -0,0 +1,37 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class DeviceState extends Equatable { + const DeviceState(this.name, this.connected); + + final String name; + final bool connected; + + List get props => [name]; +} + +class ReadingState extends Equatable { + const ReadingState(this.temperature, this.humidity); + + final double temperature; + final double humidity; + + List get props => [temperature]; +} + +class CompleteState extends Equatable { + const CompleteState(this.a, this.b); + + final int a; + final int b; + + List get props => [a, b]; +} + +class EmptyState extends Equatable { + const EmptyState(); + + static const version = 1; + int get computed => 1; + List get props => const []; +} diff --git a/packages/flutterguard_cli/test/fixtures/boundary_issue.dart b/test/fixtures/boundary_issue.dart similarity index 100% rename from packages/flutterguard_cli/test/fixtures/boundary_issue.dart rename to test/fixtures/boundary_issue.dart diff --git a/packages/flutterguard_cli/test/fixtures/cycle_a.dart b/test/fixtures/cycle_a.dart similarity index 100% rename from packages/flutterguard_cli/test/fixtures/cycle_a.dart rename to test/fixtures/cycle_a.dart diff --git a/packages/flutterguard_cli/test/fixtures/cycle_b.dart b/test/fixtures/cycle_b.dart similarity index 100% rename from packages/flutterguard_cli/test/fixtures/cycle_b.dart rename to test/fixtures/cycle_b.dart diff --git a/packages/flutterguard_cli/test/fixtures/cycle_c.dart b/test/fixtures/cycle_c.dart similarity index 100% rename from packages/flutterguard_cli/test/fixtures/cycle_c.dart rename to test/fixtures/cycle_c.dart diff --git a/packages/flutterguard_cli/test/fixtures/forbidden_file.dart b/test/fixtures/forbidden_file.dart similarity index 100% rename from packages/flutterguard_cli/test/fixtures/forbidden_file.dart rename to test/fixtures/forbidden_file.dart diff --git a/test/fixtures/generic_state.dart b/test/fixtures/generic_state.dart new file mode 100644 index 0000000..0ff3f95 --- /dev/null +++ b/test/fixtures/generic_state.dart @@ -0,0 +1,92 @@ +class Widget { + Widget({Object? onPressed}); +} + +class BuildContext {} + +class StatelessWidget {} + +class DeviceController { + DeviceController(); + + final List _items = []; + List get items => _items; + + void mutate() { + state.items.add(1); + } +} + +class DeviceBloc {} + +class BadBuildWidget extends StatelessWidget { + Widget build(BuildContext context) { + notifyListeners(); + final controller = DeviceController(); + return Widget(); + } +} + +class SecondBadBuildWidget extends StatelessWidget { + Widget build(BuildContext context) { + setState(() {}); + final bloc = DeviceBloc(); + return Widget(); + } +} + +class SafeBuildWidget extends StatelessWidget { + Widget build(BuildContext context) { + final values = []; + values.add(1); + return Widget( + onPressed: () { + notifyListeners(); + final controller = DeviceController(); + }, + ); + } +} + +class DeviceState { + int count = 0; + final List items = []; +} + +class SafeState { + final List items = List.unmodifiable(const []); +} + +class State {} + +class Page {} + +class PageState extends State { + int widgetCounter = 0; +} + +class NavigationController { + NavigationController(this.context); + + final BuildContext context; + + void open() { + Navigator.of(context).push('/device'); + } +} + +class ThemeController { + void update(Widget widget) {} +} + +class CycleController { + CycleController(this.service); + + final CycleService service; +} + +class CycleService { + CycleService(this.controller); + + final CycleController controller; +} diff --git a/packages/flutterguard_cli/test/fixtures/iot_security_issue.dart b/test/fixtures/iot_security_issue.dart similarity index 100% rename from packages/flutterguard_cli/test/fixtures/iot_security_issue.dart rename to test/fixtures/iot_security_issue.dart diff --git a/test/fixtures/iot_security_negative.dart b/test/fixtures/iot_security_negative.dart new file mode 100644 index 0000000..772ecff --- /dev/null +++ b/test/fixtures/iot_security_negative.dart @@ -0,0 +1,15 @@ +// Fixture: safe IoT code that should NOT trigger iot_security +class SafeIotService { + void connect() { + final password = getPasswordFromEnv(); + final brokerUrl = "mqtts://secure-broker.example.com:8883"; + final apiUrl = "https://iot.example.com/api/data"; + final bleConfig = BondingConfig.withPairing(); + } + + String getPasswordFromEnv() => ''; +} + +class BondingConfig { + BondingConfig.withPairing(); +} diff --git a/test/fixtures/iot_security_suppression.dart b/test/fixtures/iot_security_suppression.dart new file mode 100644 index 0000000..d57da1e --- /dev/null +++ b/test/fixtures/iot_security_suppression.dart @@ -0,0 +1,16 @@ +// Fixture: IoT security issues with suppression comments +class SuppressedIotWidget { + void connect() { + // flutterguard: ignore iot_security + final password = "admin123"; + + // flutterguard: ignore iot_security + final brokerUrl = "tcp://192.168.1.100:1883"; + + // flutterguard: ignore iot_security + final apiUrl = "http://iot.example.com/api/data"; + + // flutterguard: ignore iot_security + final bleConfig = "withoutBonding"; + } +} diff --git a/packages/flutterguard_cli/test/fixtures/lifecycle_issue.dart b/test/fixtures/lifecycle_issue.dart similarity index 78% rename from packages/flutterguard_cli/test/fixtures/lifecycle_issue.dart rename to test/fixtures/lifecycle_issue.dart index 8c902c0..e8815d4 100644 --- a/packages/flutterguard_cli/test/fixtures/lifecycle_issue.dart +++ b/test/fixtures/lifecycle_issue.dart @@ -1,18 +1,23 @@ // ignore_for_file: unused_field, inference_failure_on_instance_creation import 'dart:async'; +class OverlayEntry {} + // Fixture: lifecycle resource not disposed class LifecycleIssueWidget { late StreamSubscription _subscription; late Timer _timer; + late OverlayEntry _overlayEntry; LifecycleIssueWidget() { _subscription = const Stream.empty().listen((_) {}); _timer = Timer.periodic(const Duration(seconds: 1), (_) {}); + _overlayEntry = OverlayEntry(); } void dispose() { // _subscription.cancel() is missing // _timer.cancel() is missing + // _overlayEntry.remove() is missing } } diff --git a/test/fixtures/lifecycle_negative.dart b/test/fixtures/lifecycle_negative.dart new file mode 100644 index 0000000..1a9fa84 --- /dev/null +++ b/test/fixtures/lifecycle_negative.dart @@ -0,0 +1,25 @@ +// ignore_for_file: unused_field, inference_failure_on_instance_creation +import 'dart:async'; + +class OverlayEntry {} + +class StreamController {} + +// Fixture: lifecycle resources properly disposed +class LifecycleOkWidget { + late StreamSubscription _subscription; + late Timer _timer; + late OverlayEntry _overlayEntry; + + LifecycleOkWidget() { + _subscription = const Stream.empty().listen((_) {}); + _timer = Timer.periodic(const Duration(seconds: 1), (_) {}); + _overlayEntry = OverlayEntry(); + } + + void dispose() { + _subscription.cancel(); + _timer.cancel(); + _overlayEntry.remove(); + } +} diff --git a/test/fixtures/provider_state.dart b/test/fixtures/provider_state.dart new file mode 100644 index 0000000..7cd4c71 --- /dev/null +++ b/test/fixtures/provider_state.dart @@ -0,0 +1,42 @@ +import 'package:provider/provider.dart'; + +class DeviceController {} + +class ImmutableValue { + const ImmutableValue(); +} + +final existingController = DeviceController(); + +final badValue = ChangeNotifierProvider.value( + value: DeviceController(), +); + +final badCreate = ChangeNotifierProvider(create: (_) => existingController); + +final safeCreate = ChangeNotifierProvider(create: (_) => DeviceController()); + +final safeValue = ChangeNotifierProvider.value(value: existingController); + +final safeImmutable = Provider.value(value: const ImmutableValue()); + +class DeviceNotifier { + void updateAll(List values) { + for (final value in values) { + notifyListeners(); + } + values.forEach((value) { + notifyListeners(); + }); + } + + void safe() { + for (final value in [1]) { + notifyListeners(); + } + for (final value in [1, 2]) { + consume(value); + } + notifyListeners(); + } +} diff --git a/test/fixtures/riverpod_state.dart b/test/fixtures/riverpod_state.dart new file mode 100644 index 0000000..fd222d3 --- /dev/null +++ b/test/fixtures/riverpod_state.dart @@ -0,0 +1,47 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class Widget { + Widget({Object? value, Object? onPressed, Object? onTap}); +} + +class BuildContext {} + +class ConsumerWidget { + Widget build(BuildContext context, dynamic ref) => Widget(); +} + +final deviceProvider = Provider((ref) => 1); + +class DirectReadWidget extends ConsumerWidget { + Widget build(BuildContext context, dynamic ref) { + return Widget(value: ref.read(deviceProvider)); + } +} + +class ConditionalReadWidget extends ConsumerWidget { + Widget build(BuildContext context, dynamic ref) { + final enabled = ref.read(deviceProvider); + if (enabled) return Widget(); + return Widget(); + } +} + +class SafeReadWidget extends ConsumerWidget { + Widget build(BuildContext context, dynamic ref) { + ref.read(deviceProvider.notifier).refresh(); + return Widget(onPressed: () => ref.read(deviceProvider)); + } +} + +class WatchCallbackWidget extends ConsumerWidget { + Widget build(BuildContext context, dynamic ref) { + final value = ref.watch(deviceProvider); + return Widget( + value: value, + onPressed: () => ref.watch(deviceProvider), + onTap: () async { + ref.watch(deviceProvider); + }, + ); + } +} diff --git a/test/fixtures/state_suppression.dart b/test/fixtures/state_suppression.dart new file mode 100644 index 0000000..b744710 --- /dev/null +++ b/test/fixtures/state_suppression.dart @@ -0,0 +1,66 @@ +class SuppressedBuildWidget { + // flutterguard: ignore side_effect_in_build + Object build(Object context) { + notifyListeners(); + // flutterguard: ignore state_manager_created_in_build + final controller = SuppressedController(); + return controller; + } +} + +class SuppressedController { + // flutterguard: ignore mutable_state_exposed + int counter = 0; +} + +// flutterguard: ignore state_layer_ui_dependency +class SuppressedNavigationController { + void open(BuildContext context) { + Navigator.of(context).push('/device'); + } +} + +// flutterguard: ignore state_dependency_cycle +class SuppressedCycleController { + final SuppressedCycleService service; +} + +class SuppressedCycleService { + final SuppressedCycleController controller; +} + +class SuppressedRiverpodWidget { + Object build(Object context, dynamic ref) { + // flutterguard: ignore riverpod_read_used_for_render + return Text(ref.read(deviceProvider)); + } + + Object callbacks(dynamic ref) { + return Button( + // flutterguard: ignore riverpod_watch_in_callback + onPressed: () => ref.watch(deviceProvider), + ); + } +} + +// flutterguard: ignore bloc_equatable_props_incomplete +class SuppressedEquatableState extends Equatable { + final String name; + final bool connected; + + List get props => [name]; +} + +// flutterguard: ignore provider_value_lifecycle_misuse +final suppressedProvider = ChangeNotifierProvider.value( + value: SuppressedNotifier(), +); + +class SuppressedNotifier { + void update(List values) { + // flutterguard: ignore notify_listeners_in_loop + for (final value in values) { + notifyListeners(); + } + } +} diff --git a/test/rules_test.dart b/test/rules_test.dart new file mode 100644 index 0000000..440a764 --- /dev/null +++ b/test/rules_test.dart @@ -0,0 +1,247 @@ +import 'dart:io'; + +import 'package:flutterguard_cli/src/config_loader.dart'; +import 'package:flutterguard_cli/src/import_graph.dart'; +import 'package:flutterguard_cli/src/rules/ble_scanning.dart'; +import 'package:flutterguard_cli/src/rules/bloc_state_management.dart'; +import 'package:flutterguard_cli/src/rules/boundary_rule.dart'; +import 'package:flutterguard_cli/src/rules/circular_dependency.dart'; +import 'package:flutterguard_cli/src/rules/generic_state_management.dart'; +import 'package:flutterguard_cli/src/rules/iot_security.dart'; +import 'package:flutterguard_cli/src/rules/lifecycle_resource.dart'; +import 'package:flutterguard_cli/src/rules/provider_state_management.dart'; +import 'package:flutterguard_cli/src/rules/riverpod_state_management.dart'; +import 'package:flutterguard_cli/src/rules/state_dependency_cycle.dart'; +import 'package:flutterguard_cli/src/source_workspace.dart'; +import 'package:flutterguard_cli/src/static_issue.dart'; +import 'package:flutterguard_cli/src/suppression.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +String get _fixtures => p.join(Directory.current.path, 'test', 'fixtures'); + +RuleConfig _config( + RiskLevel severity, { + Map options = const {}, +}) => RuleConfig(enabled: true, severity: severity, options: options); + +RuleConfig get _disabled => + const RuleConfig(enabled: false, severity: RiskLevel.low); + +void main() { + group('IoT and lifecycle rules', () { + test('resource lifecycle finds undisposed fields', () { + final issues = LifecycleResourceRule( + _config(RiskLevel.medium), + ).analyze([p.join(_fixtures, 'lifecycle_issue.dart')]); + expect(issues, hasLength(3)); + expect( + issues.every((issue) => issue.id == 'lifecycle_resource_not_disposed'), + isTrue, + ); + expect( + issues.any( + (issue) => + (issue.metadata['resourceType'] as String) == 'OverlayEntry', + ), + isTrue, + ); + }); + + test('resource lifecycle negative: properly disposed resources', () { + final issues = LifecycleResourceRule( + _config(RiskLevel.medium), + ).analyze([p.join(_fixtures, 'lifecycle_negative.dart')]); + expect(issues, isEmpty); + }); + + test('resource lifecycle disabled produces no findings', () { + final issues = LifecycleResourceRule( + _disabled, + ).analyze([p.join(_fixtures, 'lifecycle_issue.dart')]); + expect(issues, isEmpty); + }); + + test('BLE rule only owns scan timeout', () { + final issues = BleScanningRule( + _config(RiskLevel.medium), + ).analyze([p.join(_fixtures, 'ble_scanning_issue.dart')]); + expect(issues, hasLength(1)); + expect(issues.single.metadata['check'], 'scan_without_timeout'); + }); + + test('BLE rule negative: startScan with timeout parameter', () { + final issues = BleScanningRule( + _config(RiskLevel.medium), + ).analyze([p.join(_fixtures, 'ble_scanning_negative.dart')]); + expect(issues, isEmpty); + }); + + test('BLE rule disabled produces no findings', () { + final issues = BleScanningRule( + _disabled, + ).analyze([p.join(_fixtures, 'ble_scanning_issue.dart')]); + expect(issues, isEmpty); + }); + + test('BLE rule suppression filters findings', () { + final file = p.join(_fixtures, 'ble_scanning_suppression.dart'); + final rawIssues = BleScanningRule( + _config(RiskLevel.medium), + ).analyze([file]); + expect(rawIssues, isNotEmpty); + final filter = SuppressionFilter([file]); + final visible = rawIssues.where((i) => !filter.isSuppressed(i)).toList(); + expect(visible, isEmpty); + }); + + test('IoT security detects transport and credential risks', () { + final issues = IotSecurityRule( + _config(RiskLevel.high, options: {'requireTls': true}), + ).analyze([p.join(_fixtures, 'iot_security_issue.dart')]); + expect(issues.length, greaterThanOrEqualTo(3)); + expect(issues.every((issue) => issue.level == RiskLevel.high), isTrue); + }); + + test('IoT security negative: safe code with proper protocols', () { + final issues = IotSecurityRule( + _config(RiskLevel.high, options: {'requireTls': true}), + ).analyze([p.join(_fixtures, 'iot_security_negative.dart')]); + expect(issues, isEmpty); + }); + + test('IoT security disabled produces no findings', () { + final issues = IotSecurityRule( + _disabled, + ).analyze([p.join(_fixtures, 'iot_security_issue.dart')]); + expect(issues, isEmpty); + }); + + test('IoT security requireTls disabled skips transport checks', () { + final issues = IotSecurityRule( + _config(RiskLevel.high, options: {'requireTls': false}), + ).analyze([p.join(_fixtures, 'iot_security_issue.dart')]); + final checks = issues.map((i) => i.metadata['securityCheck']).toSet(); + expect(checks, isNot(contains('cleartext_mqtt'))); + expect(checks, isNot(contains('cleartext_http'))); + }); + + test('IoT security suppression filters findings', () { + final file = p.join(_fixtures, 'iot_security_suppression.dart'); + final rawIssues = IotSecurityRule( + _config(RiskLevel.high, options: {'requireTls': true}), + ).analyze([file]); + expect(rawIssues, isNotEmpty); + final filter = SuppressionFilter([file]); + final visible = rawIssues.where((i) => !filter.isSuppressed(i)).toList(); + expect(visible, isEmpty); + }); + + test('IoT security report contract: JSON output fields', () { + final issues = IotSecurityRule( + _config(RiskLevel.high, options: {'requireTls': true}), + ).analyze([p.join(_fixtures, 'iot_security_issue.dart')]); + for (final issue in issues) { + final json = issue.toJson(); + expect(json['ruleId'], 'iot_security'); + expect(json, contains('severity')); + expect(json, contains('domain')); + expect(json, contains('message')); + expect(json, contains('suggestion')); + expect(json, isNot(contains('id'))); + } + }); + }); + + group('Architecture rules', () { + test('one boundary engine serves layer and module rules', () { + final files = [ + p.join(_fixtures, 'boundary_issue.dart'), + p.join(_fixtures, 'forbidden_file.dart'), + ]; + final workspace = SourceWorkspace(); + final graph = ImportGraph.build( + files: files, + sourceFiles: files, + workspace: workspace, + projectPath: Directory.current.path, + ); + final boundaries = [ + (name: 'ui', path: '**/boundary_issue.dart', allowedDeps: const []), + (name: 'model', path: '**/forbidden_file.dart', allowedDeps: const []), + ]; + for (final kind in BoundaryKind.values) { + final issues = + BoundaryRule( + kind: kind, + boundaries: boundaries, + severity: RiskLevel.high, + projectPath: Directory.current.path, + ).analyze( + files, + allFiles: files, + workspace: workspace, + importGraph: graph, + ); + expect(issues, hasLength(1)); + expect(issues.single.id, '${kind.name}_violation'); + } + }); + + test('circular dependency uses the shared import graph', () { + final files = [ + p.join(_fixtures, 'cycle_a.dart'), + p.join(_fixtures, 'cycle_b.dart'), + p.join(_fixtures, 'cycle_c.dart'), + ]; + final issues = CircularDependencyRule( + projectPath: Directory.current.path, + ).analyze(files); + expect(issues, isNotEmpty); + expect(issues.first.id, 'circular_dependency'); + }); + }); + + group('State architecture rules', () { + test('generic state rules detect build and mutable-state problems', () { + final file = p.join(_fixtures, 'generic_state.dart'); + final scans = Function()>[ + () => SideEffectInBuildRule(_config(RiskLevel.high)).analyze([file]), + () => StateManagerCreatedInBuildRule( + _config(RiskLevel.high), + ).analyze([file]), + () => + MutableStateExposedRule(_config(RiskLevel.medium)).analyze([file]), + () => + StateLayerUiDependencyRule(_config(RiskLevel.high)).analyze([file]), + ]; + for (final scan in scans) { + expect(scan(), isNotEmpty); + } + }); + + test('framework-specific rules are activated by imports', () { + final riverpod = RiverpodReadUsedForRenderRule( + _config(RiskLevel.medium), + ).analyze([p.join(_fixtures, 'riverpod_state.dart')]); + final bloc = BlocEquatablePropsIncompleteRule( + _config(RiskLevel.medium), + ).analyze([p.join(_fixtures, 'bloc_state.dart')]); + final provider = ProviderValueLifecycleMisuseRule( + _config(RiskLevel.medium), + ).analyze([p.join(_fixtures, 'provider_state.dart')]); + expect(riverpod, isNotEmpty); + expect(bloc, isNotEmpty); + expect(provider, isNotEmpty); + }); + + test('state dependency cycles remain project-wide', () { + final issues = StateDependencyCycleRule( + _config(RiskLevel.high), + projectPath: Directory.current.path, + ).analyze([p.join(_fixtures, 'generic_state.dart')]); + expect(issues, isNotEmpty); + expect(issues.first.id, 'state_dependency_cycle'); + }); + }); +} diff --git a/test/scanner_test.dart b/test/scanner_test.dart new file mode 100644 index 0000000..d61f212 --- /dev/null +++ b/test/scanner_test.dart @@ -0,0 +1,110 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutterguard_cli/src/baseline.dart'; +import 'package:flutterguard_cli/src/report_generator.dart'; +import 'package:flutterguard_cli/src/sarif_report.dart'; +import 'package:flutterguard_cli/src/scanner.dart'; +import 'package:flutterguard_cli/src/static_issue.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +Directory _project() { + final project = Directory.systemTemp.createTempSync('fg_project_'); + Directory(p.join(project.path, 'lib')).createSync(); + File(p.join(project.path, 'pubspec.yaml')).writeAsStringSync(''' +name: fixture_project +environment: + sdk: ^3.11.0 +'''); + File(p.join(project.path, 'lib', 'device.dart')).writeAsStringSync(''' +class Device { + void connect() { + final password = 'admin123'; + final broker = 'tcp://192.168.1.2:1883'; + } +} +'''); + return project; +} + +void main() { + test('scanner uses built-in defaults and sorts findings', () { + final project = _project(); + addTearDown(() => project.deleteSync(recursive: true)); + final result = FlutterGuardScanner.scan(projectPath: project.path); + expect(result.files, hasLength(1)); + expect(result.issues, isNotEmpty); + expect(result.issues.first.level, RiskLevel.high); + }); + + test('JSON report uses the compact 2.0 contract', () { + final project = _project(); + addTearDown(() => project.deleteSync(recursive: true)); + final result = FlutterGuardScanner.scan( + projectPath: project.path, + writeJson: true, + ); + final report = File(p.join(result.reportDir, 'report.json')); + final json = jsonDecode(report.readAsStringSync()) as Map; + expect(json['schemaVersion'], '2.0.0'); + final issue = (json['issues'] as List).first as Map; + expect(issue, contains('ruleId')); + expect(issue, contains('severity')); + expect(issue, isNot(contains('id'))); + expect(issue, isNot(contains('priority'))); + expect(issue, isNot(contains('confidence'))); + }); + + test('severity is the only CI gate dimension', () { + final issues = [ + StaticIssue( + id: 'test', + title: 'test', + file: '/tmp/test.dart', + level: RiskLevel.medium, + domain: IssueDomain.architecture, + message: 'test', + suggestion: 'fix', + ), + ]; + expect(ReportGenerator.shouldFail(issues, 'high'), isFalse); + expect(ReportGenerator.shouldFail(issues, 'medium'), isTrue); + expect(ReportGenerator.shouldFail(issues, 'none'), isFalse); + }); + + test('baseline fingerprints remain stable for the compact issue model', () { + final issue = StaticIssue( + id: 'test', + title: 'test', + file: '/project/lib/test.dart', + line: 3, + level: RiskLevel.medium, + domain: IssueDomain.architecture, + message: 'message', + suggestion: 'fix', + ); + final encoded = Baseline.encode(projectPath: '/project', issues: [issue]); + final baseline = Baseline.loadFromString(encoded); + expect(baseline.contains(issue, '/project'), isTrue); + }); + + test('SARIF contains the same registry and finding IDs', () { + final issue = StaticIssue( + id: 'iot_security', + title: 'security', + file: '/project/lib/test.dart', + level: RiskLevel.high, + domain: IssueDomain.architecture, + message: 'message', + suggestion: 'fix', + ); + final sarif = + jsonDecode( + SarifReport.generate(projectPath: '/project', issues: [issue]), + ) + as Map; + expect(jsonEncode(sarif), contains('iot_security')); + expect(jsonEncode(sarif), isNot(contains('confidence'))); + }); +}