diff --git a/.github/workflows/flutterguard.yml b/.github/workflows/flutterguard.yml new file mode 100644 index 0000000..ade143a --- /dev/null +++ b/.github/workflows/flutterguard.yml @@ -0,0 +1,35 @@ +name: FlutterGuard + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +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 --fail-on high --min-score 80 + continue-on-error: true + + - name: Upload report + if: always() + uses: actions/upload-artifact@v4 + with: + name: flutterguard-report-${{ matrix.os }} + path: .flutterguard/report.json diff --git a/AGENTS.md b/AGENTS.md index 1d5577a..a705e6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,38 +19,62 @@ IoT/smart home Flutter project static analysis CLI plugin. NOT an observability |---------|---------| | `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 | -| `dart run flutterguard scan -p ` | Run scan on a project | +| `dart run melos run test:cli` | CLI tests only (26 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 | +| `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` -Wired rules (5): LargeUnitsRule, LifecycleResourceRule, LayerViolationRule, ModuleViolationRule, CircularDependencyRule +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/`. + +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 ``` packages/flutterguard_cli/lib/src/ - config_loader.dart # YAML → ScanConfig (incl architecture.layers/modules) + config_loader.dart # YAML → ScanConfig typedefs (11 rule configs + architecture) 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 + 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 rules/ - 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) + 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) ``` ## 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 bin/flutterguard.dart +1. New rule: spec entry → config typedef → rule class → fixture → test → wire into scanner.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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 09e65cf..1ff0ba0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 0.2.0 (2026-06-15) + +### IoT Domain Rules (5 new rules) + +- **cli:** `iot_security` rule — detects hardcoded credentials, cleartext MQTT (port 1883), cleartext HTTP, and insecure BLE configurations (p0, architecture) +- **cli:** `device_lifecycle` rule — checks balanced init/teardown pairs (initState↔dispose, connect↔disconnect, startScan↔stopScan, listen↔cancel, subscribe↔unsubscribe) (p0, architecture) +- **cli:** `mqtt_connection` rule — validates MQTT connect/disconnect and subscribe/unsubscribe pairing, detects hardcoded broker URLs (p0, architecture) +- **cli:** `ble_scanning` rule — checks BLE startScan/stopScan pairing, connect/disconnect, and scan timeout configuration (p1, architecture) +- **cli:** `pubspec_security` rule — analyzes pubspec.yaml for unbounded dependencies, deprecated packages (flutter_blue→flutter_blue_plus), and outdated IoT dependencies (p2, standards) + +### UX Improvements + +- **cli:** Positional path argument — `flutterguard scan ./my_project` now works without `-p` flag +- **cli:** Project auto-discovery — walks up from CWD to find `flutterguard.yaml`, `pubspec.yaml`, or `lib/` +- **cli:** Config path resolution with 3-tier priority (absolute → CWD-relative → project-relative) +- **cli:** `--no-color` flag to disable ANSI terminal output +- **cli:** Cross-platform compile scripts (`scripts/compile.sh`, `scripts/compile.ps1`) + +### CI & Automation + +- **ci:** GitHub Actions workflow with ubuntu/macos/windows matrix +- **ci:** Local CI scripts (`scripts/scan_ci.sh`, `scripts/scan_ci.ps1`) with configurable gates +- **docs:** README restructured — user install (pub.dev) / native binary / developer install tiers +- **docs:** README CI integration examples (GitHub Actions, GitLab CI, pre-commit hook, local scripts) +- **docs:** Windows commands use correct backslash paths in install and compile steps + +### Total Rules: 11 rule classes, 13 rule IDs + ## 0.1.0 (2026-05-17) ### Initial Release — CLI Static Analysis diff --git a/README.md b/README.md index bde227e..899a13c 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,14 @@ > 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) | [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 @@ -17,107 +23,199 @@ FlutterGuard scans Flutter/Dart source code and reports architecture boundary br - 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 + +--- ## Install -### From source +### User install (recommended — from pub.dev) + +
+macOS / Linux ```bash -git clone https://github.com/lizy-coding/flutterguard.git -cd flutterguard +dart pub global activate flutterguard_cli -dart pub global activate melos -melos bootstrap +# Verify +flutterguard --version +``` -dart pub global activate --source path packages/flutterguard_cli -flutterguard --help +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" ``` +
-> Windows users may need `%USERPROFILE%\AppData\Local\Pub\Cache\bin` on `PATH` after global activation. +### Compile native binary (no Dart SDK required at runtime) -### Compile a native binary +
+macOS / Linux ```bash git clone https://github.com/lizy-coding/flutterguard.git cd flutterguard +dart pub get dart pub global activate melos melos bootstrap -dart pub get + dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard +./flutterguard --help ``` +
-On Windows, compile with an `.exe` output name and run the local binary with -`.\flutterguard.exe`: +
+Windows (PowerShell) ```powershell +git clone https://github.com/lizy-coding/flutterguard.git +cd flutterguard dart pub get -dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard.exe -.\flutterguard.exe scan -p D:\path\to\flutter_app +dart pub global activate melos +melos bootstrap + +dart compile exe packages\flutterguard_cli\bin\flutterguard.dart -o flutterguard.exe +.\flutterguard.exe --help ``` +
+ +### Developer install (from source) -If `flutterguard scan` prints `API key required` or mentions uploading APKs, the -shell is resolving an old globally installed binary instead of this repository's -static-analysis CLI. Check it with `where flutterguard`, then either run -`.\flutterguard.exe` from the repo directory or reinstall the local CLI: +
+macOS / Linux + +```bash +git clone https://github.com/lizy-coding/flutterguard.git +cd flutterguard +dart pub get +dart pub global activate melos +melos bootstrap + +dart pub global activate --source path packages/flutterguard_cli +flutterguard --help +``` +
+ +
+Windows (PowerShell) ```powershell -dart pub global deactivate flutterguard_cli +git clone https://github.com/lizy-coding/flutterguard.git +cd flutterguard +dart pub get +dart pub global activate melos +melos bootstrap + dart pub global activate --source path packages\flutterguard_cli +flutterguard --help ``` +
+ +--- ## Quick Start ```bash -# Scan a Flutter project -flutterguard scan -p /path/to/project +# Scan the current directory +flutterguard scan + +# Scan a specific project +flutterguard scan ./my_flutter_app # macOS / Linux +flutterguard scan .\my_flutter_app # Windows -# Write JSON report and fail on HIGH issues -flutterguard scan -p . --format json --fail-on high +# Scan with explicit path flag +flutterguard scan -p /path/to/project # macOS / Linux +flutterguard scan -p D:\path\to\project # Windows + +# JSON output with CI gate +flutterguard scan . --format json --fail-on high # Show help flutterguard --help +flutterguard scan --help ``` ### Demo target ```bash -flutterguard scan -p examples/scan_demo +flutterguard scan examples/scan_demo ``` -## CLI +--- + +## CLI Reference Commands: -- `flutterguard scan` -- `flutterguard --help` -- `flutterguard --version` +| Command | Description | +|---------|-------------| +| `flutterguard scan []` | Scan a project (path defaults to current directory) | +| `flutterguard --help` / `-h` | Show usage | +| `flutterguard --version` / `-V` | Show version | -Scan options: +### Scan options -| Flag | Meaning | Default | -|------|---------|---------| -| `-p`, `--path` | Project path to scan | `.` | -| `-c`, `--config` | Config file path inside the project root | `flutterguard.yaml` | -| `-f`, `--format` | Output format: `table` or `json` | `table` | -| `-o`, `--output` | Output directory for generated reports | `.flutterguard` | -| `-v`, `--verbose` | Show issue detail in terminal output | off | -| `--fail-on` | CI gate threshold: `none`, `high`, `medium`, `low` | `none` | -| `--min-score` | Minimum acceptable score, 0-100 | unset | +| 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` or `json` | +| `--output` | `-o` | `.flutterguard` | Output directory for reports | +| `--verbose` | `-v` | off | Show detailed output with code context | +| `--no-color` | — | off | Disable ANSI terminal colors | +| `--fail-on` | — | `none` | CI gate: `none` / `high` / `medium` / `low` | +| `--min-score` | — | unset | Minimum score threshold 0–100 | +| `--help` | `-h` | — | Show scan usage | -Exit codes: +### Exit codes -- `0` success -- `1` gate failed -- `2` scan error or invalid input +| Code | Meaning | +|------|---------| +| `0` | Success (includes help/version output and no-files-found) | +| `1` | CI gate failed (issues at/above `--fail-on` level, or score below `--min-score`) | +| `2` | Scan error (bad path, config parse error) | + +### 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 with this priority: +1. Absolute path (`-c /path/to/config.yaml`) — used as-is +2. Relative path matching a file from CWD (`-c my_config.yaml`) — resolved from CWD +3. Relative path matching a file from the project root — fallback + +--- ## Configuration -Create `flutterguard.yaml` in the project root: +Create `flutterguard.yaml` in your project root. + +### Basic config (for most users) ```yaml include: @@ -143,6 +241,24 @@ rules: 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 +``` + +### Full config (with architecture enforcement) + +```yaml +# ... include/exclude/rules from basic config above ... architecture: layers: @@ -174,44 +290,51 @@ architecture: enabled: true ``` -Notes: +> **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. -- If `flutterguard.yaml` is missing, defaults are used. -- Architecture rules require explicit `layers` and `modules`; they do not auto-discover boundaries. -- `layer_violation` and `module_violation` only work when the relevant declarations are present. +--- -## Checks +## Rules -FlutterGuard currently emits these issue IDs: +| 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 | — | -| Rule ID | Level | Domain | Priority | What it checks | -|---------|-------|--------|----------|----------------| -| `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` | -| `layer_violation` | HIGH | architecture | P0 | Importing across forbidden architecture layers | -| `module_violation` | HIGH | architecture | P0 | Importing across forbidden business modules | -| `circular_dependency` | MEDIUM | architecture | P1 | File-level import cycles | -| `missing_const_constructor` | LOW | standards | P2 | Widget classes missing a `const` constructor | +* Requires explicit YAML configuration to activate. + +--- ## Output -### Terminal table +### Terminal table (default) -Default output is a colored terminal report grouped by domain. It shows the overall score, file count, issue count, and per-issue detail. +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. The terminal summary is still printed to stdout. +`--format json` writes `.flutterguard/report.json` under the output directory. Example shape: ```json { "version": "1.0.0", - "generatedAt": "2026-05-20T00:00:00.000Z", - "projectPath": "/absolute/path", + "generatedAt": "2026-06-09T12:00:00.000Z", + "projectPath": "/path/to/project", "score": 85, "summary": { "total": 3, @@ -228,27 +351,160 @@ Example shape: ## Scoring -```text -score = max(0, 100 - high*10 - medium*4 - low*1) +``` +score = max(0, 100 - high×10 - medium×4 - low×1) ``` | Score | Rating | |-------|--------| -| 80-100 | 优秀 (Excellent) | -| 50-79 | 需关注 (Needs review) | -| 0-49 | 需整改 (Needs action) | +| 80–100 | Excellent | +| 50–79 | Needs review | +| 0–49 | Needs action | + +--- ## 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 --fail-on high --min-score 80 +``` + +### 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 +``` + +### Local scripts + +
+macOS / Linux + ```bash -flutterguard scan -p . --format json --fail-on high -flutterguard scan -p . --format json --min-score 80 -flutterguard scan -p . --fail-on low +#!/usr/bin/env bash +# scan_ci.sh +flutterguard scan . --format json --fail-on high --min-score 80 +if [ $? -eq 0 ]; then + echo "All checks passed!" +else + echo "CI gate failed! Check .flutterguard/report.json for details." + exit 1 +fi +``` +
+ +
+Windows (PowerShell) + +```powershell +# scan_ci.ps1 +$ErrorActionPreference = "Stop" +flutterguard scan . --format json --fail-on high --min-score 80 + +if ($LASTEXITCODE -eq 0) { + Write-Host "All checks passed!" -ForegroundColor Green +} else { + Write-Host "CI gate failed! Check .flutterguard/report.json for details." -ForegroundColor Red + exit 1 +} ``` +
+ +--- + +## 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\** +``` + +--- ## Repository Layout -```text +``` flutterguard/ ├── packages/ │ └── flutterguard_cli/ Active CLI implementation @@ -260,17 +516,28 @@ flutterguard/ ## Development ```bash +# All platforms git clone https://github.com/lizy-coding/flutterguard.git cd flutterguard +dart pub get dart pub global activate melos melos bootstrap -dart pub get -dart run melos run analyze -dart run melos run test:cli +# 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 ``` +## Further Reading + +| 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 | + ## License MIT diff --git a/README.zh.md b/README.zh.md index 519ad9c..f11e6ce 100644 --- a/README.zh.md +++ b/README.zh.md @@ -6,6 +6,10 @@ FlutterGuard 扫描 Flutter/Dart 源码,报告架构边界违规、生命周期资源泄漏、循环依赖、过大文件/类/`build` 方法,以及常见代码规范问题。当前活动开发路径是 `packages/flutterguard_cli/`;旧的运行时追踪包已归档在 `archive/`。 +**支持平台**: macOS、Windows、Linux — 纯 Dart CLI,零原生依赖。 + +**文档**: [使用指南](docs/USAGE.md) | [Windows 评估](docs/WINDOWS_ASSESSMENT.md) | [技术规格](docs/FLUTTERGUARD_SPEC.md) | [架构](docs/ARCHITECTURE.md) + ## 它是什么 - 静态分析命令行工具 @@ -24,94 +28,193 @@ FlutterGuard 扫描 Flutter/Dart 源码,报告架构边界违规、生命周 - Dart SDK 3.3.0 或更高版本 - 从源码开发时需要 `melos` +- 支持操作系统: macOS、Windows、Linux + +--- ## 安装 -### 从源码安装全局命令 +### 普通用户安装(推荐 — 从 pub.dev) + +
+macOS / Linux ```bash -git clone https://github.com/lizy-coding/flutterguard.git -cd flutterguard +dart pub global activate flutterguard_cli -dart pub global activate melos -melos bootstrap -dart pub get +# 验证安装 +flutterguard --version +``` -dart pub global activate --source path packages/flutterguard_cli -flutterguard --help +确认 `$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 ``` -Windows 用户如果全局命令不可用,需要确认 `%USERPROFILE%\AppData\Local\Pub\Cache\bin` 已加入 `PATH`。 +若 `flutterguard` 命令未识别,确认 `%USERPROFILE%\AppData\Local\Pub\Cache\bin` 在 `PATH` 中: -### 编译原生二进制 +```powershell +$env:Path += ";$env:USERPROFILE\AppData\Local\Pub\Cache\bin" +``` +
+ +### 编译独立二进制(运行时无需 Dart 环境) + +
+macOS / Linux ```bash git clone https://github.com/lizy-coding/flutterguard.git cd flutterguard dart pub get +dart pub global activate melos +melos bootstrap + dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard +./flutterguard --help ``` +
-Windows 请使用 `.exe` 输出名,并优先运行当前目录下的二进制: +
+Windows (PowerShell) ```powershell +git clone https://github.com/lizy-coding/flutterguard.git +cd flutterguard dart pub get -dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard.exe -.\flutterguard.exe scan -p D:\path\to\flutter_app +dart pub global activate melos +melos bootstrap + +dart compile exe packages\flutterguard_cli\bin\flutterguard.dart -o flutterguard.exe +.\flutterguard.exe --help ``` +
-如果 `flutterguard scan` 输出 `API key required` 或 `Upload an APK for analysis`,说明当前 shell 解析到了旧版全局二进制,而不是本仓库的静态扫描 CLI。用下面命令检查实际路径: +### 开发者安装(从源码) -```powershell -where flutterguard +
+macOS / Linux + +```bash +git clone https://github.com/lizy-coding/flutterguard.git +cd flutterguard +dart pub get +dart pub global activate melos +melos bootstrap + +dart pub global activate --source path packages/flutterguard_cli +flutterguard --help ``` +
-处理方式: +
+Windows (PowerShell) ```powershell -.\flutterguard.exe scan -p D:\path\to\flutter_app +git clone https://github.com/lizy-coding/flutterguard.git +cd flutterguard +dart pub get +dart pub global activate melos +melos bootstrap -dart pub global deactivate flutterguard_cli dart pub global activate --source path packages\flutterguard_cli +flutterguard --help ``` +
+ +--- ## 快速开始 ```bash -flutterguard scan -p /path/to/flutter_app -flutterguard scan -p . --format json --fail-on high +# 扫描当前目录 +flutterguard scan + +# 扫描指定项目 +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 + +# 显示帮助 flutterguard --help +flutterguard scan --help ``` -扫描示例项目: +### 扫描示例项目 ```bash -flutterguard scan -p examples/scan_demo +flutterguard scan examples/scan_demo ``` -## CLI +--- + +## CLI 参考 命令: -- `flutterguard scan` -- `flutterguard --help` -- `flutterguard --version` +| 命令 | 说明 | +|------|------| +| `flutterguard scan []` | 扫描项目(路径默认为当前目录) | +| `flutterguard --help` / `-h` | 显示帮助 | +| `flutterguard --version` / `-V` | 显示版本 | + +### 扫描参数 + +| 参数 | 简写 | 默认值 | 说明 | +|------|------|--------|------| +| `` | — | `.` | 位置参数,项目路径(可选,放在选项之前) | +| `--path` | `-p` | `.` | 项目路径(被 `` 位置参数覆盖) | +| `--config` | `-c` | `flutterguard.yaml` | 配置文件路径 | +| `--format` | `-f` | `table` | 输出格式:`table` 或 `json` | +| `--output` | `-o` | `.flutterguard` | 报告输出目录 | +| `--verbose` | `-v` | 关闭 | 显示详细代码上下文 | +| `--no-color` | — | 关闭 | 禁用 ANSI 终端颜色 | +| `--fail-on` | — | `none` | CI 门禁等级:`none` / `high` / `medium` / `low` | +| `--min-score` | — | 不设 | 最低可接受评分,0–100 | +| `--help` | `-h` | — | 显示 scan 帮助 | -扫描参数: +### 退出码 -| 参数 | 含义 | 默认值 | -|------|------|--------| -| `-p`, `--path` | 要扫描的项目路径 | `.` | -| `-c`, `--config` | 项目内配置文件路径 | `flutterguard.yaml` | -| `-f`, `--format` | 输出格式:`table` 或 `json` | `table` | -| `-o`, `--output` | 报告输出目录 | `.flutterguard` | -| `-v`, `--verbose` | 显示详细上下文 | 关闭 | -| `--fail-on` | CI 门禁等级:`none`、`high`、`medium`、`low` | `none` | -| `--min-score` | 最低可接受评分,0-100 | 未设置 | +| 退出码 | 含义 | +|--------|------| +| `0` | 成功(含 help/version 输出及未找到文件的情况) | +| `1` | CI 门禁失败(存在超过 `--fail-on` 的问题,或评分低于 `--min-score`)| +| `2` | 扫描错误(路径不存在、配置文件解析错误等) | + +### 路径解析 + +FlutterGuard 从当前目录向上遍历,自动发现项目根目录(查找 `flutterguard.yaml`、`pubspec.yaml` 或 `lib/` 目录)。若未找到,则退化为当前目录。 + +`--config` 路径按以下优先级解析: +1. 绝对路径(`-c /path/to/config.yaml`)— 直接使用 +2. 相对 CWD 匹配到的文件(`-c my_config.yaml`)— 从 CWD 解析 +3. 相对项目根目录匹配到的文件 — 兜底 + +--- ## 配置文件 -`flutterguard.yaml` 示例: +在项目根目录创建 `flutterguard.yaml`。 + +### 基础配置(大多数用户适用) ```yaml include: @@ -137,6 +240,24 @@ rules: 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: @@ -162,24 +283,260 @@ architecture: allowed_deps: [domain, core] detect_cycles: true + layer_violation: + enabled: true + module_violation: + enabled: true ``` -未提供 `flutterguard.yaml` 时会使用默认规则;架构层和模块规则需要在 `architecture.layers` / `architecture.modules` 中显式声明。 +> **注意**: 架构规则(`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, + "byDomain": { + "architecture": { "high": 1, "medium": 0, "low": 0, "total": 1 } + } + }, + "issues": [] +} +``` + +## 评分 + +``` +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 --fail-on high --min-score 80 +``` + +### 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 +flutterguard scan . --format json --fail-on high --min-score 80 +if [ $? -eq 0 ]; then + echo "All checks passed!" +else + echo "CI gate failed! Check .flutterguard/report.json for details." + exit 1 +fi +``` +
+ +
+Windows (PowerShell) + +```powershell +# scan_ci.ps1 +$ErrorActionPreference = "Stop" +flutterguard scan . --format json --fail-on high --min-score 80 + +if ($LASTEXITCODE -eq 0) { + Write-Host "All checks passed!" -ForegroundColor Green +} else { + Write-Host "CI gate failed! Check .flutterguard/report.json for details." -ForegroundColor Red + exit 1 +} +``` +
+ +--- + +## 常见问题 + +### 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 pub get -dart run melos run analyze -dart run melos run test:cli +# 常用命令 +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/packages/flutterguard_cli/AGENTS.md b/packages/flutterguard_cli/AGENTS.md index caca6ed..43524f1 100644 --- a/packages/flutterguard_cli/AGENTS.md +++ b/packages/flutterguard_cli/AGENTS.md @@ -14,42 +14,52 @@ Primary CLI tool for IoT Flutter static architecture scanning and CI gating. ## Key Source Files | File | Responsibility | |------|---------------| -| `bin/flutterguard.dart` | Arg parsing, scan orchestration, exit codes | -| `src/config_loader.dart` | YAML → ScanConfig typedef parsing | +| `bin/flutterguard.dart` | Arg parsing, positional path, scan orchestration, exit codes, `--no-color` | +| `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 | +| `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 (6) -LargeUnitsRule, LifecycleResourceRule, LayerViolationRule, ModuleViolationRule, CircularDependencyRule, MissingConstConstructorRule +## 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 file: `test/scanner_test.dart` (12 tests) -- fixtures: `test/fixtures/` (12 fixture files) -- every new rule needs: spec entry → config typedef → class → fixture → test → wire in bin/ - -## IoT Domain (planned in spec §12) -Rules defined but not yet implemented: device_lifecycle, mqtt_connection, ble_scanning, iot_security, pubspec_security. +- test file: `test/scanner_test.dart` (26 tests) +- fixtures: `test/fixtures/` (17 fixture files) +- every new rule needs: spec entry → config typedef → class → fixture → test → wire in scanner.dart ## Current Toolchain Flow -1. `bin/flutterguard.dart` parses CLI arguments and maps validation errors to exit codes. -2. `lib/src/scanner.dart` owns scan orchestration: config loading, file collection, rule execution, issue sorting, and optional JSON writing. -3. `lib/src/config_loader.dart` parses `flutterguard.yaml` into typed record configs. -4. `lib/src/file_collector.dart` resolves include/exclude globs to Dart files. -5. `lib/src/rules/` contains explicit rule classes. Do not add reflection or dynamic plugin loading. -6. `lib/src/report_generator.dart` renders table output and JSON report payloads. +1. `bin/flutterguard.dart` parses CLI arguments (supports positional ``), maps validation errors to exit codes. +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/` contains explicit rule classes. Do not add reflection or dynamic plugin loading. +7. `lib/src/report_generator.dart` renders table output (with optional `--no-color`) and JSON report payloads. ## Change Boundaries - Put user-facing CLI parsing and exit-code behavior in `bin/`. - 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 new file mode 100644 index 0000000..a4f336e --- /dev/null +++ b/packages/flutterguard_cli/CHANGELOG.md @@ -0,0 +1,44 @@ +# Changelog + +## 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 new file mode 100644 index 0000000..127a57b --- /dev/null +++ b/packages/flutterguard_cli/LICENSE @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..9ebebb8 --- /dev/null +++ b/packages/flutterguard_cli/README.md @@ -0,0 +1,116 @@ +[![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 + +# Scan a Flutter project +flutterguard scan -p /path/to/flutter_project + +# JSON output with CI gate +flutterguard scan -p . --format json --fail-on high --min-score 80 +``` + +## Checks (8 rule IDs) + +| 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 | + +## Configuration + +Create `flutterguard.yaml` in your project root: + +```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 no config file exists, defaults are used (all rules enabled, no architecture constraints). + +## CLI Reference + +``` +flutterguard scan [options] + -p, --path Project path (default: .) + -c, --config Config file (default: flutterguard.yaml) + -f, --format table | json (default: table) + -o, --output Output directory (default: .flutterguard) + -v, --verbose Show detailed context + --fail-on CI gate: none | high | medium | low + --min-score Minimum score threshold 0-100 +``` + +Exit codes: `0` success, `1` CI gate failed, `2` scan error. + +## 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 + +```yaml +# GitHub Actions +- uses: dart-lang/setup-dart@v1 + with: + sdk: 3.3.0 +- run: dart pub global activate flutterguard_cli +- run: flutterguard scan -p . --format json --fail-on high --min-score 80 +``` + +## 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/bin/AGENTS.md b/packages/flutterguard_cli/bin/AGENTS.md index ad1d686..99a0c54 100644 --- a/packages/flutterguard_cli/bin/AGENTS.md +++ b/packages/flutterguard_cli/bin/AGENTS.md @@ -5,9 +5,11 @@ It should: - Parse commands and options with `package:args`. +- Support positional path: `flutterguard scan ./my_project` (no `-p` required). - Print help/version text. - Convert validation or scan errors into documented exit codes. - Call `FlutterGuardScanner.scan()` for real work. +- Pass `--no-color` flag through to `ReportGenerator`. It should not: - Implement rules. diff --git a/packages/flutterguard_cli/bin/flutterguard.dart b/packages/flutterguard_cli/bin/flutterguard.dart index 329f9ac..dbb0fa0 100644 --- a/packages/flutterguard_cli/bin/flutterguard.dart +++ b/packages/flutterguard_cli/bin/flutterguard.dart @@ -5,10 +5,12 @@ import 'package:args/args.dart'; import 'package:flutterguard_cli/src/report_generator.dart'; import 'package:flutterguard_cli/src/scanner.dart'; -const _version = '0.1.0'; +const _version = '0.2.0'; void main(List args) { - final scanParser = ArgParser() + final normalizedArgs = _extractPositionalPath(args); + + final scanParser = ArgParser(allowTrailingOptions: false) ..addOption('path', abbr: 'p', defaultsTo: '.', help: 'Project path to scan') ..addOption('config', @@ -28,6 +30,9 @@ void main(List args) { abbr: 'v', help: 'Show detailed output with code context', negatable: false) + ..addFlag('no-color', + help: 'Disable ANSI terminal colors', + negatable: false) ..addOption('fail-on', defaultsTo: 'none', allowed: ['none', 'high', 'medium', 'low'], @@ -41,14 +46,14 @@ void main(List args) { ..addFlag('version', abbr: 'V', help: 'Show version', negatable: false); try { - final results = parser.parse(args); + final results = parser.parse(normalizedArgs); if (results['version'] == true) { stdout.writeln('flutterguard $_version'); exit(0); } - if (results['help'] == true || args.isEmpty || args.first == 'help') { + if (results['help'] == true || normalizedArgs.isEmpty) { _printUsage(parser); exit(0); } @@ -77,9 +82,35 @@ void main(List 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) { final format = args['format'] as String; final verbose = args['verbose'] as bool; + final noColor = args['no-color'] as bool; final failOn = args['fail-on'] as String; final minScoreStr = args['min-score'] as String?; final minScore = _parseMinScore(minScoreStr); @@ -91,6 +122,7 @@ void _handleScan(ArgResults args) { configPath: args['config'] as String, outputDir: args['output'] as String, writeJson: format == 'json', + noColor: noColor, ); } on ScanException catch (e) { stderr.writeln('Error: ${e.message}'); @@ -110,6 +142,7 @@ void _handleScan(ArgResults args) { issues: result.issues, scannedFileCount: result.files.length, verbose: verbose, + noColor: noColor, ); stdout.writeln(stdoutOutput); @@ -139,37 +172,32 @@ int? _parseMinScore(String? value) { 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(' scan [] Scan a Flutter project for architecture issues'); stdout.writeln(); - stdout.writeln('Scan Options:'); - stdout.writeln(' -p, --path Project path to scan (default: .)'); - stdout.writeln( - ' -c, --config Config file path (default: flutterguard.yaml)'); - stdout.writeln( - ' -f, --format Output format: table | json (default: table)'); - stdout.writeln( - ' -o, --output Output directory (default: .flutterguard)'); - stdout.writeln( - ' -v, --verbose Show detailed output with code context'); - stdout.writeln(' -V, --version Show version'); - stdout.writeln( - ' --fail-on CI gate: none | high | medium | low (default: none)'); - stdout.writeln(' --min-score Minimum score threshold 0-100'); - stdout.writeln(' -h, --help Show this help message'); + 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 -p ./my_flutter_app'); - stdout.writeln(' flutterguard scan -p . --format json --fail-on high'); + stdout.writeln(' flutterguard scan # Scan current directory'); + 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'); } void _printScanUsage(ArgParser scanParser) { stdout.writeln('FlutterGuard — scan command'); stdout.writeln(); - stdout.writeln('Usage: flutterguard scan [options]'); + stdout.writeln('Usage: flutterguard scan [] [options]'); + stdout.writeln(); + stdout.writeln(' Project path to scan (default: current directory)'); stdout.writeln(); stdout.writeln(scanParser.usage); } diff --git a/packages/flutterguard_cli/lib/flutterguard_cli.dart b/packages/flutterguard_cli/lib/flutterguard_cli.dart index b0163d9..4f9448e 100644 --- a/packages/flutterguard_cli/lib/flutterguard_cli.dart +++ b/packages/flutterguard_cli/lib/flutterguard_cli.dart @@ -2,15 +2,21 @@ 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/rules/ble_scanning.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/scanner.dart'; export 'src/source_utils.dart'; export 'src/static_issue.dart'; diff --git a/packages/flutterguard_cli/lib/src/AGENTS.md b/packages/flutterguard_cli/lib/src/AGENTS.md index 40fcfe4..c99e92d 100644 --- a/packages/flutterguard_cli/lib/src/AGENTS.md +++ b/packages/flutterguard_cli/lib/src/AGENTS.md @@ -5,14 +5,15 @@ This directory contains reusable implementation for the CLI. ## Main Files - `scanner.dart`: global scan orchestration and `ScanResult`. -- `config_loader.dart`: YAML parsing into typed record configs. +- `config_loader.dart`: YAML parsing into typed record configs (11 rule configs + architecture). - `file_collector.dart`: include/exclude glob file discovery. -- `report_generator.dart`: table and JSON output. +- `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. +- `rules/`: rule implementations only (11 rule classes, 13 rule IDs). ## Design Rules - Keep `bin/` thin; reusable logic belongs here. @@ -20,3 +21,4 @@ This directory contains reusable implementation for the CLI. - 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 its own YAML parsing (uses `package:yaml` directly). diff --git a/packages/flutterguard_cli/lib/src/config_loader.dart b/packages/flutterguard_cli/lib/src/config_loader.dart index 7d5d926..b0674e6 100644 --- a/packages/flutterguard_cli/lib/src/config_loader.dart +++ b/packages/flutterguard_cli/lib/src/config_loader.dart @@ -30,6 +30,11 @@ typedef RulesConfig = ({ LargeBuildMethodRuleConfig largeBuildMethod, LifecycleResourceRuleConfig lifecycleResource, MissingConstConstructorRuleConfig missingConstConstructor, + DeviceLifecycleRuleConfig deviceLifecycle, + MqttConnectionRuleConfig mqttConnection, + BleScanningRuleConfig bleScanning, + IotSecurityRuleConfig iotSecurity, + PubspecSecurityRuleConfig pubspecSecurity, }); typedef LargeFileRuleConfig = ({bool enabled, int maxLines}); @@ -37,6 +42,11 @@ 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; @@ -63,6 +73,11 @@ class ScanConfig { 'large_build_method', 'lifecycle_resource', 'missing_const_constructor', + 'device_lifecycle', + 'mqtt_connection', + 'ble_scanning', + 'iot_security', + 'pubspec_security', }; static const _knownLayerKeys = { 'name', @@ -147,6 +162,11 @@ class ScanConfig { 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: [], @@ -172,6 +192,26 @@ class ScanConfig { 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: ( @@ -192,6 +232,23 @@ class ScanConfig { 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), + ), ); } diff --git a/packages/flutterguard_cli/lib/src/project_resolver.dart b/packages/flutterguard_cli/lib/src/project_resolver.dart new file mode 100644 index 0000000..fa75cdd --- /dev/null +++ b/packages/flutterguard_cli/lib/src/project_resolver.dart @@ -0,0 +1,49 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +class ProjectResolver { + static const _discoveryMarkers = [ + 'flutterguard.yaml', + 'pubspec.yaml', + ]; + + static String resolveProjectPath(String? explicitPath) { + if (explicitPath != null && explicitPath != '.') { + return p.normalize(p.absolute(explicitPath)); + } + final discovered = _walkUpFind(Directory.current.path); + return discovered ?? Directory.current.path; + } + + static String resolveConfigPath({ + required String projectPath, + required String explicitConfig, + }) { + if (p.isAbsolute(explicitConfig)) { + return explicitConfig; + } + final fromCwd = p.normalize(p.absolute(explicitConfig)); + if (File(fromCwd).existsSync()) return fromCwd; + final fromProject = p.join(projectPath, explicitConfig); + if (File(fromProject).existsSync()) return fromProject; + return fromProject; + } + + static String? _walkUpFind(String startPath) { + var dir = Directory(startPath); + while (true) { + for (final marker in _discoveryMarkers) { + final candidate = p.join(dir.path, marker); + if (File(candidate).existsSync()) return dir.path; + } + final libCandidate = p.join(dir.path, 'lib'); + if (Directory(libCandidate).existsSync()) return dir.path; + + final parent = dir.parent.path; + if (parent == dir.path) break; + dir = dir.parent; + } + return null; + } +} diff --git a/packages/flutterguard_cli/lib/src/report_generator.dart b/packages/flutterguard_cli/lib/src/report_generator.dart index 8bd82dd..fc6bda1 100644 --- a/packages/flutterguard_cli/lib/src/report_generator.dart +++ b/packages/flutterguard_cli/lib/src/report_generator.dart @@ -84,6 +84,7 @@ class ReportGenerator { required List issues, int? scannedFileCount, bool verbose = false, + bool noColor = false, }) { final buf = StringBuffer(); final score = calculateScore(issues); @@ -95,19 +96,28 @@ class ReportGenerator { score, issues, scannedFileCount ?? issues.map((i) => i.file).toSet().length, + noColor: noColor, ); if (issues.isEmpty) { - buf.writeln(' ${_Ansi.green}未发现问题,代码质量良好。${_Ansi.reset}'); + final msg = noColor ? '未发现问题,代码质量良好。' : '${_Ansi.green}未发现问题,代码质量良好。${_Ansi.reset}'; + buf.writeln(' $msg'); return buf.toString(); } - _writeDomainSummaryBar(buf, issues); + _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); + _writeDomainSection( + buf, + projectPath, + domain, + domainIssues, + verbose, + noColor: noColor, + ); } return buf.toString(); @@ -118,22 +128,26 @@ class ReportGenerator { String projectName, int score, List issues, - int scannedFileCount, - ) { - final scoreAnsi = _scoreAnsi(score); + 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( - ' ${_Ansi.bold}FlutterGuard Report${_Ansi.reset} ${_Ansi.gray}─${_Ansi.reset} $projectName'); + ' ${bold}FlutterGuard Report$reset ${gray}─$reset $projectName'); buf.writeln( '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); buf.write( - ' 总评分: $scoreAnsi$score/100${_Ansi.reset} $scoreAnsi$scoreLabel${_Ansi.reset}'); + ' 总评分: $scoreAnsi$score/100$reset $scoreAnsi$scoreLabel$reset'); buf.writeln( - ' ${_Ansi.bold}扫描文件: $scannedFileCount${_Ansi.reset} 问题总数: ${_Ansi.bold}${issues.length}${_Ansi.reset}'); + ' ${bold}扫描文件: $scannedFileCount$reset 问题总数: ${bold}${issues.length}$reset'); buf.writeln( '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); buf.writeln(); @@ -141,8 +155,11 @@ class ReportGenerator { static void _writeDomainSummaryBar( StringBuffer buf, - List issues, - ) { + 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; @@ -152,12 +169,13 @@ class ReportGenerator { .map((i) => i.level) .reduce((a, b) => a.index > b.index ? a : b); final levelLabel = _levelLabel[maxLevel]!; - final domainAnsi = _domainAnsi[domain]!; + final domainAnsi = noColor ? '' : _domainAnsi[domain]!; + final levelAnsi = noColor ? '' : _levelAnsi[maxLevel]!; - buf.write(' $domainAnsi${_domainLabels[domain]}${_Ansi.reset}'); + buf.write(' $domainAnsi${_domainLabels[domain]}$reset'); buf.write(' $count items '); - buf.write('${_Ansi.gray}▰${_Ansi.reset} '); - buf.writeln('${_levelAnsi[maxLevel]}$levelLabel${_Ansi.reset}'); + buf.write('${gray}▰$reset '); + buf.writeln('$levelAnsi$levelLabel$reset'); } buf.writeln( '────────────────────────────────────────────────────────────────────────────────────'); @@ -169,8 +187,9 @@ class ReportGenerator { String projectPath, IssueDomain domain, List issues, - bool verbose, - ) { + bool verbose, { + bool noColor = false, + }) { final sorted = [...issues]..sort((a, b) { final order = { RiskLevel.high: 0, @@ -180,27 +199,33 @@ class ReportGenerator { 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 = _levelAnsi[issue.level]!; + final lvlAnsi = noColor ? '' : _levelAnsi[issue.level]!; final lvlLabel = _levelLabel[issue.level]!; - final priAnsi = _priorityAnsi[issue.priority]!; + 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${_Ansi.reset} $priAnsi$priLabel${_Ansi.reset}'); - buf.writeln(' ${_Ansi.bold}${issue.title}${_Ansi.reset}'); - buf.writeln(' ${_Ansi.gray}$displayPath$lineInfo${_Ansi.reset}'); + ' $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(' ${_Ansi.dim}$line${_Ansi.reset}'); + buf.writeln(' ${dim}$line$reset'); } } - buf.writeln(' 修复: ${_Ansi.green}${issue.suggestion}${_Ansi.reset}'); + buf.writeln(' 修复: ${green}${issue.suggestion}$reset'); buf.writeln(); } buf.writeln( diff --git a/packages/flutterguard_cli/lib/src/rules/AGENTS.md b/packages/flutterguard_cli/lib/src/rules/AGENTS.md index 926d772..bea483c 100644 --- a/packages/flutterguard_cli/lib/src/rules/AGENTS.md +++ b/packages/flutterguard_cli/lib/src/rules/AGENTS.md @@ -10,6 +10,11 @@ Each file implements one rule family and returns `List`. - `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. @@ -18,6 +23,7 @@ Each file implements one rule family and returns `List`. - 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`. @@ -25,5 +31,5 @@ Each file implements one rule family and returns `List`. 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 in `scanner.dart`. +6. Wire the rule in `scanner.dart:_analyze()`. 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 new file mode 100644 index 0000000..d3b3904 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/rules/ble_scanning.dart @@ -0,0 +1,151 @@ +import 'dart:io'; + +import 'package:analyzer/dart/analysis/utilities.dart'; +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 '../source_utils.dart'; +import '../static_issue.dart'; + +const _bleTypePatterns = ['Ble', 'Ble', 'BluetoothDevice', 'Bluetooth']; + +class BleScanningRule { + final BleScanningRuleConfig config; + + const BleScanningRule(this.config); + + List analyze(List files) { + if (!config.enabled) return []; + + final issues = []; + + for (final file in files) { + try { + final content = File(file).readAsStringSync(); + final result = parseString(content: content, path: file); + issues.addAll(_checkFile(file, content, result.unit, result.lineInfo)); + } catch (_) {} + } + + 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, + }, + )); + } + } + } +} diff --git a/packages/flutterguard_cli/lib/src/rules/device_lifecycle.dart b/packages/flutterguard_cli/lib/src/rules/device_lifecycle.dart new file mode 100644 index 0000000..fa63833 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/rules/device_lifecycle.dart @@ -0,0 +1,88 @@ +import 'dart:io'; + +import 'package:analyzer/dart/analysis/utilities.dart'; +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 '../source_utils.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) { + if (!config.enabled) return []; + + final issues = []; + + for (final file in files) { + try { + final content = File(file).readAsStringSync(); + final result = parseString(content: content, path: file); + issues.addAll(_checkFile(file, result.unit, result.lineInfo)); + } catch (_) {} + } + + 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; + } +} diff --git a/packages/flutterguard_cli/lib/src/rules/iot_security.dart b/packages/flutterguard_cli/lib/src/rules/iot_security.dart new file mode 100644 index 0000000..8680afb --- /dev/null +++ b/packages/flutterguard_cli/lib/src/rules/iot_security.dart @@ -0,0 +1,172 @@ +import 'dart:io'; + +import '../config_loader.dart'; +import '../domain.dart'; +import '../priority.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) { + if (!config.enabled) return []; + + final issues = []; + + for (final file in files) { + try { + final content = File(file).readAsStringSync(); + issues.addAll(_checkFile(file, content)); + } catch (_) {} + } + + 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, + }, + )); + } + } + } + } +} diff --git a/packages/flutterguard_cli/lib/src/rules/mqtt_connection.dart b/packages/flutterguard_cli/lib/src/rules/mqtt_connection.dart new file mode 100644 index 0000000..a4f6e97 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/rules/mqtt_connection.dart @@ -0,0 +1,140 @@ +import 'dart:io'; + +import 'package:analyzer/dart/analysis/utilities.dart'; +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 '../source_utils.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) { + if (!config.enabled) return []; + + final issues = []; + + for (final file in files) { + try { + final content = File(file).readAsStringSync(); + final result = parseString(content: content, path: file); + issues.addAll(_checkFile(file, content, result.unit, result.lineInfo)); + } catch (_) {} + } + + 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, + }, + )); + } + } + } + } +} diff --git a/packages/flutterguard_cli/lib/src/rules/pubspec_security.dart b/packages/flutterguard_cli/lib/src/rules/pubspec_security.dart new file mode 100644 index 0000000..e94154e --- /dev/null +++ b/packages/flutterguard_cli/lib/src/rules/pubspec_security.dart @@ -0,0 +1,171 @@ +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 '../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) { + if (!config.enabled) return []; + + final issues = []; + + for (final file in files) { + final dir = p.dirname(file); + final pubspec = p.join(dir, '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)); + } catch (_) {} + } + + 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(); + } +} diff --git a/packages/flutterguard_cli/lib/src/scanner.dart b/packages/flutterguard_cli/lib/src/scanner.dart index a074bed..c51e59b 100644 --- a/packages/flutterguard_cli/lib/src/scanner.dart +++ b/packages/flutterguard_cli/lib/src/scanner.dart @@ -4,13 +4,19 @@ import 'package:path/path.dart' as p; import 'config_loader.dart'; import 'file_collector.dart'; +import 'project_resolver.dart'; import 'report_generator.dart'; +import 'rules/ble_scanning.dart'; import 'rules/circular_dependency.dart'; +import 'rules/device_lifecycle.dart'; +import 'rules/iot_security.dart'; import 'rules/large_units.dart'; import 'rules/layer_violation.dart'; import 'rules/lifecycle_resource.dart'; import 'rules/missing_const_constructor.dart'; import 'rules/module_violation.dart'; +import 'rules/mqtt_connection.dart'; +import 'rules/pubspec_security.dart'; import 'static_issue.dart'; class ScanException implements Exception { @@ -44,17 +50,21 @@ class FlutterGuardScanner { String configPath = 'flutterguard.yaml', String outputDir = '.flutterguard', bool writeJson = false, + bool noColor = false, }) { - final resolvedProjectPath = p.normalize(p.absolute(projectPath)); + final resolvedProjectPath = + ProjectResolver.resolveProjectPath(projectPath); if (!Directory(resolvedProjectPath).existsSync()) { throw ScanException( 'Project path "$resolvedProjectPath" does not exist.', ); } - final resolvedConfigPath = p.isAbsolute(configPath) - ? configPath - : p.join(resolvedProjectPath, configPath); + final resolvedConfigPath = + ProjectResolver.resolveConfigPath( + projectPath: resolvedProjectPath, + explicitConfig: configPath, + ); final config = ScanConfig.fromFile(resolvedConfigPath); final files = FileCollector.collect(resolvedProjectPath, config); @@ -123,6 +133,21 @@ class FlutterGuardScanner { enabled: config.architecture.detectCycles, projectPath: projectPath, ).analyze(files)); + allIssues.addAll(DeviceLifecycleRule( + config.rules.deviceLifecycle, + ).analyze(files)); + allIssues.addAll(MqttConnectionRule( + config.rules.mqttConnection, + ).analyze(files)); + allIssues.addAll(BleScanningRule( + config.rules.bleScanning, + ).analyze(files)); + allIssues.addAll(IotSecurityRule( + config.rules.iotSecurity, + ).analyze(files)); + allIssues.addAll(PubspecSecurityRule( + config.rules.pubspecSecurity, + ).analyze(files)); allIssues.sort((a, b) { final levelOrder = { diff --git a/packages/flutterguard_cli/pubspec.yaml b/packages/flutterguard_cli/pubspec.yaml index 8a38779..5d5ed30 100644 --- a/packages/flutterguard_cli/pubspec.yaml +++ b/packages/flutterguard_cli/pubspec.yaml @@ -1,7 +1,14 @@ name: flutterguard_cli -description: CLI tool for static architecture scanning and CI gating. -version: 0.1.0 -publish_to: none +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.2.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" diff --git a/packages/flutterguard_cli/test/AGENTS.md b/packages/flutterguard_cli/test/AGENTS.md index 79272ce..8fa24a3 100644 --- a/packages/flutterguard_cli/test/AGENTS.md +++ b/packages/flutterguard_cli/test/AGENTS.md @@ -4,7 +4,15 @@ Tests verify rule behavior, scanner orchestration, report generation, and cross-platform path handling. ## Main Test File -`scanner_test.dart` is the current integration-style test suite for the CLI package. +`scanner_test.dart` is the current integration-style test suite for the CLI package (26 tests, 5 groups). + +## Test Groups +| Group | Tests | Coverage | +|-------|-------|---------| +| Static Rules | 18 | 8 existing rules + 5 IoT rules + config parsing + wiring | +| Report Generation | 2 | JSON and stdout output validation | +| Scanner Orchestration | 3 | Full scan, missing path exception, invalid config | +| Path Handling | 3 | Windows globs, package imports, cross-platform import resolution | ## Rules - Add a fixture for every new rule or regression case. @@ -12,6 +20,7 @@ Tests verify rule behavior, scanner orchestration, report generation, and cross- - 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 index 9a78607..1bc56f2 100644 --- a/packages/flutterguard_cli/test/fixtures/AGENTS.md +++ b/packages/flutterguard_cli/test/fixtures/AGENTS.md @@ -1,7 +1,23 @@ # Fixture Layer ## Responsibility -This directory contains intentionally imperfect Dart/YAML files used by CLI rule tests. +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. @@ -9,3 +25,4 @@ This directory contains intentionally imperfect Dart/YAML files used by CLI rule - 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/ble_scanning_issue.dart b/packages/flutterguard_cli/test/fixtures/ble_scanning_issue.dart new file mode 100644 index 0000000..c6fdaf7 --- /dev/null +++ b/packages/flutterguard_cli/test/fixtures/ble_scanning_issue.dart @@ -0,0 +1,17 @@ +// ignore_for_file: unused_field +// Fixture: BLE scanning issues +class BleDevice {} + +class BleService { + late BleDevice _device; + + void startScan() { + // scanning without timeout + } + + void connect() { + // connecting to device + } + + // stopScan() and disconnect() are missing +} diff --git a/packages/flutterguard_cli/test/fixtures/device_lifecycle_issue.dart b/packages/flutterguard_cli/test/fixtures/device_lifecycle_issue.dart new file mode 100644 index 0000000..477932c --- /dev/null +++ b/packages/flutterguard_cli/test/fixtures/device_lifecycle_issue.dart @@ -0,0 +1,8 @@ +// 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/iot_security_issue.dart b/packages/flutterguard_cli/test/fixtures/iot_security_issue.dart new file mode 100644 index 0000000..422822f --- /dev/null +++ b/packages/flutterguard_cli/test/fixtures/iot_security_issue.dart @@ -0,0 +1,17 @@ +// ignore_for_file: unused_local_variable +// Fixture: IoT security issues +class IotSecurityWidget { + void connect() { + // hardcoded credential + final password = "admin123"; + + // cleartext MQTT + final brokerUrl = "tcp://192.168.1.100:1883"; + + // cleartext HTTP + final apiUrl = "http://iot.example.com/api/data"; + + // insecure BLE + final bleConfig = "withoutBonding"; + } +} diff --git a/packages/flutterguard_cli/test/fixtures/large_build.dart b/packages/flutterguard_cli/test/fixtures/large_build.dart index 7e8e5df..859a000 100644 --- a/packages/flutterguard_cli/test/fixtures/large_build.dart +++ b/packages/flutterguard_cli/test/fixtures/large_build.dart @@ -1,7 +1,17 @@ -import 'package:flutter/material.dart'; +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 { - @override Widget build(BuildContext context) { return Column( children: const [ @@ -80,6 +90,14 @@ class LargeBuildWidget extends StatelessWidget { 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/missing_const.dart b/packages/flutterguard_cli/test/fixtures/missing_const.dart index d67c1f3..e02e99b 100644 --- a/packages/flutterguard_cli/test/fixtures/missing_const.dart +++ b/packages/flutterguard_cli/test/fixtures/missing_const.dart @@ -1,27 +1,27 @@ -import 'package:flutter/material.dart'; +class Widget {} +class BuildContext {} +class StatelessWidget {} +class StatefulWidget {} +class State {} class ValidWidget extends StatelessWidget { - const ValidWidget({super.key}); + const ValidWidget(); - @override - Widget build(BuildContext context) => const SizedBox(); + Widget build(BuildContext context) => Widget(); } class MissingConstWidget extends StatelessWidget { - MissingConstWidget({super.key}); + MissingConstWidget(); - @override - Widget build(BuildContext context) => const SizedBox(); + Widget build(BuildContext context) => Widget(); } class MyStatefulWidget extends StatefulWidget { - MyStatefulWidget({super.key}); + MyStatefulWidget(); - @override State createState() => _MyStatefulWidgetState(); } class _MyStatefulWidgetState extends State { - @override - Widget build(BuildContext context) => const SizedBox(); + 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 new file mode 100644 index 0000000..947459f --- /dev/null +++ b/packages/flutterguard_cli/test/fixtures/mqtt_connection_issue.dart @@ -0,0 +1,14 @@ +// 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 index feab47d..d506b69 100644 --- a/packages/flutterguard_cli/test/scanner_test.dart +++ b/packages/flutterguard_cli/test/scanner_test.dart @@ -9,9 +9,14 @@ 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/scanner.dart'; import 'package:flutterguard_cli/src/static_issue.dart'; import 'package:path/path.dart' as p; @@ -168,6 +173,95 @@ void main() { 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')); diff --git a/scripts/compile.ps1 b/scripts/compile.ps1 new file mode 100644 index 0000000..69c470c --- /dev/null +++ b/scripts/compile.ps1 @@ -0,0 +1,16 @@ +$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 new file mode 100755 index 0000000..8d86a11 --- /dev/null +++ b/scripts/compile.sh @@ -0,0 +1,17 @@ +#!/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/scan_ci.ps1 b/scripts/scan_ci.ps1 new file mode 100644 index 0000000..2516f83 --- /dev/null +++ b/scripts/scan_ci.ps1 @@ -0,0 +1,22 @@ +$ErrorActionPreference = "Stop" + +$failOn = $env:FLUTTERGUARD_FAIL_ON ?? "high" +$minScore = $env:FLUTTERGUARD_MIN_SCORE ?? "80" +$target = if ($args.Count -gt 0) { $args[0] } else { "." } + +Write-Host "==> FlutterGuard CI Scan" -ForegroundColor Cyan +Write-Host " Target: $target" +Write-Host " Fail-on: $failOn" +Write-Host " Min-score: $minScore" +Write-Host "" + +flutterguard scan $target --format json --fail-on $failOn --min-score $minScore + +if ($LASTEXITCODE -eq 0) { + Write-Host "" + Write-Host "All checks passed!" -ForegroundColor Green +} else { + Write-Host "" + Write-Host "CI gate failed! Check .flutterguard/report.json for details." -ForegroundColor Red + exit 1 +} diff --git a/scripts/scan_ci.sh b/scripts/scan_ci.sh new file mode 100755 index 0000000..5dfe09d --- /dev/null +++ b/scripts/scan_ci.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +FAIL_ON="${FLUTTERGUARD_FAIL_ON:-high}" +MIN_SCORE="${FLUTTERGUARD_MIN_SCORE:-80}" +TARGET="${1:-.}" + +echo "==> FlutterGuard CI Scan" +echo " Target: $TARGET" +echo " Fail-on: $FAIL_ON" +echo " Min-score: $MIN_SCORE" +echo "" + +flutterguard scan "$TARGET" --format json --fail-on "$FAIL_ON" --min-score "$MIN_SCORE" + +if [ $? -eq 0 ]; then + echo "" + echo "All checks passed!" +else + echo "" + echo "CI gate failed! Check .flutterguard/report.json for details." + exit 1 +fi