diff --git a/.github/workflows/flutterguard.yml b/.github/workflows/flutterguard.yml index ade143a..3351164 100644 --- a/.github/workflows/flutterguard.yml +++ b/.github/workflows/flutterguard.yml @@ -7,8 +7,9 @@ on: branches: [main, develop] jobs: - scan: + validate: strategy: + fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} @@ -18,18 +19,27 @@ jobs: - uses: dart-lang/setup-dart@v1 with: - sdk: "3.3.0" + sdk: stable - - name: Install FlutterGuard - run: dart pub global activate flutterguard_cli + - name: Install workspace dependencies + run: dart pub get - - name: Scan - run: flutterguard scan . --format json --fail-on high --min-score 80 - continue-on-error: true + - name: Bootstrap workspace + run: dart run melos bootstrap + + - name: Analyze workspace + run: dart run melos run analyze + + - name: Test CLI + run: dart run melos run test:cli + + - name: Scan demo with current checkout + run: dart run flutterguard_cli:flutterguard scan examples/scan_demo --format json --output .flutterguard/ci --fail-on high --min-score 80 --no-color - name: Upload report if: always() uses: actions/upload-artifact@v4 with: name: flutterguard-report-${{ matrix.os }} - path: .flutterguard/report.json + path: examples/scan_demo/.flutterguard/ci/report.json + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d1623f5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,64 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: "3.11.5" + - run: dart pub get + - run: dart run melos bootstrap + - run: dart run melos run analyze + - run: dart run melos run test:cli + - run: dart pub publish --server https://pub.dev --dry-run + working-directory: packages/flutterguard_cli + + build: + needs: validate + strategy: + matrix: + include: + - os: ubuntu-latest + artifact_glob: "dist/*.tar.gz" + script: "bash scripts/package_release.sh" + - os: macos-14 + artifact_glob: "dist/*.tar.gz" + script: "bash scripts/package_release.sh" + - os: windows-latest + artifact_glob: "dist/*.zip" + script: "pwsh scripts/package_release.ps1" + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: "3.11.5" + - run: dart pub get + - run: ${{ matrix.script }} + - uses: actions/upload-artifact@v4 + with: + name: flutterguard-${{ matrix.os }} + path: ${{ matrix.artifact_glob }} + + publish-github-release: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: release-artifacts + merge-multiple: true + - uses: softprops/action-gh-release@v2 + with: + files: release-artifacts/* + generate_release_notes: true diff --git a/AGENTS.md b/AGENTS.md index a705e6e..5ade6d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,9 +19,11 @@ 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 (26 tests) | +| `dart run melos run test:cli` | CLI tests only (61 tests) | | `flutterguard scan []` | Run scan on a project (path defaults to current dir) | | `flutterguard scan --format json --fail-on high` | JSON output with CI gate | +| `flutterguard scan --changed-only` | Incremental scan of git-changed files | +| `flutterguard rules` / `flutterguard explain ` | List/describe rules | | `dart compile exe ... -o flutterguard` | Compile native binary | ## CI & Automation @@ -32,7 +34,7 @@ IoT/smart home Flutter project static analysis CLI plugin. NOT an observability ## CLI Entry Point `packages/flutterguard_cli/bin/flutterguard.dart` -Supports positional path: `flutterguard scan ./my_project` (no `-p` required). Project auto-discovery walks up from CWD to find `flutterguard.yaml`, `pubspec.yaml`, or `lib/`. +Supports positional path: `flutterguard scan ./my_project` (no `-p` required). Project auto-discovery walks up from CWD to find `flutterguard.yaml`, `pubspec.yaml`, or `lib/`. Supports --changed-only incremental scan and rule introspection (rules/explain). Wired rules (11 rule classes, 13 rule IDs): - Standards: LargeUnitsRule (3 IDs), MissingConstConstructorRule, PubspecSecurityRule @@ -44,6 +46,10 @@ Wired rules (11 rule classes, 13 rule IDs): ``` packages/flutterguard_cli/lib/src/ config_loader.dart # YAML → ScanConfig typedefs (11 rule configs + architecture) + scan_context.dart # Project/all/target files and scan mode + source_workspace.dart # Shared source/AST cache + scan diagnostics + import_graph.dart # Shared resolved Dart import graph + boundary_engine.dart # Shared layer/module boundary analysis file_collector.dart # Glob file discovery project_resolver.dart # Project auto-discovery (walk-up flutterguard.yaml / pubspec.yaml / lib/) static_issue.dart # StaticIssue + RiskLevel + IssueDomain + Priority @@ -53,7 +59,10 @@ packages/flutterguard_cli/lib/src/ path_utils.dart # Cross-platform path/glob helpers (p.Context abstraction) import_utils.dart # Dart import resolution against collected files source_utils.dart # Analyzer offset → line number conversion + rule_meta.dart # Rule metadata for rules/explain rules/ + catalog.dart # Rule metadata + execution source of truth + registry.dart # RuleRegistry for all 13 rule IDs large_units.dart # large_file, large_class, large_build_method lifecycle_resource.dart # lifecycle_resource_not_disposed layer_violation.dart # layer_violation (architecture layer breaches) @@ -71,7 +80,7 @@ packages/flutterguard_cli/lib/src/ 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 scanner.dart +1. New rule: spec entry → config typedef → rule class → fixture → test → wire into rules/catalog.dart 2. Always run `melos run analyze` + `melos run test:cli` before committing 3. Do NOT modify archived packages (core/dio/flutter) — they are frozen references 4. Do NOT add Flutter widgets, web/cloud infra, or SaaS SDKs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ff0ba0..87b9687 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## Unreleased + +- **fix:** Prevent successful no-op full scans and preserve setup-error exit codes in local CI scripts. +- **fix:** Anchor configuration policy to the target project and require explicitly selected config files. +- **ci:** Run analyze, tests, and the demo scan against the current checkout on the OS matrix. + +## 0.4.1 (2026-07-09) + +### Adoption Hardening + +- **cli:** Added `flutterguard doctor install` to diagnose executable version, Dart entrypoint, and PATH conflicts. +- **cli:** Added `flutterguard issue export` to create a local JSON feedback bundle for one finding without uploading source code. +- **cli:** Added baseline management commands: `baseline stats`, `baseline prune`, and `baseline check --no-growth`. +- **cli:** Added `flutterguard init --profile` with recommended, strict, migration, IoT security, architecture-only, and performance-only starter profiles. +- **cli:** `flutterguard init []` now supports a positional project path. +- **test:** Expanded CLI coverage for install diagnostics, issue export, baseline management, and config profiles. + +## 0.4.0 (2026-07-08) + +### CI Adoption + +- **cli:** Added baseline creation with `flutterguard baseline create []`. +- **cli:** Added `scan --baseline ` so legacy issues can be filtered from reports, score, and CI gates. +- **cli:** Added single-line / next-line suppression comments for false positive control. +- **cli:** Added SARIF 2.1.0 output via `--format sarif` for GitHub Code Scanning upload. +- **cli:** JSON summary now includes suppression and baseline suppression counters. +- **docs:** Updated English, Chinese, and package README files with baseline, SARIF, `rules`, `explain`, and `--changed-only` usage. +- **release:** Added local source launchers and cross-platform release packaging scripts. +- **release:** Added tag-triggered GitHub Release workflow for native binaries. +- **test:** Expanded CLI tests to cover suppression, baseline, missing baseline failures, SARIF, and JSON summary counters. + ## 0.2.0 (2026-06-15) ### IoT Domain Rules (5 new rules) diff --git a/CONFIGURATION_STRATEGY.md b/CONFIGURATION_STRATEGY.md new file mode 100644 index 0000000..6ebbb76 --- /dev/null +++ b/CONFIGURATION_STRATEGY.md @@ -0,0 +1,183 @@ +# FlutterGuard Configuration Strategy + +FlutterGuard should be easy to start and precise when a project needs stricter +architecture gates. The recommended product strategy is: use short CLI responses +for immediate next steps, and keep this dedicated guide for the complete mental +model. + +## Decision + +Use both surfaces, with different responsibilities: + +| Surface | Purpose | Content | +|---------|---------|---------| +| CLI help and scan responses | Immediate action | The next command, likely fix, and the shortest config path | +| Dedicated configuration guide | Full explanation | Configuration levels, architecture examples, CI patterns, and troubleshooting | + +Do not put the full manual into CLI output. CLI text should answer "what should +I do next?" in a few lines. The guide should answer "how should this be +configured for my project?" + +## Configuration Levels + +### Level 0: Zero Config + +Best for first scans and demos. + +```bash +flutterguard scan +flutterguard scan ./my_flutter_app +``` + +Behavior: + +- Scans `lib/**` +- Excludes generated/freezed/mock files +- Runs all default non-boundary rules +- Does not enforce layers/modules unless they are declared + +Use this level when the user wants a quick signal and has not agreed on project +architecture boundaries yet. No YAML file is required. + +### Level 1: Basic Project Config + +Best for normal local development. + +```yaml +include: + - lib/** + +exclude: + - lib/generated/** + - lib/**.g.dart + - lib/**.freezed.dart + - lib/**.mocks.dart + +rules: + large_file: + enabled: true + maxLines: 500 + large_class: + enabled: true + maxLines: 300 + large_build_method: + enabled: true + maxLines: 80 + lifecycle_resource: + enabled: true + missing_const_constructor: + enabled: true + device_lifecycle: + enabled: true + mqtt_connection: + enabled: true + ble_scanning: + enabled: true + maxScanDurationMs: 10000 + iot_security: + enabled: true + requireTls: true + pubspec_security: + enabled: true +``` + +Use this level when teams want stable thresholds or custom excludes. + +Create it with: + +```bash +flutterguard init +flutterguard config doctor +``` + +### Level 2: CI Gate Config + +Best for pull requests and release checks. + +```bash +flutterguard scan . --format json --fail-on high --min-score 80 +``` + +Recommended policy: + +- Start with `--fail-on high` +- Add `--min-score 80` once the baseline is clean enough +- Avoid `--fail-on low` early in adoption because it can create noisy rollouts +- Run `flutterguard config doctor` before adding CI gates + +### Level 3: Architecture Config + +Best when the project has agreed boundaries. + +Create a starter template with: + +```bash +flutterguard init --with-architecture +``` + +```yaml +architecture: + layers: + - name: presentation + path: lib/presentation/** + allowed_deps: [domain, core] + - name: domain + path: lib/domain/** + allowed_deps: [core] + - name: data + path: lib/data/** + allowed_deps: [domain, core] + - name: core + path: lib/core/** + allowed_deps: [] + + modules: + - name: mqtt_feature + path: lib/features/mqtt/** + allowed_deps: [shared] + - name: ble_feature + path: lib/features/ble/** + allowed_deps: [shared] + - name: shared + path: lib/shared/** + allowed_deps: [] + + detect_cycles: true + layer_violation: + enabled: true + module_violation: + enabled: true +``` + +Important distinction: + +- `architecture.layers[].allowed_deps` must reference layer names. +- `architecture.modules[].allowed_deps` must reference module names. +- Layers describe technical direction such as presentation/domain/data/core. +- Modules describe business or feature isolation such as mqtt/ble/shared. + +## CLI Response Policy + +CLI responses should stay short and operational: + +- `--help`: show common commands, option summary, and the four-level config path +- full scan with no matched files: fail as a setup error and point to include/exclude patterns +- changed-only scan with no relevant changes: succeed with an empty changed-mode report +- config parse error: show the failing config key and expected value type +- CI gate failure: show the failing threshold and direct the user to JSON output + +Avoid long explanations in command output. Point to this guide when the user +needs examples or architecture detail. + +## Config Tooling + +The core configuration helpers are available as CLI commands: + +- `flutterguard init`: write a minimal `flutterguard.yaml` +- `flutterguard init --with-architecture`: write a layered/module template +- `flutterguard config print`: show the merged effective config +- `flutterguard config doctor`: validate globs, unknown deps, empty matches, and + architecture overlap + +These commands are preferable to adding more prose to `scan`, because they keep +scan output focused while still making configuration discoverable. diff --git a/PROJECT_RULES.md b/PROJECT_RULES.md index 23ba983..e6a5dc6 100644 --- a/PROJECT_RULES.md +++ b/PROJECT_RULES.md @@ -70,9 +70,10 @@ root/analysis_options.yaml # strict-casts + strict-inference + 6 lint r ### 9.3 flutterguard.yaml Config Override Chain ``` -root/flutterguard.yaml # development defaults (maxLines: 500/300/80) -└── /flutterguard.yaml # per-scan override (merges over root) - └── (injected via CLI --config flag) # explicit path override + +├── --config # explicit project-relative or absolute config +├── flutterguard.yaml # default project config when no override is given +└── built-in defaults # only when no override is given and the default file is absent ``` -**Rule**: Root config serves as documented example. User projects may override all fields. +**Rule**: Config files do not merge across projects. Relative paths resolve from the target project root, and explicitly selected files must exist. diff --git a/README.md b/README.md index 899a13c..e6f3d3f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ FlutterGuard scans Flutter/Dart source code and reports architecture boundary br **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) +**Docs**: [Usage Guide](docs/USAGE.md) | [Configuration Strategy](CONFIGURATION_STRATEGY.md) | [Windows Assessment](docs/WINDOWS_ASSESSMENT.md) | [Spec](docs/FLUTTERGUARD_SPEC.md) | [Architecture](docs/ARCHITECTURE.md) ## What It Is @@ -35,7 +35,7 @@ FlutterGuard scans Flutter/Dart source code and reports architecture boundary br ## Install -### User install (recommended — from pub.dev) +### Option A: pub.dev install (recommended)
macOS / Linux @@ -71,20 +71,18 @@ $env:Path += ";$env:USERPROFILE\AppData\Local\Pub\Cache\bin" ```
-### Compile native binary (no Dart SDK required at runtime) +### Option B: GitHub Release binary (no Dart SDK at runtime) + +Download the matching binary from the GitHub Releases page, then run it +directly:
macOS / Linux ```bash -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 +chmod +x flutterguard +./flutterguard --version +./flutterguard scan . ```
@@ -92,18 +90,15 @@ dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard Windows (PowerShell) ```powershell -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.exe -.\flutterguard.exe --help +.\flutterguard.exe --version +.\flutterguard.exe scan . ``` -### Developer install (from source) +### Option C: source checkout for development + +Use the local launcher when you want to run the current checkout without +installing or replacing the global `flutterguard` command.
macOS / Linux @@ -112,11 +107,8 @@ dart compile exe packages\flutterguard_cli\bin\flutterguard.dart -o flutterguard 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 +./scripts/flutterguard-dev --version +./scripts/flutterguard-dev scan . ```
@@ -127,11 +119,8 @@ flutterguard --help 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 +.\scripts\flutterguard-dev.ps1 --version +.\scripts\flutterguard-dev.ps1 scan . ``` @@ -143,6 +132,14 @@ flutterguard --help # Scan the current directory flutterguard scan +# Create and validate a starter config +flutterguard init --profile migration +flutterguard config doctor +flutterguard doctor install + +# Inspect the merged effective config +flutterguard config print + # Scan a specific project flutterguard scan ./my_flutter_app # macOS / Linux flutterguard scan .\my_flutter_app # Windows @@ -154,6 +151,18 @@ flutterguard scan -p D:\path\to\project # Windows # JSON output with CI gate flutterguard scan . --format json --fail-on high +# Baseline existing issues before enabling a hard CI gate +flutterguard baseline create . +flutterguard baseline stats +flutterguard baseline check . --baseline .flutterguard/baseline.json --no-growth +flutterguard scan . --baseline .flutterguard/baseline.json --fail-on high + +# GitHub Code Scanning output +flutterguard scan . --format sarif --baseline .flutterguard/baseline.json + +# Export one finding for false-positive feedback +flutterguard issue export --rule mqtt_connection --file lib/device/mqtt.dart --line 42 + # Show help flutterguard --help flutterguard scan --help @@ -174,6 +183,19 @@ Commands: | Command | Description | |---------|-------------| | `flutterguard scan []` | Scan a project (path defaults to current directory) | +| `flutterguard baseline create []` | Create a baseline JSON file for existing issues | +| `flutterguard baseline stats` | Show baseline fingerprint counts | +| `flutterguard baseline prune []` | Remove fixed issues from a baseline | +| `flutterguard baseline check [] --no-growth` | Fail when current issues are missing from baseline | +| `flutterguard doctor install` | Diagnose executable version and PATH conflicts | +| `flutterguard init` | Create a starter `flutterguard.yaml` | +| `flutterguard init --profile migration` | Create a starter config from a profile | +| `flutterguard init --with-architecture` | Create config with architecture layer/module templates | +| `flutterguard config print` | Print the merged effective configuration | +| `flutterguard config doctor` | Validate config, globs, and architecture references | +| `flutterguard issue export` | Export one issue as a local feedback JSON bundle | +| `flutterguard rules` | List available rules | +| `flutterguard explain ` | Explain one rule | | `flutterguard --help` / `-h` | Show usage | | `flutterguard --version` / `-V` | Show version | @@ -184,10 +206,13 @@ Commands: | `` | — | `.` | 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` | +| `--format` | `-f` | `table` | Output format: `table`, `json`, or `sarif` | | `--output` | `-o` | `.flutterguard` | Output directory for reports | | `--verbose` | `-v` | off | Show detailed output with code context | | `--no-color` | — | off | Disable ANSI terminal colors | +| `--changed-only` | — | off | Only scan Dart files changed since `--base` | +| `--base` | — | `main` | Git base ref for `--changed-only` | +| `--baseline` | — | unset | Baseline JSON file used to hide existing issues | | `--fail-on` | — | `none` | CI gate: `none` / `high` / `medium` / `low` | | `--min-score` | — | unset | Minimum score threshold 0–100 | | `--help` | `-h` | — | Show scan usage | @@ -196,18 +221,18 @@ Commands: | Code | Meaning | |------|---------| -| `0` | Success (includes help/version output and no-files-found) | +| `0` | Success, including help/version and a changed-only scan with no relevant changes | | `1` | CI gate failed (issues at/above `--fail-on` level, or score below `--min-score`) | -| `2` | Scan error (bad path, config parse error) | +| `2` | Scan setup error (bad path, missing explicit config, invalid config, or no configured Dart files) | ### Path resolution FlutterGuard auto-discovers the project root by walking up from the current directory, looking for `flutterguard.yaml`, `pubspec.yaml`, or a `lib/` directory. If none are found, it falls back to the current directory. -The `--config` path is resolved 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 +The `--config` path is resolved against the target project: +1. Absolute paths are used as-is and must exist. +2. Relative paths are resolved from the target project root, never from CWD. +3. An omitted default `flutterguard.yaml` uses built-in defaults; any explicitly selected config must exist. --- @@ -215,6 +240,16 @@ The `--config` path is resolved with this priority: Create `flutterguard.yaml` in your project root. +Recommended strategy: + +1. Start with zero config: `flutterguard scan`. +2. Run `flutterguard init` when you need custom thresholds or excludes. +3. Use `flutterguard config print` to inspect merged defaults. +4. Use `flutterguard config doctor` before enabling CI gates. +5. Add architecture layers/modules only after project boundaries are agreed. + +For the full decision model, see [Configuration Strategy](CONFIGURATION_STRATEGY.md). + ### Basic config (for most users) ```yaml @@ -341,6 +376,8 @@ Example shape: "high": 1, "medium": 1, "low": 1, + "suppressed": 0, + "suppressedByBaseline": 0, "byDomain": { "architecture": { "high": 1, "medium": 0, "low": 0, "total": 1 } } @@ -349,6 +386,31 @@ Example shape: } ``` +### SARIF report + +`--format sarif` writes `.flutterguard/report.sarif` for GitHub Code Scanning. High, medium, and low map to SARIF `error`, `warning`, and `note`. + +### Suppression and baseline + +Use source suppression for known false positives: + +```dart +// flutterguard: ignore missing_const_constructor +// flutterguard: ignore iot_security, mqtt_connection +// flutterguard: ignore all +``` + +Suppression applies only to the comment line and the following line. + +Recommended CI adoption order: + +```bash +flutterguard config doctor +flutterguard baseline create . +flutterguard baseline check . --baseline .flutterguard/baseline.json --no-growth +flutterguard scan . --baseline .flutterguard/baseline.json --format json --fail-on high +``` + ## Scoring ``` @@ -386,7 +448,32 @@ jobs: - name: Install FlutterGuard run: dart pub global activate flutterguard_cli - name: Scan - run: flutterguard scan . --format json --fail-on high --min-score 80 + run: flutterguard scan . --format json --baseline .flutterguard/baseline.json --fail-on high --min-score 80 +``` + +### GitHub Code Scanning + +```yaml +name: FlutterGuard SARIF + +on: [push, pull_request] + +jobs: + code-scanning: + runs-on: ubuntu-latest + permissions: + security-events: write + contents: read + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: 3.3.0 + - run: dart pub global activate flutterguard_cli + - run: flutterguard scan . --format sarif --baseline .flutterguard/baseline.json + - uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: .flutterguard/report.sarif ``` ### GitLab CI @@ -426,12 +513,12 @@ repos: ```bash #!/usr/bin/env bash # scan_ci.sh -flutterguard scan . --format json --fail-on high --min-score 80 -if [ $? -eq 0 ]; then +if flutterguard scan . --format json --fail-on high --min-score 80; then echo "All checks passed!" else - echo "CI gate failed! Check .flutterguard/report.json for details." - exit 1 + status=$? + echo "FlutterGuard failed with exit code $status." + exit "$status" fi ``` @@ -443,12 +530,13 @@ fi # scan_ci.ps1 $ErrorActionPreference = "Stop" flutterguard scan . --format json --fail-on high --min-score 80 +$status = $LASTEXITCODE -if ($LASTEXITCODE -eq 0) { +if ($status -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 + Write-Host "FlutterGuard failed with exit code $status." -ForegroundColor Red + exit $status } ``` diff --git a/README.zh.md b/README.zh.md index f11e6ce..40e9f22 100644 --- a/README.zh.md +++ b/README.zh.md @@ -8,7 +8,7 @@ FlutterGuard 扫描 Flutter/Dart 源码,报告架构边界违规、生命周 **支持平台**: macOS、Windows、Linux — 纯 Dart CLI,零原生依赖。 -**文档**: [使用指南](docs/USAGE.md) | [Windows 评估](docs/WINDOWS_ASSESSMENT.md) | [技术规格](docs/FLUTTERGUARD_SPEC.md) | [架构](docs/ARCHITECTURE.md) +**文档**: [使用指南](docs/USAGE.md) | [配置策略](CONFIGURATION_STRATEGY.md) | [Windows 评估](docs/WINDOWS_ASSESSMENT.md) | [技术规格](docs/FLUTTERGUARD_SPEC.md) | [架构](docs/ARCHITECTURE.md) ## 它是什么 @@ -34,7 +34,7 @@ FlutterGuard 扫描 Flutter/Dart 源码,报告架构边界违规、生命周 ## 安装 -### 普通用户安装(推荐 — 从 pub.dev) +### 方式 A:pub.dev 安装(推荐)
macOS / Linux @@ -70,20 +70,17 @@ $env:Path += ";$env:USERPROFILE\AppData\Local\Pub\Cache\bin" ```
-### 编译独立二进制(运行时无需 Dart 环境) +### 方式 B:GitHub Release 二进制(运行时无需 Dart 环境) + +从 GitHub Releases 下载对应平台的二进制后直接运行:
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 +chmod +x flutterguard +./flutterguard --version +./flutterguard scan . ```
@@ -91,18 +88,15 @@ dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard Windows (PowerShell) ```powershell -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.exe -.\flutterguard.exe --help +.\flutterguard.exe --version +.\flutterguard.exe scan . ``` -### 开发者安装(从源码) +### 方式 C:源码开发运行 + +如果你希望直接运行当前 checkout 的源码,不替换全局 `flutterguard` +命令,使用本地 launcher。
macOS / Linux @@ -111,11 +105,8 @@ dart compile exe packages\flutterguard_cli\bin\flutterguard.dart -o flutterguard 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 +./scripts/flutterguard-dev --version +./scripts/flutterguard-dev scan . ```
@@ -126,11 +117,8 @@ flutterguard --help 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 +.\scripts\flutterguard-dev.ps1 --version +.\scripts\flutterguard-dev.ps1 scan . ``` @@ -142,6 +130,14 @@ flutterguard --help # 扫描当前目录 flutterguard scan +# 创建并检查基础配置 +flutterguard init --profile migration +flutterguard config doctor +flutterguard doctor install + +# 查看合并后的有效配置 +flutterguard config print + # 扫描指定项目 flutterguard scan ./my_flutter_app # macOS / Linux flutterguard scan .\my_flutter_app # Windows @@ -153,6 +149,18 @@ flutterguard scan -p D:\path\to\project # Windows # JSON 输出 + CI 门禁 flutterguard scan . --format json --fail-on high +# 启用强门禁前先为历史问题创建 baseline +flutterguard baseline create . +flutterguard baseline stats +flutterguard baseline check . --baseline .flutterguard/baseline.json --no-growth +flutterguard scan . --baseline .flutterguard/baseline.json --fail-on high + +# GitHub Code Scanning 输出 +flutterguard scan . --format sarif --baseline .flutterguard/baseline.json + +# 导出单个问题用于误报反馈 +flutterguard issue export --rule mqtt_connection --file lib/device/mqtt.dart --line 42 + # 显示帮助 flutterguard --help flutterguard scan --help @@ -173,6 +181,19 @@ flutterguard scan examples/scan_demo | 命令 | 说明 | |------|------| | `flutterguard scan []` | 扫描项目(路径默认为当前目录) | +| `flutterguard baseline create []` | 为现有问题创建 baseline JSON 文件 | +| `flutterguard baseline stats` | 查看 baseline fingerprint 数量 | +| `flutterguard baseline prune []` | 从 baseline 移除已修复问题 | +| `flutterguard baseline check [] --no-growth` | 当前问题未进入 baseline 时失败 | +| `flutterguard doctor install` | 检查可执行文件版本和 PATH 冲突 | +| `flutterguard init` | 创建基础 `flutterguard.yaml` | +| `flutterguard init --profile migration` | 使用 profile 创建基础配置 | +| `flutterguard init --with-architecture` | 创建包含架构层/模块模板的配置 | +| `flutterguard config print` | 输出合并后的有效配置 | +| `flutterguard config doctor` | 检查配置、glob 和架构引用 | +| `flutterguard issue export` | 导出单个问题为本地反馈 JSON | +| `flutterguard rules` | 列出所有可用规则 | +| `flutterguard explain ` | 查看单条规则说明 | | `flutterguard --help` / `-h` | 显示帮助 | | `flutterguard --version` / `-V` | 显示版本 | @@ -183,10 +204,13 @@ flutterguard scan examples/scan_demo | `` | — | `.` | 位置参数,项目路径(可选,放在选项之前) | | `--path` | `-p` | `.` | 项目路径(被 `` 位置参数覆盖) | | `--config` | `-c` | `flutterguard.yaml` | 配置文件路径 | -| `--format` | `-f` | `table` | 输出格式:`table` 或 `json` | +| `--format` | `-f` | `table` | 输出格式:`table`、`json` 或 `sarif` | | `--output` | `-o` | `.flutterguard` | 报告输出目录 | | `--verbose` | `-v` | 关闭 | 显示详细代码上下文 | | `--no-color` | — | 关闭 | 禁用 ANSI 终端颜色 | +| `--changed-only` | — | 关闭 | 只扫描相对 `--base` 变更的 Dart 文件 | +| `--base` | — | `main` | `--changed-only` 使用的 Git base ref | +| `--baseline` | — | 不设 | 用于隐藏历史问题的 baseline JSON 文件 | | `--fail-on` | — | `none` | CI 门禁等级:`none` / `high` / `medium` / `low` | | `--min-score` | — | 不设 | 最低可接受评分,0–100 | | `--help` | `-h` | — | 显示 scan 帮助 | @@ -195,18 +219,18 @@ flutterguard scan examples/scan_demo | 退出码 | 含义 | |--------|------| -| `0` | 成功(含 help/version 输出及未找到文件的情况) | +| `0` | 成功,包含 help/version 以及增量扫描没有相关变更的情况 | | `1` | CI 门禁失败(存在超过 `--fail-on` 的问题,或评分低于 `--min-score`)| -| `2` | 扫描错误(路径不存在、配置文件解析错误等) | +| `2` | 扫描设置错误(路径不存在、显式配置缺失、配置无效或未匹配到配置范围内的 Dart 文件) | ### 路径解析 FlutterGuard 从当前目录向上遍历,自动发现项目根目录(查找 `flutterguard.yaml`、`pubspec.yaml` 或 `lib/` 目录)。若未找到,则退化为当前目录。 -`--config` 路径按以下优先级解析: -1. 绝对路径(`-c /path/to/config.yaml`)— 直接使用 -2. 相对 CWD 匹配到的文件(`-c my_config.yaml`)— 从 CWD 解析 -3. 相对项目根目录匹配到的文件 — 兜底 +`--config` 路径始终针对目标项目解析: +1. 绝对路径直接使用,且文件必须存在。 +2. 相对路径从目标项目根目录解析,不再读取 CWD 下的同名文件。 +3. 未显式指定且默认 `flutterguard.yaml` 不存在时使用内置默认值;任何显式选择的配置都必须存在。 --- @@ -214,6 +238,16 @@ FlutterGuard 从当前目录向上遍历,自动发现项目根目录(查找 在项目根目录创建 `flutterguard.yaml`。 +推荐策略: + +1. 先零配置运行:`flutterguard scan`。 +2. 需要自定义阈值或排除文件时,运行 `flutterguard init`。 +3. 使用 `flutterguard config print` 查看合并后的默认值。 +4. 启用 CI 门禁前先运行 `flutterguard config doctor`。 +5. 只有项目边界已经明确时,再添加 architecture layers/modules。 + +完整决策模型见 [配置策略](CONFIGURATION_STRATEGY.md)。 + ### 基础配置(大多数用户适用) ```yaml @@ -340,6 +374,8 @@ architecture: "high": 1, "medium": 1, "low": 1, + "suppressed": 0, + "suppressedByBaseline": 0, "byDomain": { "architecture": { "high": 1, "medium": 0, "low": 0, "total": 1 } } @@ -348,6 +384,31 @@ architecture: } ``` +### SARIF 报告 + +`--format sarif` 会写入 `.flutterguard/report.sarif`,可上传到 GitHub Code Scanning。high、medium、low 分别映射为 SARIF `error`、`warning`、`note`。 + +### Suppression 与 baseline + +对已确认的误报可以使用源码注释: + +```dart +// flutterguard: ignore missing_const_constructor +// flutterguard: ignore iot_security, mqtt_connection +// flutterguard: ignore all +``` + +注释只作用于当前行和下一行。 + +推荐 CI 接入顺序: + +```bash +flutterguard config doctor +flutterguard baseline create . +flutterguard baseline check . --baseline .flutterguard/baseline.json --no-growth +flutterguard scan . --baseline .flutterguard/baseline.json --format json --fail-on high +``` + ## 评分 ``` @@ -385,7 +446,32 @@ jobs: - name: Install FlutterGuard run: dart pub global activate flutterguard_cli - name: Scan - run: flutterguard scan . --format json --fail-on high --min-score 80 + run: flutterguard scan . --format json --baseline .flutterguard/baseline.json --fail-on high --min-score 80 +``` + +### GitHub Code Scanning + +```yaml +name: FlutterGuard SARIF + +on: [push, pull_request] + +jobs: + code-scanning: + runs-on: ubuntu-latest + permissions: + security-events: write + contents: read + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: 3.3.0 + - run: dart pub global activate flutterguard_cli + - run: flutterguard scan . --format sarif --baseline .flutterguard/baseline.json + - uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: .flutterguard/report.sarif ``` ### GitLab CI @@ -425,12 +511,12 @@ repos: ```bash #!/usr/bin/env bash # scan_ci.sh -flutterguard scan . --format json --fail-on high --min-score 80 -if [ $? -eq 0 ]; then +if flutterguard scan . --format json --fail-on high --min-score 80; then echo "All checks passed!" else - echo "CI gate failed! Check .flutterguard/report.json for details." - exit 1 + status=$? + echo "FlutterGuard failed with exit code $status." + exit "$status" fi ``` @@ -442,12 +528,13 @@ fi # scan_ci.ps1 $ErrorActionPreference = "Stop" flutterguard scan . --format json --fail-on high --min-score 80 +$status = $LASTEXITCODE -if ($LASTEXITCODE -eq 0) { +if ($status -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 + Write-Host "FlutterGuard failed with exit code $status." -ForegroundColor Red + exit $status } ``` diff --git a/RELEASE_v0.3.0.md b/RELEASE_v0.3.0.md new file mode 100644 index 0000000..0d193b1 --- /dev/null +++ b/RELEASE_v0.3.0.md @@ -0,0 +1,321 @@ +# Release v0.3.0 — Execution Prompt + +## Summary +Version bump 0.2.0 → 0.3.0. Update docs, package metadata, CHANGELOG, compile, tag, push. + +--- + +## Step 1: Version Bump + +### 1a. `packages/flutterguard_cli/pubspec.yaml` +`version: 0.2.0` → `version: 0.3.0` + +### 1b. `packages/flutterguard_cli/bin/flutterguard.dart` +`const _version = '0.2.0';` → `const _version = '0.3.0';` + +--- + +## Step 2: Update `docs/FLUTTERGUARD_SPEC.md` + +### 2a. Header line 6 +`Version: M2 (Milestone 2) — Architecture Overhaul | Last Updated: 2026-05-17` +→ `Version: M3 (Milestone 3) — Incremental Scan + Rule Introspection | Last Updated: 2026-06-28` + +### 2b. §1 Architecture Overview lines 44-63 + +Replace the entire flow diagram block with: + +``` +User runs: flutterguard scan [] [--changed-only] [--base main] + │ + ├── 1. ArgParser parses CLI flags + ├── 2. ScanConfig.fromFile() loads YAML config + ├── 3. FileCollector.collect() globs .dart files + │ └── if --changed-only: FileCollector.getChangedFiles() filters by git diff + ├── 4. Per-file scan: 11 rule classes analyze each file (13 rule IDs) + │ ├── LargeUnitsRule (file size, class size, build method size) + │ ├── LifecycleResourceRule (undisposed controllers/streams) + │ ├── LayerViolationRule (cross-layer import violations) + │ ├── ModuleViolationRule (cross-module import violations) + │ ├── CircularDependencyRule (file-level cycle detection) + │ ├── MissingConstConstructorRule (widgets missing const constructor) + │ ├── DeviceLifecycleRule (init/teardown pairing) + │ ├── MqttConnectionRule (MQTT connect/disconnect, hardcoded URLs) + │ ├── BleScanningRule (BLE scan lifecycle, timeout) + │ ├── IotSecurityRule (hardcoded secrets, cleartext, insecure BLE) + │ └── PubspecSecurityRule (unbounded deps, deprecated packages) + │ └── (changed-only: circular_dependency skipped) + ├── 5. Issues sorted by risk level (high → medium → low) + ├── 6. ReportGenerator generates output + │ ├── Table → terminal stdout + │ └── JSON → .flutterguard/report.json + └── 7. CI gate check (exit 1 if fail threshold exceeded) +``` + +Also update line 71: `Rule class interface` table row. + +### 2c. §4 CLI Contract — Add flags + +After `--min-score` in the scan command block, add: + +``` + --changed-only Only scan .dart files changed since --base (default: false) + --base Git base ref for changed-only (default: main) +``` + +After the scan block, add: + +``` +flutterguard rules [options] + --format (-f) Output format: table | json (default: table) + +flutterguard explain +``` + +In Exit Codes table, update Code 2 description: +``` +| 2 | Scan/explain error (bad path, config parse error, unknown rule ID) | +``` + +### 2d. §6.1 JSON Report Schema — Add scanMode + +After `"generatedAt"` line, insert: +```json + "scanMode": "full|changed", +``` + +### 2e. §7 Static Rules Detail — Append IoT rules + +After §7.8 (missing_const_constructor), append 5 new sections: + +``` +### 7.9 device_lifecycle (RiskLevel: high, Domain: architecture, Priority: p0) + +**Detection**: For each class, check that device lifecycle methods have balanced init/teardown pairs: +- `initState` ↔ `dispose` +- `connect()` ↔ `disconnect()` +- `start()` ↔ `stop()` +- `listen()` / `subscribe()` ↔ `cancel()` / `unsubscribe()` + +**Implementation**: Parse with `package:analyzer`, check method name presence for balanced pairs. + +### 7.10 mqtt_connection (RiskLevel: high, Domain: architecture, Priority: p0) + +**Detection**: +1. Find MQTT client field declarations (types matching `*MqttClient`, `*MQTT*`) +2. Check for `connect()` calls — verify `disconnect()` exists in dispose-like methods +3. Check for `subscribe()` calls — verify corresponding `unsubscribe()` calls exist +4. Check for hardcoded broker URLs (string literals containing `tcp://` or `mqtt://`) + +### 7.11 ble_scanning (RiskLevel: medium, Domain: architecture, Priority: p1) + +**Detection**: +1. Find BLE-related field declarations (types matching `*Ble*`, `*Bluetooth*`) +2. Check for `startScan()` calls — verify `stopScan()` exists in dispose-like methods +3. Check for `connect()` calls to BLE devices — verify `disconnect()` exists +4. Check that scan timeout is configured (look for timeout parameter in `startScan()`) + +**Config**: `maxScanDurationMs: int (default: 10000)` + +### 7.12 iot_security (RiskLevel: high, Domain: architecture, Priority: p0) + +**Detection**: +| Check | Pattern | Severity | +|-------|---------|----------| +| Hardcoded password/token | String literal matching `password`/`token`/`secret` assignment | high | +| Cleartext MQTT | `tcp://` host or port `1883` in MQTT config | high | +| Cleartext HTTP | `http://` in IoT context packages | medium | +| Insecure BLE | BLE without `bond`/`pair` references | medium | + +**Config**: `requireTls: bool (default: true)` + +### 7.13 pubspec_security (RiskLevel: medium, Domain: standards, Priority: p2) + +**Detection**: Analyzes the project's `pubspec.yaml` rather than individual `.dart` files. +| Check | Pattern | Severity | +|-------|---------|----------| +| Unbounded dependency | `^any` or no version constraint | medium | +| Outdated mqtt_client | `mqtt_client` version < 10.x.x | high | +| Outdated flutter_blue | `flutter_blue` (deprecated, use `flutter_blue_plus`) | high | +| Outdated http | `http` package < 1.x.x with cleartext patterns | medium | +``` + +### 2f. §8 Test Contracts — Full replace + +Replace header: `### 8.1 CLI Tests (13 tests)` → `### 8.1 CLI Tests (37 tests)` + +Append to the test table: + +``` +| changed_only_filters_files | temp git repo, 2 files, change 1 | scanMode=changed, only changed-file issues | +| changed_only_full_scan_when_no_git | non-git dir with changedOnly | scanMode=full | +| changed_only_skips_circular_dependency | cycle fixture with changedOnly | 0 circular_dependency issues | +| registry_contains_all_13_rules | RuleRegistry.all() | length == 13 | +| registry_find_returns_correct_meta | find('large_file') | non-null, correct id/domain | +| registry_find_unknown_returns_null | find('nonexistent') | null | +``` + +### 2g. §9 Evolution Roadmap — Mark M3 complete + +Add after M2 entry: + +``` +### M3 (Completed v0.3.0) — Incremental Scan + Rule Introspection + +Key deliverables: +- `--changed-only` incremental scan via git diff (skips cyclic dep in changed mode) +- `flutterguard rules` / `flutterguard explain` subcommands with RuleMeta registry +- 37 tests (26 base + 6 new + 5 existing) +- RuleMeta class + RuleRegistry for rule introspection +``` + +### 2h. §10 Known Limitations — Update + +- Remove entries 5 and 6 (IoT MQTT/BLE and pubspec_security — now implemented) +- Add entry: +``` +9. **Incremental scan**: `--changed-only` skips circular_dependency entirely + in changed mode. Layer/module violations only detected if the changed file + is the source of the illegal import. Pubspec security is not re-checked + unless the changed file set includes pubspec.yaml. +``` + +### 2i. §12 IoT Domain Rules (Planned) — Delete entire section + +The 5 IoT rules (12.1–12.5) are now documented in §7.9–§7.13. Delete the entire §12 section. + +### 2j. Append §13 Rule Registry + §14 Incremental Scan + +After the final line of the document (end of existing content), append: + +``` +--- +## 13. Rule Registry & Explain Commands + +### 13.1 RuleMeta +Data class in `lib/src/rule_meta.dart`: +- `id` — rule identifier +- `name` — Chinese display name +- `domain` — architecture / performance / standards +- `riskLevel` — high / medium / low +- `priority` — p0 / p1 / p2 +- `purpose` — detection purpose +- `riskReason` — why this matters +- `badExample` — anti-pattern +- `fixSuggestion` — recommended fix +- `configKeys` — YAML config keys +- `cicdSafe` — whether suitable for CI gating + +### 13.2 RuleRegistry +Singleton in `lib/src/rules/registry.dart`: +- `all()` → `List` (13 entries) +- `find(String id)` → `RuleMeta?` + +### 13.3 CLI Commands +- `flutterguard rules` — table of all rules +- `flutterguard rules --format json` — JSON payload +- `flutterguard explain ` — full detail; exit 2 on unknown ID + +--- +## 14. Incremental Scan (--changed-only) + +### Flow +1. Check `.git/` exists in project path +2. `git diff --name-only --diff-filter=ACMR` +3. `git ls-files --others --exclude-standard` (untracked files) +4. Union both sets, filter to .dart files +5. Feed filtered files to all rules +6. CircularDependencyRule force-disabled in changed-only mode + +### Behavior Matrix +| Condition | Behavior | scanMode | +|-----------|----------|----------| +| Non-git dir | Fallback to full scan | full | +| --changed-only, 0 changes | "No Dart files found" exit | full | +| --changed-only, changes > 0 | Only scan changed .dart files | changed | +| --base not specified | Defaults to 'main' | — | +``` + +--- + +## Step 3: Update `AGENTS.md` + +| Line | Change | +|------|--------| +| **Key Commands** | `(26 tests)` → `(37 tests)` | +| **Key Commands row 3** | After json row, add: `\| \`flutterguard scan --changed-only\` \| Incremental scan of git-changed files \|` | +| **Key Commands row 4** | After above: `\| \`flutterguard rules\` / \`flutterguard explain \` \| List/describe rules \|` | +| **CLI Entry Point** | After last sentence, append: `Supports --changed-only incremental scan and rule introspection (rules/explain).` | +| **Source Layout rules/** | Before `pubspec_security.dart` line, add 2 lines: `rule_meta.dart` and `rules/registry.dart` | + +--- + +## Step 4: Update `packages/flutterguard_cli/CHANGELOG.md` + +Insert at top (before `## 0.1.1`): + +```markdown +## 0.3.0 (2026-06-28) + +### Incremental Scan (--changed-only) + +- **cli:** New `--changed-only` flag — only scans `.dart` files changed since `--base` (default: `main`) +- **cli:** Integrated `git diff --name-only` + `git ls-files --others` for change detection +- **cli:** Non-git fallback: gracefully degrades to full scan +- **cli:** `circular_dependency` auto-disabled in changed-only mode +- **cli:** JSON report now includes `scanMode: full|changed` field + +### Rule Introspection (rules / explain) + +- **cli:** New `flutterguard rules` subcommand — list all 13 rules with ID, domain, name +- **cli:** New `flutterguard explain ` subcommand — detailed purpose, risk, example, fix, config +- **cli:** New `RuleMeta` class in `lib/src/rule_meta.dart` — structured rule metadata +- **cli:** New `RuleRegistry` singleton in `lib/src/rules/registry.dart` — registry with `all()` and `find()` +- **cli:** Output supports both `table` (default) and `--format json` + +### Tests + +- **test:** 37 total tests (26 base + 6 new + 5 existing) +- **test:** 3 `changed-only` tests: git repo filter, non-git fallback, skip cycle +- **test:** 3 registry tests: all 13 rules, find by ID, unknown returns null + +### Infrastructure + +- **meta:** Version bumped to 0.3.0 +- **meta:** `AGENTS.md` and `FLUTTERGUARD_SPEC.md` updated for new features +``` + +--- + +## Step 5: Delete ITERATION_1.md + +Remove the plan file (no longer needed after release): + +```bash +rm /Users/forest/code/flutterguard/ITERATION_1.md +``` + +--- + +## Step 6: Compile + Verify + +```bash +dart run melos run analyze +dart run melos run test:cli +dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard +./flutterguard --version # expect "flutterguard 0.3.0" +./flutterguard rules # expect 13 rules +./flutterguard explain large_file # expect full detail +``` + +--- + +## Step 7: git commit + tag + push + +```bash +git add -A +git commit -m "cli: v0.3.0 — --changed-only + rules/explain" +git tag v0.3.0 +git push origin develop +git push origin v0.3.0 +``` diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..6d1a306 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,185 @@ +# FlutterGuard Evolution Roadmap + +FlutterGuard remains an IoT / smart home Flutter static analysis toolchain. The +core product direction is static governance for Flutter projects, not runtime +observability, APM, cloud dashboards, crash reporting, or SDK instrumentation. + +## Guiding Principles + +- Keep the CLI as the source of truth for scanning, configuration, reports, and + CI behavior. +- Prefer local-first integrations that consume CLI output before introducing + hosted services. +- Focus on real team adoption: low-noise CI, editor feedback, baseline + management, and actionable reports. +- Avoid archived runtime packages for new feature work. + +## Architecture Invariants (do not violate) + +These were established by the v0.5.0 refactor and are binding for all future +work. New agents MUST follow them instead of reintroducing pre-0.5.0 patterns. + +- Rule metadata and execution are wired through `lib/src/rules/catalog.dart`. + It is the single source of truth. Do NOT wire rules directly in + `bin/flutterguard.dart` or `scanner.dart`, and do NOT add reflection or + dynamic plugin loading. +- `bin/flutterguard.dart` is thin: top-level routing, help, positional path, + and exit codes only. Functional command behavior lives in `lib/src/cli/` + (`scan_command`, `baseline_commands`, `config_commands`, `issue_commands`, + `rule_commands`, `cli_parsers`). +- Rules consume shared analysis state, not ad-hoc file reads: + - `SourceWorkspace` for source/AST caching and diagnostics. + - `ImportGraph` for resolved Dart imports. + - `DependencyBoundaryEngine` for layer/module boundary checks. + - `ScanContext` for scan scope/mode (project/all/target files). +- Each rule keeps the standalone `analyze(List files, {SourceWorkspace? + workspace})` API so direct rule tests and programmatic consumers keep working. +- New rule flow is unchanged: spec entry → config typedef → rule class → + fixture → test → wire into `rules/catalog.dart`. +- Workspace SDK constraint is `^3.11.5`; keep CI (`release.yml`, + `flutterguard.yml`) SDK aligned when it changes. + +## Current State (as of v0.5.0) + +- Milestone 0.4.x (CI Adoption Hardening) is DONE: baseline `stats` / `prune` / + `check --no-growth`, config profiles, install diagnostics, issue export, and + no-match / changed-only scan policy hardening all shipped in 0.4.0–0.4.1. +- v0.5.0 was consumed by the internal architecture refactor above rather than + the originally planned developer-workflow integrations. Those integrations + now move to Milestone 0.6. + +## Milestone 0.6 — Developer Workflow Integrations + +Goal: Surface FlutterGuard findings before CI by integrating with developer +tools. + +Deliverables: + +- Official GitHub Action: + - install FlutterGuard + - run scan + - upload JSON / SARIF artifacts + - optionally upload SARIF to GitHub Code Scanning +- GitHub PR annotations mode for changed-line findings. +- VS Code extension MVP driven by CLI JSON output: + - run scan on demand + - show diagnostics in the editor + - open rule explanations + - insert suppression comments +- Config profiles: + - `recommended` + - `strict` + - `migration` + - `iot-security` + - `architecture-only` + +Exit Criteria: + +- New users can add FlutterGuard to GitHub Actions with one reusable action. +- Developers can see and suppress findings in VS Code without reading CI logs. +- Teams can start from a profile instead of hand-authoring every rule setting. + +## Milestone 0.7 — Fixes, Reports, and Workspace Scale + +Goal: Move from reporting issues to helping teams reduce them across larger +Flutter codebases. + +Deliverables: + +- Auto-fix support for safe cases: + - add missing `const` constructors where deterministic + - insert suppression comments for selected findings + - suggest `pubspec.yaml` dependency replacements +- Static HTML report output for local reviews and architecture audits. +- Melos / monorepo workspace scanning: + - scan all packages + - scan one package + - scan affected packages + - merge package reports +- Architecture graph exports: + - Mermaid + - DOT + - dependency cycle graph + - layer / module violation graph + +Exit Criteria: + +- Teams can track and reduce findings without building custom scripts. +- Large Flutter workspaces can run FlutterGuard package-by-package. +- Architecture violations can be reviewed visually during refactors. + +## Milestone 0.8 — Deeper Static Analysis + +Goal: Reduce false positives and expand IoT-specific detection using stronger +AST and project context. + +Deliverables: + +- Lifecycle detection improvements: + - helper dispose methods + - base classes and mixins + - composite disposer patterns + - resource collections +- Import resolution improvements: + - package name awareness + - workspace package imports + - generated-file boundaries +- Additional IoT checks: + - OTA update safety patterns + - token and key storage risks + - device pairing flow risks + - local network discovery risks + - permission and privacy configuration checks + +Exit Criteria: + +- Core lifecycle and IoT security rules produce fewer false positives on real + projects. +- Monorepo package imports are handled consistently. +- New IoT checks remain static-only and CI-safe. + +## Milestone 0.9+ — Extensibility and Team Governance + +Goal: Support mature teams that need reusable governance policies without +turning FlutterGuard into a hosted platform. + +Deliverables: + +- Reusable team rule packs. +- Shared config inheritance: + - local file extends + - bundled profiles + - organization profile conventions +- Custom rule SDK exploration. +- IntelliJ / Android Studio plugin after VS Code integration stabilizes. +- Optional GitHub App only if GitHub Action annotations are insufficient. + +Exit Criteria: + +- Teams can standardize policies across many Flutter apps. +- Customization is possible without weakening the built-in static analysis + contract. +- Hosted services remain optional and out of the core product path. + +## Non-Goals + +- Runtime tracing SDK. +- APM or observability platform. +- Crash reporting. +- Cloud dashboard as a required workflow. +- Network proxy or HTTP inspector. +- Flutter widget library. +- Editing archived runtime packages in `archive/`. + +## Recommended Priority + +1. Official GitHub Action. +2. Baseline management commands. +3. VS Code extension MVP. +4. Config profiles and inheritance. +5. PR annotations. +6. Auto-fix for deterministic cases. +7. Static HTML report. +8. Workspace / monorepo scanning. +9. Architecture graph export. +10. Custom rule SDK exploration. diff --git a/packages/flutterguard_cli/AGENTS.md b/packages/flutterguard_cli/AGENTS.md index 43524f1..238cad4 100644 --- a/packages/flutterguard_cli/AGENTS.md +++ b/packages/flutterguard_cli/AGENTS.md @@ -8,13 +8,18 @@ Primary CLI tool for IoT Flutter static architecture scanning and CI gating. - depended by: nothing ## Entry Points -- bin: `bin/flutterguard.dart` — CLI entry, arg parsing, rule wiring +- bin: `bin/flutterguard.dart` — thin CLI router, help, and exit codes - lib barrel: `lib/flutterguard_cli.dart` ## Key Source Files | File | Responsibility | |------|---------------| -| `bin/flutterguard.dart` | Arg parsing, positional path, scan orchestration, exit codes, `--no-color` | +| `bin/flutterguard.dart` | Top-level routing, positional path, help, exit codes | +| `src/cli/` | Parser tree and functional command handlers | +| `src/scan_context.dart` | Project/all/target file scope and scan mode | +| `src/source_workspace.dart` | Shared source/AST cache and diagnostics | +| `src/import_graph.dart` | Shared import graph for architecture rules | +| `src/rules/catalog.dart` | Explicit metadata and execution wiring | | `src/config_loader.dart` | YAML → ScanConfig typedef parsing (11 rule configs + architecture) | | `src/file_collector.dart` | Glob-based .dart file discovery | | `src/project_resolver.dart` | Project auto-discovery (walk-up flutterguard.yaml / pubspec.yaml / lib/) | @@ -43,21 +48,22 @@ IoT: DeviceLifecycleRule, MqttConnectionRule, BleScanningRule, IotSecurityRule ## Test - command: `melos run test:cli` -- 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 +- test files: `test/scanner_test.dart` (57 tests) and `test/cli_test.dart` (4 process-level tests) +- fixtures: `test/fixtures/` (16 functional fixture files) +- every new rule needs: spec entry → config typedef → class → fixture → test → wire in rules/catalog.dart ## Current Toolchain Flow -1. `bin/flutterguard.dart` parses CLI arguments (supports positional ``), maps validation errors to exit codes. +1. `bin/flutterguard.dart` routes CLI commands (supports positional ``) and maps validation errors to exit codes; `lib/src/cli/` owns parser and command-family behavior. 2. `lib/src/project_resolver.dart` auto-discovers project root by walking up for flutterguard.yaml / pubspec.yaml / lib/. 3. `lib/src/scanner.dart` owns scan orchestration: config loading, file collection, rule execution, issue sorting, and optional JSON writing. 4. `lib/src/config_loader.dart` parses `flutterguard.yaml` into typed record configs. 5. `lib/src/file_collector.dart` resolves include/exclude globs to Dart files. -6. `lib/src/rules/` 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. +6. `lib/src/rules/catalog.dart` explicitly registers metadata and execution. Do not add reflection or dynamic plugin loading. +7. `SourceWorkspace` and `ImportGraph` provide per-scan shared analysis data. +8. `lib/src/report_generator.dart` renders table output (with optional `--no-color`) and JSON report payloads. ## Change Boundaries -- Put user-facing CLI parsing and exit-code behavior in `bin/`. +- Put top-level CLI routing/help in `bin/`; put functional command parsing and behavior in `lib/src/cli/`. - Put reusable scan behavior in `lib/src/scanner.dart`, not in `bin/`. - Put rule-specific detection in `lib/src/rules/`. - Put shared path/import/source helpers in `lib/src/*_utils.dart`. diff --git a/packages/flutterguard_cli/CHANGELOG.md b/packages/flutterguard_cli/CHANGELOG.md index a4f336e..ab05a9f 100644 --- a/packages/flutterguard_cli/CHANGELOG.md +++ b/packages/flutterguard_cli/CHANGELOG.md @@ -1,5 +1,74 @@ # Changelog +## 0.5.0 (2026-07-12) + +### Architecture Refactor + +- **cli:** Introduced shared scan analysis infrastructure: `ScanContext`, `SourceWorkspace`, `ImportGraph`, and `DependencyBoundaryEngine`. +- **cli:** Added `rules/catalog.dart` as the single source of truth for rule metadata and execution, routing all rules through the shared workspace. +- **cli:** Split command handlers into `lib/src/cli/`, reducing `bin/flutterguard.dart` to routing, help, and exit codes. +- **ci:** Aligned the release workflow Dart SDK with the workspace `^3.11.5` constraint. +- **fix:** Reject full scans whose include/exclude policy matches no Dart files instead of returning a successful empty report. +- **fix:** Resolve relative configs from the target project and reject missing explicitly selected config files. +- **fix:** Treat clean changed-only scans as successful empty incremental reports and reject invalid Git base refs. +- **ci:** Validate the current checkout on all supported CI operating systems instead of scanning with a pub.dev installation. + +## 0.4.1 (2026-07-09) + +### Adoption Hardening + +- **cli:** Added `flutterguard doctor install` to diagnose executable version, Dart entrypoint, and PATH conflicts. +- **cli:** Added `flutterguard issue export` for local, user-reviewed false-positive feedback bundles. +- **cli:** Added `flutterguard baseline stats`, `flutterguard baseline prune`, and `flutterguard baseline check --no-growth`. +- **cli:** Added `flutterguard init --profile` starter profiles and positional `init []` support. +- **test:** Added coverage for install diagnostics, issue export, baseline management, and config profiles. + +## 0.4.0 (2026-07-08) + +### CI Adoption + +- **cli:** Added `flutterguard baseline create []` to snapshot existing issues into `.flutterguard/baseline.json`. +- **cli:** Added `scan --baseline ` to hide baseline-matched issues from stdout, JSON, SARIF, scoring, and CI gates. +- **cli:** Added source suppression comments: `// flutterguard: ignore `, comma-separated IDs, and `ignore all`. +- **cli:** Added `--format sarif` with SARIF 2.1.0 output at `.flutterguard/report.sarif` for GitHub Code Scanning. +- **cli:** JSON summary now reports `suppressed` and `suppressedByBaseline` counts. +- **docs:** Updated README command references, CI onboarding order, suppression examples, baseline usage, and SARIF upload workflow. +- **release:** Documented source-checkout launcher usage to avoid stale global binaries. + +### Tests + +- **test:** Added coverage for suppression matching, next-line `ignore all`, baseline filtering, missing baseline errors, JSON counters, and SARIF structure. +- **test:** CLI test suite now covers 43 tests. + +## 0.3.0 (2026-06-28) + +### Incremental Scan (--changed-only) + +- **cli:** New `--changed-only` flag — only scans `.dart` files changed since `--base` (default: `main`) +- **cli:** Integrated `git diff --name-only` + `git ls-files --others` for change detection +- **cli:** Non-git fallback: gracefully degrades to full scan +- **cli:** `circular_dependency` auto-disabled in changed-only mode +- **cli:** JSON report now includes `scanMode: full|changed` field + +### Rule Introspection (rules / explain) + +- **cli:** New `flutterguard rules` subcommand — list all 13 rules with ID, domain, name +- **cli:** New `flutterguard explain ` subcommand — detailed purpose, risk, example, fix, config +- **cli:** New `RuleMeta` class in `lib/src/rule_meta.dart` — structured rule metadata +- **cli:** New `RuleRegistry` singleton in `lib/src/rules/registry.dart` — registry with `all()` and `find()` +- **cli:** Output supports both `table` (default) and `--format json` + +### Tests + +- **test:** 37 total tests (26 base + 6 new + 5 existing) +- **test:** 3 `changed-only` tests: git repo filter, non-git fallback, skip cycle +- **test:** 3 registry tests: all 13 rules, find by ID, unknown returns null + +### Infrastructure + +- **meta:** Version bumped to 0.3.0 +- **meta:** `AGENTS.md` and `FLUTTERGUARD_SPEC.md` updated for new features + ## 0.1.1 (2026-06-09) ### pub.dev Publishing diff --git a/packages/flutterguard_cli/README.md b/packages/flutterguard_cli/README.md index 9ebebb8..b5f2eee 100644 --- a/packages/flutterguard_cli/README.md +++ b/packages/flutterguard_cli/README.md @@ -12,15 +12,41 @@ IoT Flutter project static analysis CLI for architecture enforcement, code quali ```bash # Install globally dart pub global activate flutterguard_cli +flutterguard --version # Scan a Flutter project flutterguard scan -p /path/to/flutter_project +# Create and validate a starter config +flutterguard init --profile migration +flutterguard config doctor +flutterguard doctor install + # JSON output with CI gate flutterguard scan -p . --format json --fail-on high --min-score 80 + +# Baseline existing issues before enforcing CI +flutterguard baseline create . +flutterguard baseline stats +flutterguard baseline check . --baseline .flutterguard/baseline.json --no-growth +flutterguard scan . --baseline .flutterguard/baseline.json --fail-on high + +# GitHub Code Scanning output +flutterguard scan . --format sarif --baseline .flutterguard/baseline.json + +# Export one finding for feedback +flutterguard issue export --rule mqtt_connection --file lib/device/mqtt.dart --line 42 ``` -## Checks (8 rule IDs) +When working from a source checkout, prefer the local launcher so you always +run the current files instead of an older global executable: + +```bash +./scripts/flutterguard-dev --version +./scripts/flutterguard-dev scan . +``` + +## Checks | Rule | Level | What it checks | |------|-------|----------------| @@ -32,11 +58,21 @@ flutterguard scan -p . --format json --fail-on high --min-score 80 | `module_violation` | HIGH | Cross-module architecture import violations | | `circular_dependency` | MEDIUM | File-level import cycles | | `missing_const_constructor` | LOW | Widget classes missing `const` constructor | +| `device_lifecycle` | HIGH | Unbalanced device init/teardown pairs | +| `mqtt_connection` | HIGH | MQTT connect/disconnect and broker URL checks | +| `iot_security` | HIGH | Hardcoded secrets, cleartext MQTT/HTTP, insecure BLE patterns | +| `ble_scanning` | MEDIUM | BLE scan/stop and timeout checks | +| `pubspec_security` | MEDIUM | Unbounded, deprecated, or outdated dependency checks | ## Configuration Create `flutterguard.yaml` in your project root: +Start without config, then run `flutterguard init` only when you need custom +thresholds, excludes, CI gates, or explicit architecture boundaries. Use +`flutterguard config print` to inspect merged defaults and +`flutterguard config doctor` before enabling CI gates. + ```yaml rules: large_file: @@ -60,22 +96,38 @@ architecture: detect_cycles: true ``` -If no config file exists, defaults are used (all rules enabled, no architecture constraints). +If the default config is omitted, built-in defaults are used (all default rules enabled, no architecture constraints). Any path explicitly passed with `--config` must exist and relative paths resolve from the target project root. ## CLI Reference ``` -flutterguard scan [options] +flutterguard scan [] [options] -p, --path Project path (default: .) -c, --config Config file (default: flutterguard.yaml) - -f, --format table | json (default: table) + -f, --format table | json | sarif (default: table) -o, --output Output directory (default: .flutterguard) -v, --verbose Show detailed context + --changed-only Only scan changed Dart files + --base Git base ref for changed-only mode + --baseline Baseline JSON file for existing issues --fail-on CI gate: none | high | medium | low --min-score Minimum score threshold 0-100 + +flutterguard baseline create [] [--output .flutterguard/baseline.json] +flutterguard baseline stats +flutterguard baseline prune [] [--dry-run] +flutterguard baseline check [] --no-growth +flutterguard doctor install +flutterguard init [--with-architecture] [--force] +flutterguard init [] [--profile recommended|migration|strict|iot-security|architecture-only|performance-only] +flutterguard config print +flutterguard config doctor +flutterguard issue export [--rule ] [--file ] [--line ] +flutterguard rules [--format table|json] +flutterguard explain ``` -Exit codes: `0` success, `1` CI gate failed, `2` scan error. +Exit codes: `0` success, `1` CI gate failed, `2` scan setup error. A full scan that matches no configured Dart files exits with `2`; a changed-only scan with no relevant changes exits with `0`. ## Scoring @@ -91,13 +143,38 @@ score = max(0, 100 - HIGH*10 - MEDIUM*4 - LOW*1) ## CI Integration +Recommended adoption order: + +```bash +flutterguard config doctor +flutterguard baseline create . +flutterguard scan . --baseline .flutterguard/baseline.json --format json --fail-on high +``` + ```yaml # GitHub Actions - uses: dart-lang/setup-dart@v1 with: sdk: 3.3.0 - run: dart pub global activate flutterguard_cli -- run: flutterguard scan -p . --format json --fail-on high --min-score 80 +- run: flutterguard scan . --format json --baseline .flutterguard/baseline.json --fail-on high --min-score 80 +``` + +For GitHub Code Scanning: + +```yaml +- run: flutterguard scan . --format sarif --baseline .flutterguard/baseline.json +- uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: .flutterguard/report.sarif +``` + +Known false positives can be suppressed on the same line or the next line: + +```dart +// flutterguard: ignore missing_const_constructor +// flutterguard: ignore iot_security, mqtt_connection +// flutterguard: ignore all ``` ## Requirements diff --git a/packages/flutterguard_cli/bin/AGENTS.md b/packages/flutterguard_cli/bin/AGENTS.md index 99a0c54..3f76672 100644 --- a/packages/flutterguard_cli/bin/AGENTS.md +++ b/packages/flutterguard_cli/bin/AGENTS.md @@ -1,15 +1,14 @@ # CLI Entry Layer ## Responsibility -`flutterguard.dart` is only the command-line adapter. +`flutterguard.dart` is only the top-level command router. It should: -- Parse commands and options with `package:args`. +- Use the parser tree from `lib/src/cli/cli_parsers.dart`. - Support positional path: `flutterguard scan ./my_project` (no `-p` required). - Print help/version text. - Convert validation or scan errors into documented exit codes. -- Call `FlutterGuardScanner.scan()` for real work. -- Pass `--no-color` flag through to `ReportGenerator`. +- Delegate functional command behavior to `lib/src/cli/`. It should not: - Implement rules. diff --git a/packages/flutterguard_cli/bin/flutterguard.dart b/packages/flutterguard_cli/bin/flutterguard.dart index dbb0fa0..ad79380 100644 --- a/packages/flutterguard_cli/bin/flutterguard.dart +++ b/packages/flutterguard_cli/bin/flutterguard.dart @@ -2,48 +2,36 @@ import 'dart:io'; import 'package:args/args.dart'; -import 'package:flutterguard_cli/src/report_generator.dart'; -import 'package:flutterguard_cli/src/scanner.dart'; +import 'package:flutterguard_cli/src/cli/scan_command.dart'; +import 'package:flutterguard_cli/src/cli/baseline_commands.dart'; +import 'package:flutterguard_cli/src/cli/cli_parsers.dart'; +import 'package:flutterguard_cli/src/cli/config_commands.dart'; +import 'package:flutterguard_cli/src/cli/issue_commands.dart'; +import 'package:flutterguard_cli/src/cli/rule_commands.dart'; -const _version = '0.2.0'; +const _version = '0.4.1'; void main(List args) { final normalizedArgs = _extractPositionalPath(args); - final scanParser = ArgParser(allowTrailingOptions: false) - ..addOption('path', - abbr: 'p', defaultsTo: '.', help: 'Project path to scan') - ..addOption('config', - abbr: 'c', - defaultsTo: 'flutterguard.yaml', - help: 'Path to flutterguard.yaml config file') - ..addOption('format', - abbr: 'f', - defaultsTo: 'table', - allowed: ['table', 'json'], - help: 'Output format') - ..addOption('output', - abbr: 'o', - defaultsTo: '.flutterguard', - help: 'Output directory for reports') - ..addFlag('verbose', - abbr: 'v', - help: 'Show detailed output with code context', - negatable: false) - ..addFlag('no-color', - help: 'Disable ANSI terminal colors', - negatable: false) - ..addOption('fail-on', - defaultsTo: 'none', - allowed: ['none', 'high', 'medium', 'low'], - help: 'Fail threshold for CI gate') - ..addOption('min-score', help: 'Minimum score threshold (0-100)') - ..addFlag('help', abbr: 'h', help: 'Show scan usage', negatable: false); - - final parser = ArgParser() - ..addCommand('scan', scanParser) - ..addFlag('help', abbr: 'h', help: 'Show usage', negatable: false) - ..addFlag('version', abbr: 'V', help: 'Show version', negatable: false); + final parsers = CliParsers(); + final scanParser = parsers.scan; + final baselineCreateParser = parsers.baselineCreate; + final baselineStatsParser = parsers.baselineStats; + final baselinePruneParser = parsers.baselinePrune; + final baselineCheckParser = parsers.baselineCheck; + final baselineParser = parsers.baseline; + final initParser = parsers.init; + final installDoctorParser = parsers.installDoctor; + final doctorParser = parsers.doctor; + final issueExportParser = parsers.issueExport; + final issueParser = parsers.issue; + final configPrintParser = parsers.configPrint; + final configDoctorParser = parsers.configDoctor; + final configParser = parsers.config; + final rulesParser = parsers.rules; + final explainParser = parsers.explain; + final parser = parsers.root; try { final results = parser.parse(normalizedArgs); @@ -70,6 +58,40 @@ void main(List args) { exit(0); } _handleScan(command); + } else if (command.name == 'baseline') { + _handleBaseline( + command, + baselineParser, + baselineCreateParser, + baselineStatsParser, + baselinePruneParser, + baselineCheckParser, + ); + } else if (command.name == 'doctor') { + _handleDoctor(command, doctorParser, installDoctorParser); + } else if (command.name == 'init') { + if (command['help'] == true) { + _printInitUsage(initParser); + exit(0); + } + _handleInit(command); + } else if (command.name == 'config') { + _handleConfig( + command, configParser, configPrintParser, configDoctorParser); + } else if (command.name == 'issue') { + _handleIssue(command, issueParser, issueExportParser); + } else if (command.name == 'rules') { + if (command['help'] == true) { + _printRulesUsage(rulesParser); + exit(0); + } + _handleRules(command); + } else if (command.name == 'explain') { + if (command['help'] == true) { + _printExplainUsage(explainParser); + exit(0); + } + _handleExplain(command); } else { _printUsage(parser); exit(0); @@ -82,6 +104,54 @@ void main(List args) { } } +void _handleInit(ArgResults args) { + ConfigCommands.init(args); +} + +void _handleConfig( + ArgResults command, + ArgParser configParser, + ArgParser configPrintParser, + ArgParser configDoctorParser, +) { + if (command['help'] == true || command.command == null) { + _printConfigUsage(configParser); + exit(0); + } + + final subcommand = command.command!; + if (subcommand.name == 'print') { + if (subcommand['help'] == true) { + _printConfigPrintUsage(configPrintParser); + exit(0); + } + _handleConfigPrint(subcommand); + return; + } + if (subcommand.name == 'doctor') { + if (subcommand['help'] == true) { + _printConfigDoctorUsage(configDoctorParser); + exit(0); + } + _handleConfigDoctor(subcommand); + return; + } + + _printConfigUsage(configParser); + exit(0); +} + +void _handleConfigPrint(ArgResults args) { + ConfigCommands.printEffective( + args, + configPath: _explicitConfigPath(args), + ); +} + +void _handleConfigDoctor(ArgResults args) { + ConfigCommands.doctor(args, configPath: _explicitConfigPath(args)); +} + List _extractPositionalPath(List args) { if (args.isEmpty) return args; @@ -108,66 +178,132 @@ List _extractPositionalPath(List args) { } 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); - - late final ScanResult result; - try { - result = FlutterGuardScanner.scan( - projectPath: args['path'] as String, - configPath: args['config'] as String, - outputDir: args['output'] as String, - writeJson: format == 'json', - noColor: noColor, - ); - } on ScanException catch (e) { - stderr.writeln('Error: ${e.message}'); - exit(2); - } on FormatException catch (e) { - stderr.writeln('Error: ${e.message}'); - exit(2); - } + ScanCommand.run(args, configPath: _explicitConfigPath(args)); +} - if (result.files.isEmpty) { - stderr.writeln('No Dart files found. Check your include/exclude patterns.'); +void _handleBaseline( + ArgResults command, + ArgParser baselineParser, + ArgParser baselineCreateParser, + ArgParser baselineStatsParser, + ArgParser baselinePruneParser, + ArgParser baselineCheckParser, +) { + if (command['help'] == true || command.command == null) { + _printBaselineUsage(baselineParser); exit(0); } - final stdoutOutput = ReportGenerator.generateStdout( - projectPath: result.projectPath, - issues: result.issues, - scannedFileCount: result.files.length, - verbose: verbose, - noColor: noColor, - ); - stdout.writeln(stdoutOutput); - - if (failOn != 'none') { - if (ReportGenerator.shouldFail(result.issues, failOn)) { - stderr - .writeln('CI gate failed: Issues found at or above "$failOn" level.'); - exit(1); + final subcommand = command.command!; + if (subcommand.name == 'create') { + if (subcommand['help'] == true) { + _printBaselineCreateUsage(baselineCreateParser); + exit(0); } + _handleBaselineCreate(subcommand); + return; } + if (subcommand.name == 'stats') { + if (subcommand['help'] == true) { + _printBaselineStatsUsage(baselineStatsParser); + exit(0); + } + _handleBaselineStats(subcommand); + return; + } + if (subcommand.name == 'prune') { + if (subcommand['help'] == true) { + _printBaselinePruneUsage(baselinePruneParser); + exit(0); + } + _handleBaselinePrune(subcommand); + return; + } + if (subcommand.name == 'check') { + if (subcommand['help'] == true) { + _printBaselineCheckUsage(baselineCheckParser); + exit(0); + } + _handleBaselineCheck(subcommand); + return; + } + + _printBaselineUsage(baselineParser); + exit(0); +} - if (minScore != null && result.score < minScore) { - stderr.writeln( - 'CI gate failed: Score ${result.score} is below minimum $minScore.'); - exit(1); +void _handleBaselineCreate(ArgResults args) { + BaselineCommands.create(args, configPath: _explicitConfigPath(args)); +} + +void _handleBaselineStats(ArgResults args) { + BaselineCommands.stats(args); +} + +void _handleBaselinePrune(ArgResults args) { + BaselineCommands.prune(args, configPath: _explicitConfigPath(args)); +} + +void _handleBaselineCheck(ArgResults args) { + BaselineCommands.check(args, configPath: _explicitConfigPath(args)); +} + +void _handleDoctor( + ArgResults command, + ArgParser doctorParser, + ArgParser installDoctorParser, +) { + if (command['help'] == true || command.command == null) { + _printDoctorUsage(doctorParser); + exit(0); } + final subcommand = command.command!; + if (subcommand.name == 'install') { + if (subcommand['help'] == true) { + _printInstallDoctorUsage(installDoctorParser); + exit(0); + } + ConfigCommands.installDoctor(version: _version); + return; + } + _printDoctorUsage(doctorParser); + exit(0); } -int? _parseMinScore(String? value) { - if (value == null) return null; - final score = int.tryParse(value); - if (score == null || score < 0 || score > 100) { - throw const FormatException('Expected --min-score to be an integer 0-100.'); +void _handleIssue( + ArgResults command, + ArgParser issueParser, + ArgParser issueExportParser, +) { + if (command['help'] == true || command.command == null) { + _printIssueUsage(issueParser); + exit(0); + } + final subcommand = command.command!; + if (subcommand.name != 'export') { + _printIssueUsage(issueParser); + exit(0); } - return score; + if (subcommand['help'] == true) { + _printIssueExportUsage(issueExportParser); + exit(0); + } + IssueCommands.export( + subcommand, + configPath: _explicitConfigPath(subcommand), + ); +} + +String? _explicitConfigPath(ArgResults args) { + return args.wasParsed('config') ? args['config'] as String : null; +} + +void _handleRules(ArgResults args) { + RuleCommands.list(args); +} + +void _handleExplain(ArgResults args) { + RuleCommands.explain(args); } void _printUsage(ArgParser parser) { @@ -178,18 +314,41 @@ void _printUsage(ArgParser parser) { 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(' baseline Create a baseline for existing issues'); + stdout.writeln(' doctor Diagnose installation and environment'); + stdout.writeln(' init Create a starter flutterguard.yaml'); + stdout.writeln(' config Print or validate effective configuration'); + stdout.writeln(' issue Export issue feedback bundles'); + stdout.writeln(' rules List available rules'); + stdout.writeln(' explain Explain a rule by ID'); stdout.writeln(); stdout.writeln('Global Options:'); stdout.writeln(' -h, --help Show this help message'); stdout.writeln(' -V, --version Show version'); stdout.writeln(); stdout.writeln('Examples:'); - stdout.writeln(' flutterguard scan # Scan current directory'); - stdout.writeln(' flutterguard scan ./my_flutter_app # Scan specific project'); + stdout.writeln( + ' flutterguard scan # Scan current directory'); + stdout + .writeln(' flutterguard init # Create basic config'); + stdout.writeln( + ' flutterguard config doctor # Validate config and globs'); + stdout.writeln( + ' flutterguard scan ./my_flutter_app # Scan specific project'); stdout.writeln(' flutterguard scan -p /path/to/app # Explicit path flag'); + stdout.writeln(' flutterguard scan . --format json --fail-on high'); + stdout.writeln(' flutterguard baseline create .'); + stdout + .writeln(' flutterguard scan . --baseline .flutterguard/baseline.json'); + stdout.writeln(' flutterguard doctor install'); + stdout.writeln(' flutterguard issue export --rule mqtt_connection'); + stdout.writeln(); + stdout.writeln('Configuration strategy:'); stdout.writeln( - ' flutterguard scan . --format json --fail-on high'); + ' Start with zero config, add flutterguard.yaml only for thresholds, excludes,'); + stdout.writeln(' CI gates, or explicit architecture layers/modules.'); } void _printScanUsage(ArgParser scanParser) { @@ -197,7 +356,168 @@ void _printScanUsage(ArgParser scanParser) { stdout.writeln(); stdout.writeln('Usage: flutterguard scan [] [options]'); stdout.writeln(); - stdout.writeln(' Project path to scan (default: current directory)'); + stdout.writeln( + ' Project path to scan (default: current directory)'); stdout.writeln(); stdout.writeln(scanParser.usage); + stdout.writeln(); + stdout.writeln('Configuration strategy:'); + stdout.writeln(' 1. Zero config: run scan with built-in defaults.'); + stdout + .writeln(' 2. Basic config: tune include/exclude and rule thresholds.'); + stdout.writeln( + ' 3. Baseline: run baseline create . before gating legacy projects.'); + stdout.writeln( + ' 4. CI config: add --format json --baseline .flutterguard/baseline.json --fail-on high.'); + stdout.writeln( + ' 5. Changed-only: add --changed-only --base main to scan git changes.'); + stdout.writeln( + ' 6. Architecture config: declare layers/modules explicitly before enabling boundary gates.'); + stdout.writeln(); + stdout.writeln('Docs: README.md and CONFIGURATION_STRATEGY.md'); +} + +void _printBaselineUsage(ArgParser parser) { + stdout.writeln('FlutterGuard — baseline command'); + stdout.writeln(); + stdout.writeln('Usage: flutterguard baseline [options]'); + stdout.writeln(); + stdout.writeln('Commands:'); + stdout.writeln(' create [] Create a baseline for existing issues'); + stdout.writeln(' stats Show baseline fingerprint counts'); + stdout.writeln(' prune [] Remove fixed issues from a baseline'); + stdout.writeln(' check [] Check current issues against a baseline'); + stdout.writeln(); + stdout.writeln(parser.usage); +} + +void _printBaselineCreateUsage(ArgParser parser) { + stdout.writeln('FlutterGuard — baseline create command'); + stdout.writeln(); + stdout.writeln('Usage: flutterguard baseline create [] [options]'); + stdout.writeln(); + stdout.writeln(parser.usage); +} + +void _printBaselineStatsUsage(ArgParser parser) { + stdout.writeln('FlutterGuard — baseline stats command'); + stdout.writeln(); + stdout.writeln('Usage: flutterguard baseline stats [options]'); + stdout.writeln(); + stdout.writeln(parser.usage); +} + +void _printBaselinePruneUsage(ArgParser parser) { + stdout.writeln('FlutterGuard — baseline prune command'); + stdout.writeln(); + stdout.writeln('Usage: flutterguard baseline prune [] [options]'); + stdout.writeln(); + stdout.writeln(parser.usage); +} + +void _printBaselineCheckUsage(ArgParser parser) { + stdout.writeln('FlutterGuard — baseline check command'); + stdout.writeln(); + stdout.writeln('Usage: flutterguard baseline check [] [options]'); + stdout.writeln(); + stdout.writeln(parser.usage); +} + +void _printDoctorUsage(ArgParser parser) { + stdout.writeln('FlutterGuard — doctor command'); + stdout.writeln(); + stdout.writeln('Usage: flutterguard doctor [options]'); + stdout.writeln(); + stdout.writeln('Commands:'); + stdout.writeln(' install Diagnose installed executable and PATH'); + stdout.writeln(); + stdout.writeln(parser.usage); +} + +void _printInstallDoctorUsage(ArgParser parser) { + stdout.writeln('FlutterGuard — doctor install command'); + stdout.writeln(); + stdout.writeln('Usage: flutterguard doctor install [options]'); + stdout.writeln(); + stdout.writeln(parser.usage); +} + +void _printIssueUsage(ArgParser parser) { + stdout.writeln('FlutterGuard — issue command'); + stdout.writeln(); + stdout.writeln('Usage: flutterguard issue [options]'); + stdout.writeln(); + stdout.writeln('Commands:'); + stdout.writeln(' export Export a local feedback bundle for one issue'); + stdout.writeln(); + stdout.writeln(parser.usage); +} + +void _printIssueExportUsage(ArgParser parser) { + stdout.writeln('FlutterGuard — issue export command'); + stdout.writeln(); + stdout.writeln('Usage: flutterguard issue export [options]'); + stdout.writeln(); + stdout.writeln(parser.usage); +} + +void _printRulesUsage(ArgParser parser) { + stdout.writeln('FlutterGuard — rules command'); + stdout.writeln(); + stdout.writeln('Usage: flutterguard rules [options]'); + stdout.writeln(); + stdout.writeln('List all available rules.'); + stdout.writeln(parser.usage); +} + +void _printExplainUsage(ArgParser parser) { + stdout.writeln('FlutterGuard — explain command'); + stdout.writeln(); + stdout.writeln('Usage: flutterguard explain '); + stdout.writeln(); + stdout.writeln('Show detailed explanation for a rule.'); + stdout.writeln(parser.usage); +} + +void _printInitUsage(ArgParser initParser) { + stdout.writeln('FlutterGuard — init command'); + stdout.writeln(); + stdout.writeln('Usage: flutterguard init [] [options]'); + stdout.writeln(); + stdout.writeln(initParser.usage); + stdout.writeln(); + stdout.writeln('Examples:'); + stdout.writeln(' flutterguard init'); + stdout.writeln(' flutterguard init --profile migration'); + stdout.writeln(' flutterguard init --with-architecture'); + stdout.writeln(' flutterguard init -p ./my_flutter_app --force'); +} + +void _printConfigUsage(ArgParser configParser) { + stdout.writeln('FlutterGuard — config command'); + stdout.writeln(); + stdout.writeln('Usage: flutterguard config [options]'); + stdout.writeln(); + stdout.writeln('Commands:'); + stdout.writeln(' print Show merged effective config'); + stdout.writeln( + ' doctor Validate config, globs, and architecture references'); + stdout.writeln(); + stdout.writeln(configParser.usage); +} + +void _printConfigPrintUsage(ArgParser parser) { + stdout.writeln('FlutterGuard — config print command'); + stdout.writeln(); + stdout.writeln('Usage: flutterguard config print [options]'); + stdout.writeln(); + stdout.writeln(parser.usage); +} + +void _printConfigDoctorUsage(ArgParser parser) { + stdout.writeln('FlutterGuard — config doctor command'); + stdout.writeln(); + stdout.writeln('Usage: flutterguard config doctor [options]'); + stdout.writeln(); + stdout.writeln(parser.usage); } diff --git a/packages/flutterguard_cli/lib/flutterguard_cli.dart b/packages/flutterguard_cli/lib/flutterguard_cli.dart index 4f9448e..e5c800d 100644 --- a/packages/flutterguard_cli/lib/flutterguard_cli.dart +++ b/packages/flutterguard_cli/lib/flutterguard_cli.dart @@ -6,7 +6,10 @@ export 'src/path_utils.dart'; export 'src/priority.dart'; export 'src/project_resolver.dart'; export 'src/report_generator.dart'; +export 'src/rule_meta.dart'; +export 'src/scan_context.dart'; export 'src/rules/ble_scanning.dart'; +export 'src/rules/catalog.dart'; export 'src/rules/circular_dependency.dart'; export 'src/rules/device_lifecycle.dart'; export 'src/rules/iot_security.dart'; @@ -17,6 +20,8 @@ export 'src/rules/missing_const_constructor.dart'; export 'src/rules/module_violation.dart'; export 'src/rules/mqtt_connection.dart'; export 'src/rules/pubspec_security.dart'; +export 'src/rules/registry.dart'; export 'src/scanner.dart'; export 'src/source_utils.dart'; +export 'src/source_workspace.dart'; export 'src/static_issue.dart'; diff --git a/packages/flutterguard_cli/lib/src/AGENTS.md b/packages/flutterguard_cli/lib/src/AGENTS.md index c99e92d..b8c4e9e 100644 --- a/packages/flutterguard_cli/lib/src/AGENTS.md +++ b/packages/flutterguard_cli/lib/src/AGENTS.md @@ -5,6 +5,11 @@ This directory contains reusable implementation for the CLI. ## Main Files - `scanner.dart`: global scan orchestration and `ScanResult`. +- `scan_context.dart`: project/all/target file scope and scan mode. +- `source_workspace.dart`: shared source text/AST/line cache and diagnostics. +- `import_graph.dart`: resolved import graph shared by architecture rules. +- `boundary_engine.dart`: common layer/module dependency enforcement. +- `rules/catalog.dart`: explicit rule metadata and execution wiring. - `config_loader.dart`: YAML parsing into typed record configs (11 rule configs + architecture). - `file_collector.dart`: include/exclude glob file discovery. - `project_resolver.dart`: project auto-discovery (walk-up flutterguard.yaml / pubspec.yaml / lib/). @@ -21,4 +26,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). +- pubspec_security handles project-root YAML parsing (uses `package:yaml` directly). diff --git a/packages/flutterguard_cli/lib/src/baseline.dart b/packages/flutterguard_cli/lib/src/baseline.dart new file mode 100644 index 0000000..4bc8823 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/baseline.dart @@ -0,0 +1,136 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'static_issue.dart'; + +class Baseline { + final Set fingerprints; + + const Baseline(this.fingerprints); + + static String fingerprint(StaticIssue issue, String projectPath) { + final relativePath = + p.isWithin(projectPath, issue.file) || p.equals(projectPath, issue.file) + ? p.relative(issue.file, from: projectPath) + : issue.file; + final normalizedPath = relativePath.replaceAll('\\', '/'); + final source = [ + issue.id, + normalizedPath, + issue.line ?? 1, + issue.message, + ].join('|'); + return _stableHash(source); + } + + bool contains(StaticIssue issue, String projectPath) { + return fingerprints.contains(fingerprint(issue, projectPath)); + } + + static Baseline load(String path) { + final file = File(path); + if (!file.existsSync()) { + throw FormatException('Baseline file "$path" does not exist.'); + } + + return loadFromString(file.readAsStringSync()); + } + + static Baseline loadFromString(String content) { + final decoded = jsonDecode(content); + if (decoded is! Map) { + throw const FormatException('Baseline file must be a JSON object.'); + } + final fingerprints = decoded['fingerprints']; + if (fingerprints is! List) { + throw const FormatException( + 'Baseline file must contain a fingerprints list.', + ); + } + + return Baseline(fingerprints.map((v) => v.toString()).toSet()); + } + + static Map stats(String path) { + final baseline = load(path); + return { + 'path': path, + 'fingerprints': baseline.fingerprints.length, + }; + } + + static List newFingerprints({ + required String projectPath, + required Baseline baseline, + required List issues, + }) { + final current = issues + .map((issue) => fingerprint(issue, projectPath)) + .where((fingerprint) => !baseline.fingerprints.contains(fingerprint)) + .toSet() + .toList() + ..sort(); + return current; + } + + static String encodeFingerprints({ + required String projectPath, + required List fingerprints, + }) { + final sorted = fingerprints.toSet().toList()..sort(); + final payload = { + 'version': '1.0.0', + 'generatedAt': DateTime.now().toIso8601String(), + 'projectPath': projectPath, + 'issueCount': sorted.length, + 'fingerprints': sorted, + }; + return const JsonEncoder.withIndent(' ').convert(payload); + } + + static String prune({ + required String projectPath, + required Baseline baseline, + required List issues, + }) { + final current = issues + .map((issue) => fingerprint(issue, projectPath)) + .where(baseline.fingerprints.contains) + .toList(); + return encodeFingerprints( + projectPath: projectPath, + fingerprints: current, + ); + } + + static String encode({ + required String projectPath, + required List issues, + }) { + final fingerprints = issues + .map((issue) => fingerprint(issue, projectPath)) + .toSet() + .toList() + ..sort(); + + final payload = { + 'version': '1.0.0', + 'generatedAt': DateTime.now().toIso8601String(), + 'projectPath': projectPath, + 'issueCount': issues.length, + 'fingerprints': fingerprints, + }; + return const JsonEncoder.withIndent(' ').convert(payload); + } +} + +String _stableHash(String source) { + var hash = 2166136261; + for (final codeUnit in utf8.encode(source)) { + hash ^= codeUnit; + hash = (hash * 16777619) & 0xffffffff; + } + return hash.toRadixString(16).padLeft(8, '0'); +} diff --git a/packages/flutterguard_cli/lib/src/boundary_engine.dart b/packages/flutterguard_cli/lib/src/boundary_engine.dart new file mode 100644 index 0000000..0737481 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/boundary_engine.dart @@ -0,0 +1,79 @@ +import 'package:glob/glob.dart'; + +import 'import_graph.dart'; +import 'path_utils.dart'; +import 'source_workspace.dart'; + +class BoundaryDefinition { + final String name; + final String path; + final List allowedDeps; + + const BoundaryDefinition({ + required this.name, + required this.path, + required this.allowedDeps, + }); +} + +class BoundaryViolation { + final ImportEdge edge; + final BoundaryDefinition source; + final BoundaryDefinition target; + + const BoundaryViolation({ + required this.edge, + required this.source, + required this.target, + }); +} + +class DependencyBoundaryEngine { + static List analyze({ + required Iterable sourceFiles, + required Iterable allFiles, + required List boundaries, + required ImportGraph graph, + required SourceWorkspace workspace, + String? projectPath, + }) { + final fileToBoundary = {}; + for (final file in allFiles.map(normalizePath)) { + for (final boundary in boundaries) { + try { + final matches = projectPath == null + ? Glob(boundary.path.replaceAll('\\', '/')).matches(file) + : matchesProjectGlob(file, boundary.path, projectPath); + if (matches) { + fileToBoundary[file] = boundary; + break; + } + } on Object catch (error) { + workspace.addDiagnostic(ScanDiagnostic( + stage: 'boundary_glob', + file: file, + message: 'Invalid boundary glob "${boundary.path}": $error', + )); + } + } + } + + final violations = []; + for (final file in sourceFiles.map(normalizePath)) { + final source = fileToBoundary[file]; + if (source == null) continue; + for (final edge in graph.outgoing(file)) { + final target = fileToBoundary[edge.target]; + if (target == null || target.name == source.name) continue; + if (!source.allowedDeps.contains(target.name)) { + violations.add(BoundaryViolation( + edge: edge, + source: source, + target: target, + )); + } + } + } + return violations; + } +} diff --git a/packages/flutterguard_cli/lib/src/cli/baseline_commands.dart b/packages/flutterguard_cli/lib/src/cli/baseline_commands.dart new file mode 100644 index 0000000..7cf8eb1 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/cli/baseline_commands.dart @@ -0,0 +1,130 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:path/path.dart' as p; + +import '../baseline.dart'; +import '../scanner.dart'; + +class BaselineCommands { + static void create(ArgResults args, {String? configPath}) { + final projectPath = + args.rest.isNotEmpty ? args.rest.first : args['path'] as String; + final output = args['output'] as String; + + try { + final result = FlutterGuardScanner.scan( + projectPath: projectPath, + configPath: configPath, + applySuppression: false, + ); + final outputPath = + p.isAbsolute(output) ? output : p.join(result.projectPath, output); + Directory(p.dirname(outputPath)).createSync(recursive: true); + File(outputPath).writeAsStringSync(Baseline.encode( + projectPath: result.projectPath, + issues: result.rawIssues, + )); + stdout.writeln( + 'Created FlutterGuard baseline: $outputPath ' + '(${result.rawIssues.length} issues)', + ); + } on ScanException catch (error) { + _fail(error.message); + } on FormatException catch (error) { + _fail(error.message); + } + } + + static void stats(ArgResults args) { + try { + final stats = Baseline.stats(args['baseline'] as String); + stdout.writeln(const JsonEncoder.withIndent(' ').convert(stats)); + } on FormatException catch (error) { + _fail(error.message); + } + } + + static void prune(ArgResults args, {String? configPath}) { + final projectPath = + args.rest.isNotEmpty ? args.rest.first : args['path'] as String; + final baselinePath = args['baseline'] as String; + try { + final result = FlutterGuardScanner.scan( + projectPath: projectPath, + configPath: configPath, + applySuppression: false, + ); + final resolvedBaselinePath = _resolve(result.projectPath, baselinePath); + final baseline = Baseline.load(resolvedBaselinePath); + final pruned = Baseline.prune( + projectPath: result.projectPath, + baseline: baseline, + issues: result.rawIssues, + ); + final prunedBaseline = Baseline.loadFromString(pruned); + final removed = + baseline.fingerprints.length - prunedBaseline.fingerprints.length; + if (args['dry-run'] == true) { + stdout.writeln( + 'Baseline prune dry-run: ${baseline.fingerprints.length} -> ' + '${prunedBaseline.fingerprints.length} fingerprints ' + '($removed removed)', + ); + return; + } + File(resolvedBaselinePath).writeAsStringSync(pruned); + stdout.writeln( + 'Pruned baseline: ${baseline.fingerprints.length} -> ' + '${prunedBaseline.fingerprints.length} fingerprints ($removed removed)', + ); + } on ScanException catch (error) { + _fail(error.message); + } on FormatException catch (error) { + _fail(error.message); + } + } + + static void check(ArgResults args, {String? configPath}) { + final projectPath = + args.rest.isNotEmpty ? args.rest.first : args['path'] as String; + try { + final result = FlutterGuardScanner.scan( + projectPath: projectPath, + configPath: configPath, + applySuppression: false, + ); + final baseline = Baseline.load(_resolve( + result.projectPath, + args['baseline'] as String, + )); + final newFingerprints = Baseline.newFingerprints( + projectPath: result.projectPath, + baseline: baseline, + issues: result.rawIssues, + ); + stdout.writeln( + 'Baseline check: ${newFingerprints.length} issue(s) missing from baseline.', + ); + for (final fingerprint in newFingerprints.take(20)) { + stdout.writeln(' $fingerprint'); + } + if (args['no-growth'] == true && newFingerprints.isNotEmpty) { + exit(1); + } + } on ScanException catch (error) { + _fail(error.message); + } on FormatException catch (error) { + _fail(error.message); + } + } + + static String _resolve(String projectPath, String filePath) => + p.isAbsolute(filePath) ? filePath : p.join(projectPath, filePath); + + static Never _fail(String message) { + stderr.writeln('Error: $message'); + exit(2); + } +} diff --git a/packages/flutterguard_cli/lib/src/cli/cli_parsers.dart b/packages/flutterguard_cli/lib/src/cli/cli_parsers.dart new file mode 100644 index 0000000..7417ed1 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/cli/cli_parsers.dart @@ -0,0 +1,202 @@ +import 'package:args/args.dart'; + +import '../config_tools.dart'; + +class CliParsers { + late final ArgParser scan; + late final ArgParser baselineCreate; + late final ArgParser baselineStats; + late final ArgParser baselinePrune; + late final ArgParser baselineCheck; + late final ArgParser baseline; + late final ArgParser init; + late final ArgParser installDoctor; + late final ArgParser doctor; + late final ArgParser issueExport; + late final ArgParser issue; + late final ArgParser configPrint; + late final ArgParser configDoctor; + late final ArgParser config; + late final ArgParser rules; + late final ArgParser explain; + late final ArgParser root; + + CliParsers() { + scan = ArgParser(allowTrailingOptions: false) + ..addOption('path', + abbr: 'p', defaultsTo: '.', help: 'Project path to scan') + ..addOption('config', + abbr: 'c', + defaultsTo: 'flutterguard.yaml', + help: 'Path to flutterguard.yaml config file') + ..addOption('format', + abbr: 'f', + defaultsTo: 'table', + allowed: ['table', 'json', 'sarif'], + help: 'Output format') + ..addOption('output', + abbr: 'o', + defaultsTo: '.flutterguard', + help: 'Output directory for reports') + ..addFlag('verbose', + abbr: 'v', + help: 'Show detailed output with code context', + negatable: false) + ..addFlag('no-color', + help: 'Disable ANSI terminal colors', negatable: false) + ..addFlag('changed-only', + help: 'Only scan files changed since --base', negatable: false) + ..addOption('base', + defaultsTo: 'main', help: 'Git base branch/ref for changed-only mode') + ..addOption('fail-on', + defaultsTo: 'none', + allowed: ['none', 'high', 'medium', 'low'], + help: 'Fail threshold for CI gate') + ..addOption('min-score', help: 'Minimum score threshold (0-100)') + ..addOption('baseline', + help: 'Baseline JSON file used to hide existing issues') + ..addFlag('help', abbr: 'h', help: 'Show scan usage', negatable: false); + + baselineCreate = ArgParser(allowTrailingOptions: false) + ..addOption('path', + abbr: 'p', defaultsTo: '.', help: 'Project path to scan') + ..addOption('config', + abbr: 'c', + defaultsTo: 'flutterguard.yaml', + help: 'Path to flutterguard.yaml config file') + ..addOption('output', + abbr: 'o', + defaultsTo: '.flutterguard/baseline.json', + help: 'Baseline file to create') + ..addFlag('help', + abbr: 'h', help: 'Show baseline create usage', negatable: false); + baselineStats = ArgParser() + ..addOption('baseline', + abbr: 'b', + defaultsTo: '.flutterguard/baseline.json', + help: 'Baseline file to inspect') + ..addFlag('help', + abbr: 'h', help: 'Show baseline stats usage', negatable: false); + baselinePrune = ArgParser(allowTrailingOptions: false) + ..addOption('path', + abbr: 'p', defaultsTo: '.', help: 'Project path to scan') + ..addOption('config', + abbr: 'c', + defaultsTo: 'flutterguard.yaml', + help: 'Path to flutterguard.yaml config file') + ..addOption('baseline', + abbr: 'b', + defaultsTo: '.flutterguard/baseline.json', + help: 'Baseline file to prune') + ..addFlag('dry-run', + help: 'Print prune result without updating the baseline file', + negatable: false) + ..addFlag('help', + abbr: 'h', help: 'Show baseline prune usage', negatable: false); + baselineCheck = ArgParser(allowTrailingOptions: false) + ..addOption('path', + abbr: 'p', defaultsTo: '.', help: 'Project path to scan') + ..addOption('config', + abbr: 'c', + defaultsTo: 'flutterguard.yaml', + help: 'Path to flutterguard.yaml config file') + ..addOption('baseline', + abbr: 'b', + defaultsTo: '.flutterguard/baseline.json', + help: 'Baseline file to compare against') + ..addFlag('no-growth', + help: 'Fail if current scan has issues missing from baseline', + negatable: false) + ..addFlag('help', + abbr: 'h', help: 'Show baseline check usage', negatable: false); + baseline = ArgParser() + ..addCommand('create', baselineCreate) + ..addCommand('stats', baselineStats) + ..addCommand('prune', baselinePrune) + ..addCommand('check', baselineCheck) + ..addFlag('help', + abbr: 'h', help: 'Show baseline usage', negatable: false); + + init = ArgParser() + ..addOption('path', + abbr: 'p', + defaultsTo: '.', + help: 'Project path for flutterguard.yaml') + ..addOption('config', + abbr: 'c', + defaultsTo: 'flutterguard.yaml', + help: 'Config file path to create') + ..addFlag('with-architecture', + help: 'Include architecture layer/module template', negatable: false) + ..addOption('profile', + defaultsTo: 'recommended', + allowed: ConfigTools.profiles.toList()..sort(), + help: 'Starter config profile') + ..addFlag('force', + help: 'Overwrite an existing config file', negatable: false) + ..addFlag('help', abbr: 'h', help: 'Show init usage', negatable: false); + + installDoctor = ArgParser() + ..addFlag('help', + abbr: 'h', help: 'Show install doctor usage', negatable: false); + doctor = ArgParser() + ..addCommand('install', installDoctor) + ..addFlag('help', abbr: 'h', help: 'Show doctor usage', negatable: false); + + issueExport = ArgParser(allowTrailingOptions: false) + ..addOption('path', + abbr: 'p', defaultsTo: '.', help: 'Project path to scan') + ..addOption('config', + abbr: 'c', + defaultsTo: 'flutterguard.yaml', + help: 'Path to flutterguard.yaml config file') + ..addOption('rule', help: 'Rule ID to export') + ..addOption('file', help: 'Project-relative or absolute file path') + ..addOption('line', help: 'Issue line number') + ..addOption('context', + defaultsTo: '2', help: 'Number of context lines around issue') + ..addOption('output', abbr: 'o', help: 'Output file. Defaults to stdout') + ..addFlag('help', + abbr: 'h', help: 'Show issue export usage', negatable: false); + issue = ArgParser() + ..addCommand('export', issueExport) + ..addFlag('help', abbr: 'h', help: 'Show issue usage', negatable: false); + + configPrint = _configLeafParser(); + configDoctor = _configLeafParser(); + config = ArgParser() + ..addCommand('print', configPrint) + ..addCommand('doctor', configDoctor) + ..addFlag('help', abbr: 'h', help: 'Show config usage', negatable: false); + rules = ArgParser() + ..addOption('format', + abbr: 'f', + defaultsTo: 'table', + allowed: ['table', 'json'], + help: 'Output format') + ..addFlag('help', abbr: 'h', help: 'Show rules usage', negatable: false); + explain = ArgParser() + ..addFlag('help', + abbr: 'h', help: 'Show explain usage', negatable: false); + + root = ArgParser() + ..addCommand('scan', scan) + ..addCommand('baseline', baseline) + ..addCommand('doctor', doctor) + ..addCommand('init', init) + ..addCommand('config', config) + ..addCommand('issue', issue) + ..addCommand('rules', rules) + ..addCommand('explain', explain) + ..addFlag('help', abbr: 'h', help: 'Show usage', negatable: false) + ..addFlag('version', abbr: 'V', help: 'Show version', negatable: false); + } + + ArgParser _configLeafParser() => ArgParser() + ..addOption('path', + abbr: 'p', defaultsTo: '.', help: 'Project path for config resolution') + ..addOption('config', + abbr: 'c', defaultsTo: 'flutterguard.yaml', help: 'Config file path') + ..addFlag('help', + abbr: 'h', help: 'Show config command usage', negatable: false); +} diff --git a/packages/flutterguard_cli/lib/src/cli/config_commands.dart b/packages/flutterguard_cli/lib/src/cli/config_commands.dart new file mode 100644 index 0000000..b309e36 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/cli/config_commands.dart @@ -0,0 +1,71 @@ +import 'dart:io'; + +import 'package:args/args.dart'; + +import '../config_loader.dart'; +import '../config_tools.dart'; +import '../install_doctor.dart'; +import '../project_resolver.dart'; + +class ConfigCommands { + static void init(ArgResults args) { + try { + final projectPath = + args.rest.isNotEmpty ? args.rest.first : args['path'] as String; + final outputPath = ConfigTools.writeInitConfig( + projectPath: projectPath, + configPath: args['config'] as String, + withArchitecture: args['with-architecture'] as bool, + force: args['force'] as bool, + profile: args['profile'] as String, + ); + stdout.writeln('Created FlutterGuard config: $outputPath'); + stdout.writeln('Next: flutterguard config doctor -p $projectPath'); + } on StateError catch (error) { + _fail(error.message); + } + } + + static void printEffective(ArgResults args, {String? configPath}) { + try { + final projectPath = ProjectResolver.resolveProjectPath( + args['path'] as String, + ); + final resolvedConfigPath = ConfigTools.resolveConfigPathForProject( + projectPath: projectPath, + configPath: configPath, + ); + final config = ScanConfig.fromFile( + resolvedConfigPath, + requireFile: configPath != null, + ); + stdout.write(ConfigTools.effectiveYaml(config)); + } on FormatException catch (error) { + _fail(error.message); + } + } + + static void doctor(ArgResults args, {String? configPath}) { + try { + final result = ConfigTools.doctor( + projectPath: args['path'] as String, + configPath: configPath, + ); + stdout.write(ConfigTools.formatDoctorResult(result)); + if (result.hasErrors) exit(1); + } on StateError catch (error) { + _fail(error.message); + } on FormatException catch (error) { + _fail(error.message); + } + } + + static void installDoctor({required String version}) { + stdout.write(InstallDoctor.generate(version: version)); + } + + static Never _fail(String message) { + stderr.writeln('Error: $message'); + exit(2); + } +} diff --git a/packages/flutterguard_cli/lib/src/cli/issue_commands.dart b/packages/flutterguard_cli/lib/src/cli/issue_commands.dart new file mode 100644 index 0000000..860f6db --- /dev/null +++ b/packages/flutterguard_cli/lib/src/cli/issue_commands.dart @@ -0,0 +1,61 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:path/path.dart' as p; + +import '../issue_export.dart'; +import '../scanner.dart'; + +class IssueCommands { + static void export(ArgResults args, {String? configPath}) { + try { + final contextLines = int.tryParse(args['context'] as String); + if (contextLines == null || contextLines < 0) { + throw const FormatException( + 'Expected --context to be a non-negative integer.', + ); + } + final lineRaw = args['line'] as String?; + final line = lineRaw == null ? null : int.tryParse(lineRaw); + if (lineRaw != null && (line == null || line < 1)) { + throw const FormatException( + 'Expected --line to be a positive integer.', + ); + } + + final result = FlutterGuardScanner.scan( + projectPath: args['path'] as String, + configPath: configPath, + applySuppression: false, + ); + final exported = IssueExporter.export( + projectPath: result.projectPath, + issues: result.rawIssues, + ruleId: args['rule'] as String?, + filePath: args['file'] as String?, + line: line, + contextLines: contextLines, + workspace: result.sources, + ); + final output = args['output'] as String?; + if (output == null) { + stdout.writeln(exported); + return; + } + final outputPath = + p.isAbsolute(output) ? output : p.join(result.projectPath, output); + File(outputPath).parent.createSync(recursive: true); + File(outputPath).writeAsStringSync(exported); + stdout.writeln('Exported issue feedback bundle: $outputPath'); + } on ScanException catch (error) { + _fail(error.message); + } on FormatException catch (error) { + _fail(error.message); + } + } + + static Never _fail(String message) { + stderr.writeln('Error: $message'); + exit(2); + } +} diff --git a/packages/flutterguard_cli/lib/src/cli/rule_commands.dart b/packages/flutterguard_cli/lib/src/cli/rule_commands.dart new file mode 100644 index 0000000..482e6e9 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/cli/rule_commands.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/args.dart'; + +import '../rules/registry.dart'; + +class RuleCommands { + static void list(ArgResults args) { + final all = RuleRegistry.all(); + if (args['format'] == 'json') { + stdout.writeln(const JsonEncoder.withIndent(' ').convert( + all.map((meta) => meta.toJson()).toList(), + )); + return; + } + + all.sort((a, b) => a.id.compareTo(b.id)); + stdout.writeln('可用规则 (${all.length}):'); + stdout.writeln(); + for (final rule in all) { + stdout.writeln( + ' ${rule.id.padRight(32)} ${rule.domain.padRight(14)} ${rule.name}', + ); + } + stdout.writeln(); + stdout.writeln('执行 flutterguard explain 查看详情'); + } + + static void explain(ArgResults args) { + final rest = args.rest; + if (rest.isEmpty) { + stderr.writeln('Error: 请指定规则 ID'); + stderr.writeln('用法: flutterguard explain '); + stderr.writeln('可用规则: ${_ruleIds()}'); + exit(2); + } + + final ruleId = rest.first; + final meta = RuleRegistry.find(ruleId); + if (meta == null) { + stderr.writeln('Error: 未找到规则 "$ruleId"'); + stderr.writeln('可用规则: ${_ruleIds()}'); + exit(2); + } + + stdout.writeln('规则: ${meta.id}'); + stdout.writeln('名称: ${meta.name}'); + stdout.writeln('领域: ${meta.domain}'); + stdout.writeln('风险: ${meta.riskLevel}'); + stdout.writeln('优先级: ${meta.priority}'); + stdout.writeln('CI 阻断: ${meta.cicdSafe ? "是" : "否"}'); + stdout.writeln(); + stdout.writeln('检测目的:'); + stdout.writeln(' ${meta.purpose}'); + stdout.writeln(); + stdout.writeln('风险原因:'); + stdout.writeln(' ${meta.riskReason}'); + stdout.writeln(); + stdout.writeln('典型坏例子:'); + stdout.writeln(' ${meta.badExample}'); + stdout.writeln(); + stdout.writeln('推荐修复:'); + stdout.writeln(' ${meta.fixSuggestion}'); + if (meta.configKeys.isNotEmpty) { + stdout.writeln(); + stdout.writeln('配置项:'); + for (final key in meta.configKeys) { + stdout.writeln(' - $key'); + } + } + } + + static String _ruleIds() => + RuleRegistry.all().map((meta) => meta.id).join(', '); +} diff --git a/packages/flutterguard_cli/lib/src/cli/scan_command.dart b/packages/flutterguard_cli/lib/src/cli/scan_command.dart new file mode 100644 index 0000000..620e792 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/cli/scan_command.dart @@ -0,0 +1,99 @@ +import 'dart:io'; + +import 'package:args/args.dart'; + +import '../report_generator.dart'; +import '../scanner.dart'; + +class ScanCommand { + static void run(ArgResults args, {String? configPath}) { + 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 minScore = _parseMinScore(args['min-score'] as String?); + + late final ScanResult result; + try { + result = FlutterGuardScanner.scan( + projectPath: args['path'] as String, + configPath: configPath, + outputDir: args['output'] as String, + writeJson: format == 'json', + writeSarif: format == 'sarif', + changedOnly: args['changed-only'] as bool, + base: args['base'] as String, + baselinePath: args['baseline'] as String?, + ); + } on ScanException catch (error) { + stderr.writeln('Error: ${error.message}'); + exit(2); + } on FormatException catch (error) { + stderr.writeln('Error: ${error.message}'); + exit(2); + } + + if (verbose && result.diagnostics.isNotEmpty) { + stderr.writeln('Scan diagnostics (${result.diagnostics.length}):'); + for (final diagnostic in result.diagnostics) { + final location = diagnostic.file == null ? '' : ' ${diagnostic.file}'; + stderr.writeln( + ' ${diagnostic.severity.name} [${diagnostic.stage}]$location: ' + '${diagnostic.message}', + ); + } + } + + if (result.files.isEmpty && result.issues.isEmpty) { + stdout.writeln('No changed Dart files matched the scan configuration.'); + if (format == 'sarif') { + stdout.writeln('SARIF report: ${result.reportDir}/report.sarif'); + } + return; + } + + if (format == 'sarif') { + stdout.writeln( + 'FlutterGuard scanned ${result.files.length} files, found ' + '${result.issues.length} visible issues ' + '(${result.suppressedCount} suppressed, ' + '${result.suppressedByBaselineCount} baseline).', + ); + stdout.writeln('SARIF report: ${result.reportDir}/report.sarif'); + } else { + final output = ReportGenerator.generateStdout( + projectPath: result.projectPath, + issues: result.issues, + scannedFileCount: result.files.length, + verbose: verbose, + noColor: noColor, + ); + stdout.writeln(output); + } + + if (failOn != 'none' && ReportGenerator.shouldFail(result.issues, failOn)) { + stderr.writeln( + 'CI gate failed: Issues found at or above "$failOn" level.', + ); + exit(1); + } + + if (minScore != null && result.score < minScore) { + stderr.writeln( + 'CI gate failed: Score ${result.score} is below minimum $minScore.', + ); + exit(1); + } + } + + static int? _parseMinScore(String? value) { + if (value == null) return null; + final score = int.tryParse(value); + if (score == null || score < 0 || score > 100) { + throw const FormatException( + 'Expected --min-score to be an integer 0-100.', + ); + } + return score; + } +} diff --git a/packages/flutterguard_cli/lib/src/config_loader.dart b/packages/flutterguard_cli/lib/src/config_loader.dart index b0674e6..3d35fd1 100644 --- a/packages/flutterguard_cli/lib/src/config_loader.dart +++ b/packages/flutterguard_cli/lib/src/config_loader.dart @@ -92,9 +92,15 @@ class ScanConfig { 'module_violation', }; - factory ScanConfig.fromFile(String path) { + factory ScanConfig.fromFile( + String path, { + bool requireFile = false, + }) { final file = File(path); if (!file.existsSync()) { + if (requireFile) { + throw FormatException('Config file "$path" does not exist.'); + } return _defaultConfig(); } @@ -232,12 +238,8 @@ class ScanConfig { missingConstConstructor: ( enabled: _boolValue(missingConstConstructor, 'enabled', true), ), - deviceLifecycle: ( - enabled: _boolValue(deviceLifecycle, 'enabled', true), - ), - mqttConnection: ( - enabled: _boolValue(mqttConnection, '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), @@ -246,9 +248,7 @@ class ScanConfig { enabled: _boolValue(iotSecurity, 'enabled', true), requireTls: _boolValue(iotSecurity, 'requireTls', true), ), - pubspecSecurity: ( - enabled: _boolValue(pubspecSecurity, 'enabled', true), - ), + pubspecSecurity: (enabled: _boolValue(pubspecSecurity, 'enabled', true),), ); } diff --git a/packages/flutterguard_cli/lib/src/config_tools.dart b/packages/flutterguard_cli/lib/src/config_tools.dart new file mode 100644 index 0000000..b0db822 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/config_tools.dart @@ -0,0 +1,666 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'config_loader.dart'; +import 'file_collector.dart'; +import 'path_utils.dart'; +import 'project_resolver.dart'; + +enum DoctorSeverity { info, warning, error } + +class DoctorMessage { + final DoctorSeverity severity; + final String message; + + const DoctorMessage(this.severity, this.message); +} + +class DoctorResult { + final String projectPath; + final String configPath; + final bool configExists; + final int fileCount; + final List messages; + + const DoctorResult({ + required this.projectPath, + required this.configPath, + required this.configExists, + required this.fileCount, + required this.messages, + }); + + bool get hasErrors => + messages.any((message) => message.severity == DoctorSeverity.error); +} + +class ConfigTools { + static const profiles = { + 'recommended', + 'strict', + 'migration', + 'iot-security', + 'architecture-only', + 'performance-only', + }; + + static const minimalConfig = ''' +include: + - lib/** + +exclude: + - lib/generated/** + - lib/**.g.dart + - lib/**.freezed.dart + - lib/**.mocks.dart + +rules: + large_file: + enabled: true + maxLines: 500 + large_class: + enabled: true + maxLines: 300 + large_build_method: + enabled: true + maxLines: 80 + lifecycle_resource: + enabled: true + missing_const_constructor: + enabled: true + device_lifecycle: + enabled: true + mqtt_connection: + enabled: true + ble_scanning: + enabled: true + maxScanDurationMs: 10000 + iot_security: + enabled: true + requireTls: true + pubspec_security: + enabled: true +'''; + + static const architectureBlock = ''' + +architecture: + layers: + - name: presentation + path: lib/presentation/** + allowed_deps: [domain, core] + - name: domain + path: lib/domain/** + allowed_deps: [core] + - name: data + path: lib/data/** + allowed_deps: [domain, core] + - name: core + path: lib/core/** + allowed_deps: [] + + modules: + - name: mqtt_feature + path: lib/features/mqtt/** + allowed_deps: [shared] + - name: ble_feature + path: lib/features/ble/** + allowed_deps: [shared] + - name: shared + path: lib/shared/** + allowed_deps: [] + + detect_cycles: true + layer_violation: + enabled: true + module_violation: + enabled: true +'''; + + static String initTemplate({ + required bool withArchitecture, + String profile = 'recommended', + }) { + if (!profiles.contains(profile)) { + throw FormatException( + 'Unknown profile "$profile". Allowed: ${profiles.join(", ")}.', + ); + } + + final config = switch (profile) { + 'strict' => _strictConfig, + 'migration' => _migrationConfig, + 'iot-security' => _iotSecurityConfig, + 'architecture-only' => _architectureOnlyConfig, + 'performance-only' => _performanceOnlyConfig, + _ => minimalConfig, + }; + return withArchitecture ? '$config$architectureBlock' : config; + } + + static String writeInitConfig({ + required String projectPath, + required String configPath, + required bool withArchitecture, + required bool force, + String profile = 'recommended', + }) { + final resolvedProjectPath = ProjectResolver.resolveProjectPath(projectPath); + final outputPath = p.isAbsolute(configPath) + ? configPath + : p.join(resolvedProjectPath, configPath); + final file = File(outputPath); + if (file.existsSync() && !force) { + throw StateError( + 'Config file already exists at "$outputPath". Use --force to overwrite.', + ); + } + file.parent.createSync(recursive: true); + file.writeAsStringSync(initTemplate( + withArchitecture: withArchitecture, + profile: profile, + )); + return outputPath; + } + + static const _strictConfig = ''' +include: + - lib/** + +exclude: + - lib/generated/** + - lib/**.g.dart + - lib/**.freezed.dart + - lib/**.mocks.dart + +rules: + large_file: + enabled: true + maxLines: 400 + large_class: + enabled: true + maxLines: 240 + large_build_method: + enabled: true + maxLines: 60 + lifecycle_resource: + enabled: true + missing_const_constructor: + enabled: true + device_lifecycle: + enabled: true + mqtt_connection: + enabled: true + ble_scanning: + enabled: true + maxScanDurationMs: 10000 + iot_security: + enabled: true + requireTls: true + pubspec_security: + enabled: true +'''; + + static const _migrationConfig = ''' +include: + - lib/** + +exclude: + - lib/generated/** + - lib/**.g.dart + - lib/**.freezed.dart + - lib/**.mocks.dart + - test/** + - example/** + +rules: + large_file: + enabled: true + maxLines: 800 + large_class: + enabled: true + maxLines: 500 + large_build_method: + enabled: true + maxLines: 120 + lifecycle_resource: + enabled: true + missing_const_constructor: + enabled: false + device_lifecycle: + enabled: true + mqtt_connection: + enabled: true + ble_scanning: + enabled: true + maxScanDurationMs: 15000 + iot_security: + enabled: true + requireTls: true + pubspec_security: + enabled: true +'''; + + static const _iotSecurityConfig = ''' +include: + - lib/** + +exclude: + - lib/generated/** + - lib/**.g.dart + - lib/**.freezed.dart + - lib/**.mocks.dart + - test/** + - example/** + +rules: + large_file: + enabled: false + maxLines: 500 + large_class: + enabled: false + maxLines: 300 + large_build_method: + enabled: false + maxLines: 80 + lifecycle_resource: + enabled: true + missing_const_constructor: + enabled: false + device_lifecycle: + enabled: true + mqtt_connection: + enabled: true + ble_scanning: + enabled: true + maxScanDurationMs: 10000 + iot_security: + enabled: true + requireTls: true + pubspec_security: + enabled: true +'''; + + static const _architectureOnlyConfig = ''' +include: + - lib/** + +exclude: + - lib/generated/** + - lib/**.g.dart + - lib/**.freezed.dart + - lib/**.mocks.dart + +rules: + large_file: + enabled: false + maxLines: 500 + large_class: + enabled: false + maxLines: 300 + large_build_method: + enabled: false + maxLines: 80 + lifecycle_resource: + enabled: false + missing_const_constructor: + enabled: false + device_lifecycle: + enabled: false + mqtt_connection: + enabled: false + ble_scanning: + enabled: false + maxScanDurationMs: 10000 + iot_security: + enabled: false + requireTls: true + pubspec_security: + enabled: false +'''; + + static const _performanceOnlyConfig = ''' +include: + - lib/** + +exclude: + - lib/generated/** + - lib/**.g.dart + - lib/**.freezed.dart + - lib/**.mocks.dart + +rules: + large_file: + enabled: true + maxLines: 500 + large_class: + enabled: true + maxLines: 300 + large_build_method: + enabled: true + maxLines: 80 + lifecycle_resource: + enabled: true + missing_const_constructor: + enabled: false + device_lifecycle: + enabled: false + mqtt_connection: + enabled: false + ble_scanning: + enabled: false + maxScanDurationMs: 10000 + iot_security: + enabled: false + requireTls: true + pubspec_security: + enabled: false +'''; + + static String resolveConfigPathForProject({ + required String projectPath, + String? configPath, + }) { + return ProjectResolver.resolveConfigPath( + projectPath: projectPath, + explicitConfig: configPath, + ); + } + + static String effectiveYaml(ScanConfig config) { + final buffer = StringBuffer() + ..writeln('include:') + ..write(_stringList(config.include, indent: 2)) + ..writeln() + ..writeln('exclude:') + ..write(_stringList(config.exclude, indent: 2)) + ..writeln() + ..writeln('rules:') + ..write(_ruleWithMaxLines('large_file', config.rules.largeFile)) + ..write(_ruleWithMaxLines('large_class', config.rules.largeClass)) + ..write(_ruleWithMaxLines( + 'large_build_method', + config.rules.largeBuildMethod, + )) + ..write(_enabledRule( + 'lifecycle_resource', + config.rules.lifecycleResource.enabled, + )) + ..write(_enabledRule( + 'missing_const_constructor', + config.rules.missingConstConstructor.enabled, + )) + ..write(_enabledRule( + 'device_lifecycle', + config.rules.deviceLifecycle.enabled, + )) + ..write(_enabledRule( + 'mqtt_connection', + config.rules.mqttConnection.enabled, + )) + ..writeln(' ble_scanning:') + ..writeln(' enabled: ${config.rules.bleScanning.enabled}') + ..writeln( + ' maxScanDurationMs: ${config.rules.bleScanning.maxScanDurationMs}', + ) + ..writeln(' iot_security:') + ..writeln(' enabled: ${config.rules.iotSecurity.enabled}') + ..writeln(' requireTls: ${config.rules.iotSecurity.requireTls}') + ..write(_enabledRule( + 'pubspec_security', + config.rules.pubspecSecurity.enabled, + )) + ..writeln() + ..writeln('architecture:') + ..write(_boundaryList('layers', config.architecture.layers)) + ..write(_boundaryList('modules', config.architecture.modules)) + ..writeln(' detect_cycles: ${config.architecture.detectCycles}') + ..writeln(' layer_violation:') + ..writeln(' enabled: ${config.architecture.layerViolationEnabled}') + ..writeln(' module_violation:') + ..writeln(' enabled: ${config.architecture.moduleViolationEnabled}'); + + return buffer.toString(); + } + + static DoctorResult doctor({ + required String projectPath, + String? configPath, + }) { + final resolvedProjectPath = ProjectResolver.resolveProjectPath(projectPath); + if (!Directory(resolvedProjectPath).existsSync()) { + throw StateError('Project path "$resolvedProjectPath" does not exist.'); + } + + final resolvedConfigPath = resolveConfigPathForProject( + projectPath: resolvedProjectPath, + configPath: configPath, + ); + final configExists = File(resolvedConfigPath).existsSync(); + final config = ScanConfig.fromFile( + resolvedConfigPath, + requireFile: configPath != null, + ); + final files = FileCollector.collect(resolvedProjectPath, config); + + final messages = []; + if (!configExists) { + messages.add(DoctorMessage( + DoctorSeverity.info, + 'No config file found. Built-in defaults are being used.', + )); + } + if (files.isEmpty) { + messages.add(DoctorMessage( + DoctorSeverity.error, + 'No Dart files matched include/exclude patterns.', + )); + } + + _checkBackslashes(config, messages); + _checkBoundaries( + kind: 'layer', + boundaries: config.architecture.layers, + enabled: config.architecture.layerViolationEnabled, + files: files, + projectPath: resolvedProjectPath, + messages: messages, + ); + _checkBoundaries( + kind: 'module', + boundaries: config.architecture.modules, + enabled: config.architecture.moduleViolationEnabled, + files: files, + projectPath: resolvedProjectPath, + messages: messages, + ); + + if (config.architecture.detectCycles && files.isEmpty) { + messages.add(DoctorMessage( + DoctorSeverity.warning, + 'Circular dependency detection is enabled, but no files matched.', + )); + } + + return DoctorResult( + projectPath: resolvedProjectPath, + configPath: resolvedConfigPath, + configExists: configExists, + fileCount: files.length, + messages: messages, + ); + } + + static String formatDoctorResult(DoctorResult result) { + final buffer = StringBuffer() + ..writeln('FlutterGuard config doctor') + ..writeln('Project: ${result.projectPath}') + ..writeln('Config: ${result.configPath}') + ..writeln('Config exists: ${result.configExists}') + ..writeln('Matched Dart files: ${result.fileCount}'); + + if (result.messages.isEmpty) { + buffer.writeln('Status: OK'); + return buffer.toString(); + } + + buffer.writeln(); + for (final message in result.messages) { + buffer.writeln( + '${_severityLabel(message.severity)}: ${message.message}', + ); + } + return buffer.toString(); + } + + static String _stringList(List values, {required int indent}) { + final spaces = ' ' * indent; + return values.map((value) => '$spaces- $value\n').join(); + } + + static String _ruleWithMaxLines( + String name, + ({bool enabled, int maxLines}) config, + ) { + return ''' + $name: + enabled: ${config.enabled} + maxLines: ${config.maxLines} +'''; + } + + static String _enabledRule(String name, bool enabled) { + return ''' + $name: + enabled: $enabled +'''; + } + + static String _boundaryList( + String key, + List<({String name, String path, List allowedDeps})> boundaries, + ) { + if (boundaries.isEmpty) return ' $key: []\n'; + final buffer = StringBuffer(); + buffer.writeln(' $key:'); + for (final boundary in boundaries) { + buffer + ..writeln(' - name: ${boundary.name}') + ..writeln(' path: ${boundary.path}') + ..writeln(' allowed_deps: [${boundary.allowedDeps.join(', ')}]'); + } + return buffer.toString(); + } + + static void _checkBackslashes( + ScanConfig config, + List messages, + ) { + for (final pattern in [...config.include, ...config.exclude]) { + if (pattern.contains(r'\')) { + messages.add(DoctorMessage( + DoctorSeverity.warning, + 'Pattern "$pattern" contains backslashes. Use forward slashes in YAML.', + )); + } + } + for (final boundary in [ + ...config.architecture.layers, + ...config.architecture.modules, + ]) { + if (boundary.path.contains(r'\')) { + messages.add(DoctorMessage( + DoctorSeverity.warning, + 'Architecture path "${boundary.path}" contains backslashes. Use forward slashes in YAML.', + )); + } + } + } + + static void _checkBoundaries({ + required String kind, + required List<({String name, String path, List allowedDeps})> + boundaries, + required bool enabled, + required List files, + required String projectPath, + required List messages, + }) { + if (enabled && boundaries.isEmpty) { + messages.add(DoctorMessage( + DoctorSeverity.info, + 'No architecture ${kind}s declared. ${kind}_violation will not report boundary issues.', + )); + return; + } + + final names = boundaries.map((boundary) => boundary.name).toSet(); + if (names.length != boundaries.length) { + messages.add(DoctorMessage( + DoctorSeverity.error, + 'Duplicate architecture $kind names found.', + )); + } + + for (final boundary in boundaries) { + for (final dep in boundary.allowedDeps) { + if (!names.contains(dep)) { + messages.add(DoctorMessage( + DoctorSeverity.error, + 'Architecture $kind "${boundary.name}" allows unknown dependency "$dep".', + )); + } + } + } + + final matchesByName = >{}; + for (final boundary in boundaries) { + final matches = files + .where((file) => _matchesBoundary(file, boundary.path, projectPath)) + .toSet(); + matchesByName[boundary.name] = matches; + if (matches.isEmpty) { + messages.add(DoctorMessage( + DoctorSeverity.warning, + 'Architecture $kind "${boundary.name}" path "${boundary.path}" matched no files.', + )); + } + } + + for (var i = 0; i < boundaries.length; i++) { + for (var j = i + 1; j < boundaries.length; j++) { + final left = boundaries[i]; + final right = boundaries[j]; + final overlap = matchesByName[left.name]!.intersection( + matchesByName[right.name]!, + ); + if (overlap.isNotEmpty) { + messages.add(DoctorMessage( + DoctorSeverity.warning, + 'Architecture $kind paths "${left.name}" and "${right.name}" overlap on ${overlap.length} file(s).', + )); + } + } + } + } + + static bool _matchesBoundary( + String file, + String pattern, + String projectPath, + ) { + return matchesProjectGlob(file, pattern, projectPath); + } + + static String _severityLabel(DoctorSeverity severity) { + switch (severity) { + case DoctorSeverity.info: + return 'INFO'; + case DoctorSeverity.warning: + return 'WARN'; + case DoctorSeverity.error: + return 'ERROR'; + } + } +} diff --git a/packages/flutterguard_cli/lib/src/file_collector.dart b/packages/flutterguard_cli/lib/src/file_collector.dart index 41b35fd..bbf439a 100644 --- a/packages/flutterguard_cli/lib/src/file_collector.dart +++ b/packages/flutterguard_cli/lib/src/file_collector.dart @@ -1,11 +1,22 @@ +import 'dart:convert'; import 'dart:io'; import 'package:glob/glob.dart'; import 'package:glob/list_local_fs.dart'; +import 'package:path/path.dart' as p; import 'config_loader.dart'; import 'path_utils.dart'; +class ChangedFilesException implements Exception { + final String message; + + const ChangedFilesException(this.message); + + @override + String toString() => message; +} + class FileCollector { static List collect(String projectPath, ScanConfig config) { final context = projectPathContext(projectPath); @@ -30,4 +41,109 @@ class FileCollector { return allFiles.toList()..sort(); } + + static Set? getChangedFiles(String projectPath, String base) { + try { + final rootResult = Process.runSync( + 'git', + ['rev-parse', '--show-toplevel'], + workingDirectory: projectPath, + stdoutEncoding: utf8, + ); + if (rootResult.exitCode != 0) return null; + + final gitRoot = (rootResult.stdout as String).trim(); + final canonicalGitRoot = Directory(gitRoot).resolveSymbolicLinksSync(); + final canonicalProjectPath = + Directory(projectPath).resolveSymbolicLinksSync(); + if (base.startsWith('-')) { + throw ChangedFilesException('Invalid Git base "$base".'); + } + final refResult = Process.runSync( + 'git', + ['rev-parse', '--verify', '$base^{commit}'], + workingDirectory: gitRoot, + stdoutEncoding: utf8, + ); + if (refResult.exitCode != 0) { + throw ChangedFilesException( + _gitFailureMessage('Invalid Git base "$base"', refResult.stderr), + ); + } + final verifiedRef = (refResult.stdout as String).trim(); + final result = Process.runSync( + 'git', + [ + 'diff', + '--name-only', + '--diff-filter=ACMR', + '-z', + verifiedRef, + '--', + ], + workingDirectory: gitRoot, + stdoutEncoding: utf8, + ); + if (result.exitCode != 0) { + throw ChangedFilesException( + _gitFailureMessage('git diff', result.stderr), + ); + } + final untracked = Process.runSync( + 'git', + ['ls-files', '--others', '--exclude-standard', '-z'], + workingDirectory: gitRoot, + stdoutEncoding: utf8, + ); + if (untracked.exitCode != 0) { + throw ChangedFilesException( + _gitFailureMessage('git ls-files', untracked.stderr), + ); + } + + final changed = {}; + for (final path in (result.stdout as String).split('\x00')) { + if (path.isNotEmpty) { + changed.add(_projectAnchoredGitPath( + projectPath: projectPath, + canonicalProjectPath: canonicalProjectPath, + canonicalGitRoot: canonicalGitRoot, + gitRelativePath: path, + )); + } + } + for (final path in (untracked.stdout as String).split('\x00')) { + if (path.isNotEmpty) { + changed.add(_projectAnchoredGitPath( + projectPath: projectPath, + canonicalProjectPath: canonicalProjectPath, + canonicalGitRoot: canonicalGitRoot, + gitRelativePath: path, + )); + } + } + return changed; + } on ProcessException { + return null; + } + } + + static String _gitFailureMessage(String command, Object stderr) { + final detail = stderr.toString().trim(); + return detail.isEmpty ? '$command failed.' : '$command failed: $detail'; + } + + static String _projectAnchoredGitPath({ + required String projectPath, + required String canonicalProjectPath, + required String canonicalGitRoot, + required String gitRelativePath, + }) { + final canonicalPath = p.join(canonicalGitRoot, gitRelativePath); + final projectRelativePath = p.relative( + canonicalPath, + from: canonicalProjectPath, + ); + return p.normalize(p.join(projectPath, projectRelativePath)); + } } diff --git a/packages/flutterguard_cli/lib/src/import_graph.dart b/packages/flutterguard_cli/lib/src/import_graph.dart new file mode 100644 index 0000000..640a374 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/import_graph.dart @@ -0,0 +1,74 @@ +import 'package:analyzer/dart/ast/ast.dart'; + +import 'import_utils.dart'; +import 'path_utils.dart'; +import 'source_utils.dart'; +import 'source_workspace.dart'; + +class ImportEdge { + final String source; + final String target; + final String uri; + final int line; + + const ImportEdge({ + required this.source, + required this.target, + required this.uri, + required this.line, + }); +} + +class ImportGraph { + final Set files; + final Map> _outgoing; + + const ImportGraph._(this.files, this._outgoing); + + factory ImportGraph.build({ + required Iterable files, + required Iterable sourceFiles, + required SourceWorkspace workspace, + String? projectPath, + }) { + final fileSet = {for (final file in files) normalizePath(file)}; + final outgoing = >{}; + + for (final sourcePath in sourceFiles.map(normalizePath)) { + final source = workspace.source(sourcePath); + if (source == null) { + outgoing[sourcePath] = const []; + continue; + } + + final edges = []; + for (final directive + in source.unit.directives.whereType()) { + final uri = directive.uri.stringValue; + if (uri == null) continue; + final target = resolveImport( + sourcePath, + uri, + fileSet, + projectPath: projectPath, + ); + if (target == null || target == sourcePath) continue; + edges.add(ImportEdge( + source: sourcePath, + target: target, + uri: uri, + line: lineNumberForOffset(source.lineInfo, directive.uri.offset), + )); + } + outgoing[sourcePath] = edges; + } + + return ImportGraph._(fileSet, outgoing); + } + + List outgoing(String source) => + _outgoing[normalizePath(source)] ?? const []; + + Set dependenciesOf(String source) => + outgoing(source).map((edge) => edge.target).toSet(); +} diff --git a/packages/flutterguard_cli/lib/src/install_doctor.dart b/packages/flutterguard_cli/lib/src/install_doctor.dart new file mode 100644 index 0000000..1739121 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/install_doctor.dart @@ -0,0 +1,62 @@ +import 'dart:io'; + +class InstallDoctor { + static String generate({required String version}) { + final buffer = StringBuffer() + ..writeln('FlutterGuard install doctor') + ..writeln('Version: $version') + ..writeln('Dart executable: ${Platform.resolvedExecutable}') + ..writeln('Entrypoint: ${Platform.script}') + ..writeln('Operating system: ${Platform.operatingSystem}') + ..writeln('PATH entries named flutterguard:'); + + final matches = _findFlutterGuardCommands(); + if (matches.isEmpty) { + buffer.writeln(' none found'); + } else { + for (final match in matches) { + buffer.writeln(' $match'); + } + } + + buffer + ..writeln() + ..writeln('Recommended checks:') + ..writeln(' flutterguard --version') + ..writeln(' flutterguard --help') + ..writeln() + ..writeln( + 'If the version is stale, reinstall or use the source launcher:') + ..writeln(' dart pub global activate flutterguard_cli') + ..writeln(' ./scripts/flutterguard-dev --version'); + + return buffer.toString(); + } + + static List _findFlutterGuardCommands() { + final path = Platform.environment['PATH']; + if (path == null || path.isEmpty) return const []; + + final names = Platform.isWindows + ? const ['flutterguard.exe', 'flutterguard.bat', 'flutterguard.cmd'] + : const ['flutterguard']; + final matches = []; + + for (final dir in path.split(Platform.isWindows ? ';' : ':')) { + if (dir.isEmpty) continue; + for (final name in names) { + final candidate = File(_joinPath(dir, name)); + if (candidate.existsSync()) { + matches.add(candidate.path); + } + } + } + return matches; + } + + static String _joinPath(String left, String right) { + final separator = Platform.isWindows ? r'\' : '/'; + if (left.endsWith(separator)) return '$left$right'; + return '$left$separator$right'; + } +} diff --git a/packages/flutterguard_cli/lib/src/issue_export.dart b/packages/flutterguard_cli/lib/src/issue_export.dart new file mode 100644 index 0000000..643e445 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/issue_export.dart @@ -0,0 +1,113 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'baseline.dart'; +import 'rules/registry.dart'; +import 'source_workspace.dart'; +import 'static_issue.dart'; + +class IssueExporter { + static String export({ + required String projectPath, + required List issues, + String? ruleId, + String? filePath, + int? line, + int contextLines = 2, + SourceWorkspace? workspace, + }) { + final issue = _findIssue( + projectPath: projectPath, + issues: issues, + ruleId: ruleId, + filePath: filePath, + line: line, + ); + if (issue == null) { + throw const FormatException('No matching issue found to export.'); + } + + final relativePath = _relativePath(issue.file, projectPath); + final meta = RuleRegistry.find(issue.id); + final payload = { + 'version': '1.0.0', + 'generatedAt': DateTime.now().toIso8601String(), + 'projectPath': projectPath, + 'fingerprint': Baseline.fingerprint(issue, projectPath), + 'issue': issue.toJson()..['file'] = relativePath, + 'rule': meta?.toJson(), + 'context': _context(issue, contextLines, workspace: workspace), + 'feedbackTemplate': { + 'whyFalsePositiveOrFalseNegative': '', + 'expectedBehavior': '', + 'sanitizedSnippetConfirmed': false, + }, + }; + + return const JsonEncoder.withIndent(' ').convert(payload); + } + + static StaticIssue? _findIssue({ + required String projectPath, + required List issues, + String? ruleId, + String? filePath, + int? line, + }) { + final normalizedFile = filePath == null + ? null + : p.normalize( + p.isAbsolute(filePath) ? filePath : p.join(projectPath, filePath)); + + for (final issue in issues) { + if (ruleId != null && issue.id != ruleId) continue; + if (line != null && issue.line != line) continue; + if (normalizedFile != null && p.normalize(issue.file) != normalizedFile) { + continue; + } + return issue; + } + return null; + } + + static Map _context( + StaticIssue issue, + int contextLines, { + SourceWorkspace? workspace, + }) { + final line = issue.line; + final file = File(issue.file); + if (line == null || !file.existsSync()) { + return { + 'available': false, + 'lines': const [], + }; + } + + final lines = + workspace?.source(issue.file)?.lines ?? file.readAsLinesSync(); + final start = (line - contextLines).clamp(1, lines.length); + final end = (line + contextLines).clamp(1, lines.length); + return { + 'available': true, + 'startLine': start, + 'endLine': end, + 'lines': [ + for (var i = start; i <= end; i++) + { + 'line': i, + 'text': lines[i - 1], + } + ], + }; + } + + static String _relativePath(String filePath, String projectPath) { + if (p.isWithin(projectPath, filePath) || p.equals(projectPath, filePath)) { + return p.relative(filePath, from: projectPath).replaceAll('\\', '/'); + } + return filePath.replaceAll('\\', '/'); + } +} diff --git a/packages/flutterguard_cli/lib/src/project_resolver.dart b/packages/flutterguard_cli/lib/src/project_resolver.dart index fa75cdd..3b0388c 100644 --- a/packages/flutterguard_cli/lib/src/project_resolver.dart +++ b/packages/flutterguard_cli/lib/src/project_resolver.dart @@ -3,8 +3,10 @@ import 'dart:io'; import 'package:path/path.dart' as p; class ProjectResolver { + static const defaultConfigPath = 'flutterguard.yaml'; + static const _discoveryMarkers = [ - 'flutterguard.yaml', + defaultConfigPath, 'pubspec.yaml', ]; @@ -18,16 +20,18 @@ class ProjectResolver { static String resolveConfigPath({ required String projectPath, - required String explicitConfig, + required String? explicitConfig, }) { - if (p.isAbsolute(explicitConfig)) { - return explicitConfig; + final configPath = explicitConfig ?? defaultConfigPath; + final resolvedPath = p.isAbsolute(configPath) + ? p.normalize(configPath) + : p.normalize(p.join(projectPath, configPath)); + + if (explicitConfig != null && !File(resolvedPath).existsSync()) { + throw FormatException('Config file "$resolvedPath" does not exist.'); } - 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; + + return resolvedPath; } static String? _walkUpFind(String startPath) { diff --git a/packages/flutterguard_cli/lib/src/report_generator.dart b/packages/flutterguard_cli/lib/src/report_generator.dart index fc6bda1..06d6b79 100644 --- a/packages/flutterguard_cli/lib/src/report_generator.dart +++ b/packages/flutterguard_cli/lib/src/report_generator.dart @@ -4,6 +4,7 @@ import 'package:path/path.dart' as p; import 'domain.dart'; import 'priority.dart'; +import 'source_workspace.dart'; import 'static_issue.dart'; class _Ansi { @@ -57,6 +58,10 @@ class ReportGenerator { static String generateJson({ required String projectPath, required List issues, + String scanMode = 'full', + int suppressedCount = 0, + int suppressedByBaselineCount = 0, + List diagnostics = const [], }) { final byDomain = _buildSummaryByDomain(issues); final score = calculateScore(issues); @@ -65,15 +70,21 @@ class ReportGenerator { 'version': '1.0.0', 'generatedAt': DateTime.now().toIso8601String(), 'projectPath': projectPath, + 'scanMode': scanMode, 'score': score, 'summary': { 'total': issues.length, 'high': issues.where((i) => i.level == RiskLevel.high).length, 'medium': issues.where((i) => i.level == RiskLevel.medium).length, 'low': issues.where((i) => i.level == RiskLevel.low).length, + 'suppressed': suppressedCount, + 'suppressedByBaseline': suppressedByBaselineCount, + 'diagnostics': diagnostics.length, 'byDomain': byDomain, }, 'issues': issues.map((i) => i.toJson()).toList(), + 'diagnostics': + diagnostics.map((diagnostic) => diagnostic.toJson()).toList(), }; return const JsonEncoder.withIndent(' ').convert(payload); @@ -100,7 +111,9 @@ class ReportGenerator { ); if (issues.isEmpty) { - final msg = noColor ? '未发现问题,代码质量良好。' : '${_Ansi.green}未发现问题,代码质量良好。${_Ansi.reset}'; + final msg = noColor + ? '未发现问题,代码质量良好。' + : '${_Ansi.green}未发现问题,代码质量良好。${_Ansi.reset}'; buf.writeln(' $msg'); return buf.toString(); } @@ -141,13 +154,12 @@ class ReportGenerator { ? '需关注' : '需整改'; buf.writeln( - ' ${bold}FlutterGuard Report$reset ${gray}─$reset $projectName'); + ' ${bold}FlutterGuard Report$reset $gray─$reset $projectName'); buf.writeln( '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - buf.write( - ' 总评分: $scoreAnsi$score/100$reset $scoreAnsi$scoreLabel$reset'); + buf.write(' 总评分: $scoreAnsi$score/100$reset $scoreAnsi$scoreLabel$reset'); buf.writeln( - ' ${bold}扫描文件: $scannedFileCount$reset 问题总数: ${bold}${issues.length}$reset'); + ' $bold扫描文件: $scannedFileCount$reset 问题总数: $bold${issues.length}$reset'); buf.writeln( '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); buf.writeln(); @@ -174,7 +186,7 @@ class ReportGenerator { buf.write(' $domainAnsi${_domainLabels[domain]}$reset'); buf.write(' $count items '); - buf.write('${gray}▰$reset '); + buf.write('$gray▰$reset '); buf.writeln('$levelAnsi$levelLabel$reset'); } buf.writeln( @@ -213,19 +225,18 @@ class ReportGenerator { final displayPath = _displayPath(issue.file, projectPath); final lineInfo = issue.line != null ? ':${issue.line}' : ''; - buf.writeln( - ' $lvlAnsi$lvlLabel$reset $priAnsi$priLabel$reset'); - buf.writeln(' ${bold}${issue.title}$reset'); - buf.writeln(' ${gray}$displayPath$lineInfo$reset'); + buf.writeln(' $lvlAnsi$lvlLabel$reset $priAnsi$priLabel$reset'); + buf.writeln(' $bold${issue.title}$reset'); + buf.writeln(' $gray$displayPath$lineInfo$reset'); buf.writeln(' ${issue.message}'); if (verbose && issue.detail.isNotEmpty) { buf.writeln(); for (final line in issue.detail.split('\n')) { - buf.writeln(' ${dim}$line$reset'); + buf.writeln(' $dim$line$reset'); } } - buf.writeln(' 修复: ${green}${issue.suggestion}$reset'); + buf.writeln(' 修复: $green${issue.suggestion}$reset'); buf.writeln(); } buf.writeln( diff --git a/packages/flutterguard_cli/lib/src/rule_meta.dart b/packages/flutterguard_cli/lib/src/rule_meta.dart new file mode 100644 index 0000000..d8c94cf --- /dev/null +++ b/packages/flutterguard_cli/lib/src/rule_meta.dart @@ -0,0 +1,41 @@ +class RuleMeta { + final String id; + final String name; + final String domain; + final String riskLevel; + final String priority; + final String purpose; + final String riskReason; + final String badExample; + final String fixSuggestion; + final List configKeys; + final bool cicdSafe; + + const RuleMeta({ + required this.id, + required this.name, + required this.domain, + required this.riskLevel, + required this.priority, + required this.purpose, + required this.riskReason, + required this.badExample, + required this.fixSuggestion, + this.configKeys = const [], + this.cicdSafe = true, + }); + + Map toJson() => { + 'id': id, + 'name': name, + 'domain': domain, + 'riskLevel': riskLevel, + 'priority': priority, + 'purpose': purpose, + 'riskReason': riskReason, + 'badExample': badExample, + 'fixSuggestion': fixSuggestion, + 'configKeys': configKeys, + 'cicdSafe': cicdSafe, + }; +} diff --git a/packages/flutterguard_cli/lib/src/rules/AGENTS.md b/packages/flutterguard_cli/lib/src/rules/AGENTS.md index bea483c..96492b8 100644 --- a/packages/flutterguard_cli/lib/src/rules/AGENTS.md +++ b/packages/flutterguard_cli/lib/src/rules/AGENTS.md @@ -18,7 +18,8 @@ Each file implements one rule family and returns `List`. ## Rule Contract - Constructor receives typed config or explicit parameters. -- Public API is `analyze(List files)`. +- Public API is `analyze(List files, {SourceWorkspace? workspace})` for source rules; direct calls may omit the workspace. +- Scanner execution and metadata are explicitly wired in `catalog.dart`. - Return no issues when disabled or not configured. - Catch per-file parse/read failures so one bad file does not abort the full scan. - Use `StaticIssue` with domain, priority, suggestion, and metadata. @@ -31,5 +32,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:_analyze()`. +6. Wire the rule and metadata in `catalog.dart`. 7. Export through `lib/flutterguard_cli.dart` if needed. diff --git a/packages/flutterguard_cli/lib/src/rules/ble_scanning.dart b/packages/flutterguard_cli/lib/src/rules/ble_scanning.dart index d3b3904..47ce38d 100644 --- a/packages/flutterguard_cli/lib/src/rules/ble_scanning.dart +++ b/packages/flutterguard_cli/lib/src/rules/ble_scanning.dart @@ -1,13 +1,12 @@ -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 '../rule_meta.dart'; import '../source_utils.dart'; +import '../source_workspace.dart'; import '../static_issue.dart'; const _bleTypePatterns = ['Ble', 'Ble', 'BluetoothDevice', 'Bluetooth']; @@ -17,17 +16,21 @@ class BleScanningRule { const BleScanningRule(this.config); - List analyze(List files) { + List analyze( + List files, { + SourceWorkspace? workspace, + }) { if (!config.enabled) return []; final issues = []; + final sources = workspace ?? SourceWorkspace(); 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 (_) {} + final source = sources.source(file); + if (source == null) continue; + issues.addAll( + _checkFile(file, source.content, source.unit, source.lineInfo), + ); } return issues; @@ -42,28 +45,25 @@ class BleScanningRule { 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())); - }); + 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'); + 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', @@ -84,8 +84,10 @@ class BleScanningRule { )); } - if (methodNames.contains('connect') && !methodNames.contains('disconnect')) { - final connectMethod = methods.firstWhere((m) => m.name.lexeme == 'connect'); + 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', @@ -106,7 +108,8 @@ class BleScanningRule { )); } - _checkScanTimeout(file, startScanMethod: methods, lineInfo: lineInfo, issues: issues); + _checkScanTimeout(file, + startScanMethod: methods, lineInfo: lineInfo, issues: issues); } return issues; @@ -139,7 +142,8 @@ class BleScanningRule { message: 'startScan() 调用未配置超时参数', detail: '方法: ${method.name.lexeme}\n' 'BLE 扫描应设置超时以限制扫描时间,避免过度耗电', - suggestion: '为 startScan() 添加超时参数 (推荐 < ${config.maxScanDurationMs}ms)', + suggestion: + '为 startScan() 添加超时参数 (推荐 < ${config.maxScanDurationMs}ms)', metadata: { 'check': 'scan_without_timeout', 'maxScanDurationMs': config.maxScanDurationMs, @@ -148,4 +152,18 @@ class BleScanningRule { } } } + + static RuleMeta describe() => const RuleMeta( + id: 'ble_scanning', + name: 'BLE 扫描管理异常', + domain: 'architecture', + riskLevel: 'medium', + priority: 'p1', + purpose: '检测 BLE startScan/stopScan、connect/disconnect 配对及扫描超时配置', + riskReason: '未停止的 BLE 扫描持续消耗电量;缺少超时导致扫描无限进行', + badExample: 'startScan() 调用后无 stopScan() 和超时参数', + fixSuggestion: '在 dispose() 中添加 stopScan();为 startScan 添加超时参数', + configKeys: ['rules.ble_scanning.maxScanDurationMs'], + cicdSafe: true, + ); } diff --git a/packages/flutterguard_cli/lib/src/rules/catalog.dart b/packages/flutterguard_cli/lib/src/rules/catalog.dart new file mode 100644 index 0000000..dba54f7 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/rules/catalog.dart @@ -0,0 +1,172 @@ +import 'package:path/path.dart' as p; + +import '../import_graph.dart'; +import '../path_utils.dart'; +import '../rule_meta.dart'; +import '../scan_context.dart'; +import '../static_issue.dart'; +import 'ble_scanning.dart'; +import 'circular_dependency.dart'; +import 'device_lifecycle.dart'; +import 'iot_security.dart'; +import 'large_units.dart'; +import 'layer_violation.dart'; +import 'lifecycle_resource.dart'; +import 'missing_const_constructor.dart'; +import 'module_violation.dart'; +import 'mqtt_connection.dart'; +import 'pubspec_security.dart'; + +typedef RuleExecutor = List Function( + ScanContext context, + ImportGraph? importGraph, +); + +class RuleRegistration { + final List metadata; + final RuleExecutor execute; + + const RuleRegistration({ + required this.metadata, + required this.execute, + }); +} + +/// Explicit source of truth for rule metadata and execution wiring. +class RuleCatalog { + static final List registrations = List.unmodifiable([ + RuleRegistration( + metadata: [ + LargeUnitsRule.describeLargeFile(), + LargeUnitsRule.describeLargeClass(), + LargeUnitsRule.describeLargeBuildMethod(), + ], + execute: (context, _) => LargeUnitsRule( + largeFileConfig: context.config.rules.largeFile, + largeClassConfig: context.config.rules.largeClass, + largeBuildMethodConfig: context.config.rules.largeBuildMethod, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + metadata: [LifecycleResourceRule.describe()], + execute: (context, _) => LifecycleResourceRule( + context.config.rules.lifecycleResource, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + metadata: [LayerViolationRule.describe()], + execute: (context, importGraph) { + if (!context.config.architecture.layerViolationEnabled) return []; + return LayerViolationRule( + context.config.architecture.layers, + projectPath: context.projectPath, + ).analyze( + context.targetFiles, + allFiles: context.allFiles, + workspace: context.sources, + importGraph: importGraph, + ); + }, + ), + RuleRegistration( + metadata: [ModuleViolationRule.describe()], + execute: (context, importGraph) { + if (!context.config.architecture.moduleViolationEnabled) return []; + return ModuleViolationRule( + context.config.architecture.modules, + projectPath: context.projectPath, + ).analyze( + context.targetFiles, + allFiles: context.allFiles, + workspace: context.sources, + importGraph: importGraph, + ); + }, + ), + RuleRegistration( + metadata: [MissingConstConstructorRule.describe()], + execute: (context, _) => MissingConstConstructorRule( + context.config.rules.missingConstConstructor, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + metadata: [CircularDependencyRule.describe()], + execute: (context, importGraph) => CircularDependencyRule( + enabled: !context.isChanged && context.config.architecture.detectCycles, + projectPath: context.projectPath, + ).analyze( + context.targetFiles, + workspace: context.sources, + importGraph: importGraph, + ), + ), + RuleRegistration( + metadata: [DeviceLifecycleRule.describe()], + execute: (context, _) => DeviceLifecycleRule( + context.config.rules.deviceLifecycle, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + metadata: [MqttConnectionRule.describe()], + execute: (context, _) => MqttConnectionRule( + context.config.rules.mqttConnection, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + metadata: [BleScanningRule.describe()], + execute: (context, _) => BleScanningRule( + context.config.rules.bleScanning, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + metadata: [IotSecurityRule.describe()], + execute: (context, _) => IotSecurityRule( + context.config.rules.iotSecurity, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + metadata: [PubspecSecurityRule.describe()], + execute: (context, _) { + final pubspecPath = normalizePath( + p.join(context.projectPath, 'pubspec.yaml'), + ); + if (context.isChanged && !context.changedFiles.contains(pubspecPath)) { + return []; + } + return PubspecSecurityRule( + context.config.rules.pubspecSecurity, + ).analyze( + context.targetFiles, + projectPath: context.projectPath, + workspace: context.sources, + ); + }, + ), + ]); + + static List metadata() => [ + for (final registration in registrations) ...registration.metadata, + ]; + + static List analyze(ScanContext context) { + final architecture = context.config.architecture; + final needsImportGraph = + architecture.layerViolationEnabled && architecture.layers.isNotEmpty || + architecture.moduleViolationEnabled && + architecture.modules.isNotEmpty || + !context.isChanged && architecture.detectCycles; + final importGraph = needsImportGraph + ? ImportGraph.build( + files: context.allFiles, + sourceFiles: context.targetFiles, + workspace: context.sources, + projectPath: context.projectPath, + ) + : null; + + return [ + for (final registration in registrations) + ...registration.execute(context, importGraph), + ]; + } +} diff --git a/packages/flutterguard_cli/lib/src/rules/circular_dependency.dart b/packages/flutterguard_cli/lib/src/rules/circular_dependency.dart index decd11b..09e7725 100644 --- a/packages/flutterguard_cli/lib/src/rules/circular_dependency.dart +++ b/packages/flutterguard_cli/lib/src/rules/circular_dependency.dart @@ -1,13 +1,10 @@ -import 'dart:io'; - -import 'package:analyzer/dart/analysis/utilities.dart'; -import 'package:analyzer/dart/ast/ast.dart'; import 'package:path/path.dart' as p; import '../domain.dart'; -import '../import_utils.dart'; -import '../path_utils.dart'; +import '../import_graph.dart'; import '../priority.dart'; +import '../rule_meta.dart'; +import '../source_workspace.dart'; import '../static_issue.dart'; enum _Color { white, gray, black } @@ -18,57 +15,29 @@ class CircularDependencyRule { const CircularDependencyRule({this.enabled = true, this.projectPath}); - List analyze(List files) { + List analyze( + List files, { + SourceWorkspace? workspace, + ImportGraph? importGraph, + }) { if (!enabled || files.length < 2) return []; - final fileSet = { - for (final file in files) normalizePath(file), + final sources = workspace ?? SourceWorkspace(); + final imports = importGraph ?? + ImportGraph.build( + files: files, + sourceFiles: files, + workspace: sources, + projectPath: projectPath, + ); + final graph = { + for (final file in imports.files) file: imports.dependenciesOf(file), }; - final graph = >{}; - - for (final file in fileSet) { - try { - final content = File(file).readAsStringSync(); - final result = parseString(content: content, path: file); - final deps = _extractDeps(file, result.unit, fileSet); - graph[file] = deps; - } catch (_) { - graph[file] = {}; - } - } - - return _findCycles(graph, fileSet); - } - - Set _extractDeps( - String sourceFile, - CompilationUnit unit, - Set fileSet, - ) { - final deps = {}; - final imports = unit.directives.whereType(); - - for (final import in imports) { - final importStr = import.uri.stringValue; - if (importStr == null) continue; - - final resolved = resolveImport( - sourceFile, - importStr, - fileSet, - projectPath: projectPath, - ); - if (resolved != null && resolved != sourceFile) { - deps.add(resolved); - } - } - - return deps; + return _findCycles(graph); } List _findCycles( Map> graph, - Set fileSet, ) { final issues = []; final color = {}; @@ -145,4 +114,17 @@ class CircularDependencyRule { }, ); } + + static RuleMeta describe() => const RuleMeta( + id: 'circular_dependency', + name: '循环依赖', + domain: 'architecture', + riskLevel: 'medium', + priority: 'p1', + purpose: '检测文件级别的循环 import', + riskReason: '循环依赖导致编译耦合、无法单独测试和复用', + badExample: 'a.dart → b.dart → c.dart → a.dart', + fixSuggestion: '将公共依赖提取到 core 层,或使用接口反转打破循环', + cicdSafe: false, + ); } diff --git a/packages/flutterguard_cli/lib/src/rules/device_lifecycle.dart b/packages/flutterguard_cli/lib/src/rules/device_lifecycle.dart index fa63833..c48cab5 100644 --- a/packages/flutterguard_cli/lib/src/rules/device_lifecycle.dart +++ b/packages/flutterguard_cli/lib/src/rules/device_lifecycle.dart @@ -1,13 +1,12 @@ -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 '../rule_meta.dart'; import '../source_utils.dart'; +import '../source_workspace.dart'; import '../static_issue.dart'; const _lifecyclePairs = { @@ -24,17 +23,19 @@ class DeviceLifecycleRule { const DeviceLifecycleRule(this.config); - List analyze(List files) { + List analyze( + List files, { + SourceWorkspace? workspace, + }) { if (!config.enabled) return []; final issues = []; + final sources = workspace ?? SourceWorkspace(); 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 (_) {} + final source = sources.source(file); + if (source == null) continue; + issues.addAll(_checkFile(file, source.unit, source.lineInfo)); } return issues; @@ -56,7 +57,8 @@ class DeviceLifecycleRule { final teardownMethod = _lifecyclePairs[initMethod]!; if (!methodNames.contains(teardownMethod)) { - final initDecl = methods.firstWhere((m) => m.name.lexeme == initMethod); + final initDecl = + methods.firstWhere((m) => m.name.lexeme == initMethod); final line = lineNumberForOffset(lineInfo, initDecl.name.offset); issues.add(StaticIssue( @@ -67,7 +69,8 @@ class DeviceLifecycleRule { level: RiskLevel.high, domain: IssueDomain.architecture, priority: Priority.p0, - message: '类 "${cls.name.lexeme}" 中存在 "$initMethod" 但缺少对应的 "$teardownMethod"', + message: + '类 "${cls.name.lexeme}" 中存在 "$initMethod" 但缺少对应的 "$teardownMethod"', detail: '类: ${cls.name.lexeme}\n' '存在方法: $initMethod\n' '缺少方法: $teardownMethod\n' @@ -85,4 +88,17 @@ class DeviceLifecycleRule { return issues; } + + static RuleMeta describe() => const RuleMeta( + id: 'device_lifecycle', + name: '设备生命周期不匹配', + domain: 'architecture', + riskLevel: 'high', + priority: 'p0', + purpose: '检测 initState/connect 等初始化方法是否有对应的 dispose/disconnect 销毁方法', + riskReason: '不匹配的生命周期导致资源泄漏和设备连接未正常关闭', + badExample: 'connect() 调用存在但 disconnect() 不存在', + fixSuggestion: '确保每个初始化方法有对应的销毁方法', + cicdSafe: true, + ); } diff --git a/packages/flutterguard_cli/lib/src/rules/iot_security.dart b/packages/flutterguard_cli/lib/src/rules/iot_security.dart index 8680afb..eca57a0 100644 --- a/packages/flutterguard_cli/lib/src/rules/iot_security.dart +++ b/packages/flutterguard_cli/lib/src/rules/iot_security.dart @@ -1,8 +1,8 @@ -import 'dart:io'; - import '../config_loader.dart'; import '../domain.dart'; import '../priority.dart'; +import '../rule_meta.dart'; +import '../source_workspace.dart'; import '../static_issue.dart'; final _secretPattern = RegExp( @@ -18,16 +18,19 @@ class IotSecurityRule { const IotSecurityRule(this.config); - List analyze(List files) { + List analyze( + List files, { + SourceWorkspace? workspace, + }) { if (!config.enabled) return []; final issues = []; + final sources = workspace ?? SourceWorkspace(); for (final file in files) { - try { - final content = File(file).readAsStringSync(); - issues.addAll(_checkFile(file, content)); - } catch (_) {} + final source = sources.source(file); + if (source == null) continue; + issues.addAll(_checkFile(file, source.content)); } return issues; @@ -169,4 +172,19 @@ class IotSecurityRule { } } } + + static RuleMeta describe() => const RuleMeta( + id: 'iot_security', + name: 'IoT 安全风险', + domain: 'architecture', + riskLevel: 'high', + priority: 'p0', + purpose: '检测硬编码凭据、明文 MQTT/HTTP、不安全的 BLE 配置', + riskReason: '硬编码凭据泄露导致设备被入侵;明文传输导致数据窃听', + badExample: + 'password: "123456";tcp://broker:1883;BLE 使用 withoutBonding', + fixSuggestion: '使用环境变量或安全存储管理凭据;使用 TLS 加密通信', + configKeys: ['rules.iot_security.requireTls'], + cicdSafe: true, + ); } diff --git a/packages/flutterguard_cli/lib/src/rules/large_units.dart b/packages/flutterguard_cli/lib/src/rules/large_units.dart index 12398d5..fce94a0 100644 --- a/packages/flutterguard_cli/lib/src/rules/large_units.dart +++ b/packages/flutterguard_cli/lib/src/rules/large_units.dart @@ -1,12 +1,11 @@ -import 'dart:io'; - -import 'package:analyzer/dart/analysis/utilities.dart'; import 'package:analyzer/dart/ast/ast.dart'; import 'package:path/path.dart' as p; import '../config_loader.dart'; import '../domain.dart'; import '../priority.dart'; +import '../rule_meta.dart'; +import '../source_workspace.dart'; import '../static_issue.dart'; class LargeUnitsRule { @@ -20,57 +19,54 @@ class LargeUnitsRule { required this.largeBuildMethodConfig, }); - List analyze(List files) { + List analyze( + List files, { + SourceWorkspace? workspace, + }) { final issues = []; + final sources = workspace ?? SourceWorkspace(); for (final file in files) { + final source = sources.source(file); + if (source == null) continue; if (largeFileConfig.enabled) { - issues.addAll(_checkLargeFile(file)); + issues.addAll(_checkLargeFile(file, source.lines.length)); } if (largeClassConfig.enabled || largeBuildMethodConfig.enabled) { - try { - final content = File(file).readAsStringSync(); - final result = parseString(content: content, path: file); - if (largeClassConfig.enabled) { - issues.addAll(_checkLargeClass(file, content, result.unit)); - } - if (largeBuildMethodConfig.enabled) { - issues.addAll(_checkLargeBuild(file, content, result.unit)); - } - } catch (_) {} + if (largeClassConfig.enabled) { + issues.addAll(_checkLargeClass(file, source.content, source.unit)); + } + if (largeBuildMethodConfig.enabled) { + issues.addAll(_checkLargeBuild(file, source.content, source.unit)); + } } } return issues; } - List _checkLargeFile(String file) { - try { - final lines = File(file).readAsLinesSync(); - if (lines.length > largeFileConfig.maxLines) { - return [ - StaticIssue( - id: 'large_file', - title: '文件过大', - file: file, - line: null, - level: RiskLevel.low, - domain: IssueDomain.standards, - priority: Priority.p2, - message: - '文件 ${lines.length} 行(阈值: ${largeFileConfig.maxLines} 行)', - detail: '', - suggestion: - '建议将 ${p.basename(file)} 拆分为更小的模块文件', - metadata: { - 'actual': lines.length, - 'threshold': largeFileConfig.maxLines, - }, - ), - ]; - } - } catch (_) {} + List _checkLargeFile(String file, int lineCount) { + if (lineCount > largeFileConfig.maxLines) { + return [ + StaticIssue( + id: 'large_file', + title: '文件过大', + file: file, + line: null, + level: RiskLevel.low, + domain: IssueDomain.standards, + priority: Priority.p2, + message: '文件 $lineCount 行(阈值: ${largeFileConfig.maxLines} 行)', + detail: '', + suggestion: '建议将 ${p.basename(file)} 拆分为更小的模块文件', + metadata: { + 'actual': lineCount, + 'threshold': largeFileConfig.maxLines, + }, + ), + ]; + } return []; } @@ -99,8 +95,7 @@ class LargeUnitsRule { message: '类 "${cls.name.lexeme}" $lineCount 行(阈值: ${largeClassConfig.maxLines} 行)', detail: '', - suggestion: - '建议将 "${cls.name.lexeme}" 的职责提取到更小的类中', + suggestion: '建议将 "${cls.name.lexeme}" 的职责提取到更小的类中', metadata: { 'actual': lineCount, 'threshold': largeClassConfig.maxLines, @@ -142,8 +137,7 @@ class LargeUnitsRule { message: '${cls.name.lexeme}.build() $lineCount 行(阈值: ${largeBuildMethodConfig.maxLines} 行)', detail: '', - suggestion: - '将 build 方法中的部分提取为更小的子组件或方法', + suggestion: '将 build 方法中的部分提取为更小的子组件或方法', metadata: { 'actual': lineCount, 'threshold': largeBuildMethodConfig.maxLines, @@ -156,4 +150,46 @@ class LargeUnitsRule { } return issues; } + + static RuleMeta describeLargeFile() => const RuleMeta( + id: 'large_file', + name: '文件过大', + domain: 'standards', + riskLevel: 'low', + priority: 'p2', + purpose: '检测单文件行数超过阈值', + riskReason: '文件过大增加认知负载,难以维护和审查', + badExample: '500+ 行的单文件包含多个不相关类', + fixSuggestion: '将逻辑独立的类或函数提取到单独文件', + configKeys: ['rules.large_file.maxLines'], + cicdSafe: true, + ); + + static RuleMeta describeLargeClass() => const RuleMeta( + id: 'large_class', + name: '类过大', + domain: 'standards', + riskLevel: 'low', + priority: 'p2', + purpose: '检测类声明行数超过阈值', + riskReason: '大类违反单一职责原则,降低可测试性', + badExample: '300+ 行的类处理多种不相关逻辑', + fixSuggestion: '将类按职责拆分为多个更小的类', + configKeys: ['rules.large_class.maxLines'], + cicdSafe: true, + ); + + static RuleMeta describeLargeBuildMethod() => const RuleMeta( + id: 'large_build_method', + name: '构建方法过长', + domain: 'performance', + riskLevel: 'medium', + priority: 'p1', + purpose: '检测 Widget build 方法行数超过阈值', + riskReason: '过长的 build 方法难以维护,且可能包含重复渲染逻辑', + badExample: '80+ 行的 build 方法包含内联样式和嵌套 widget', + fixSuggestion: '将 build 中的子 UI 提取为独立 widget 或方法', + configKeys: ['rules.large_build_method.maxLines'], + cicdSafe: true, + ); } diff --git a/packages/flutterguard_cli/lib/src/rules/layer_violation.dart b/packages/flutterguard_cli/lib/src/rules/layer_violation.dart index a326409..2f9109f 100644 --- a/packages/flutterguard_cli/lib/src/rules/layer_violation.dart +++ b/packages/flutterguard_cli/lib/src/rules/layer_violation.dart @@ -1,16 +1,10 @@ -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 'package:glob/glob.dart'; - +import '../boundary_engine.dart'; import '../config_loader.dart'; import '../domain.dart'; -import '../import_utils.dart'; -import '../path_utils.dart'; +import '../import_graph.dart'; import '../priority.dart'; -import '../source_utils.dart'; +import '../rule_meta.dart'; +import '../source_workspace.dart'; import '../static_issue.dart'; class LayerViolationRule { @@ -19,108 +13,78 @@ class LayerViolationRule { const LayerViolationRule(this.layers, {this.projectPath}); - List analyze(List files) { + List analyze( + List files, { + List? allFiles, + SourceWorkspace? workspace, + ImportGraph? importGraph, + }) { if (layers.isEmpty) return []; - final fileToLayer = {}; - final fileSet = { - for (final file in files) normalizePath(file), - }; - - for (final file in fileSet) { - for (final layer in layers) { - try { - final matches = projectPath == null - ? Glob(layer.path.replaceAll('\\', '/')).matches(file) - : matchesProjectGlob(file, layer.path, projectPath!); - if (matches) { - fileToLayer[file] = layer; - break; - } - } catch (_) {} - } - } - - final issues = []; - for (final file in fileSet) { - final sourceLayer = fileToLayer[file]; - if (sourceLayer == null) continue; - - try { - final content = File(file).readAsStringSync(); - final result = parseString(content: content, path: file); - issues.addAll(_checkImports( - file, - sourceLayer, - result.unit, - result.lineInfo, - fileToLayer, - fileSet, - )); - } catch (_) {} - } - - return issues; - } - - List _checkImports( - String sourceFile, - LayerConfig sourceLayer, - CompilationUnit unit, - LineInfo lineInfo, - Map fileToLayer, - Set fileSet, - ) { - final issues = []; - final imports = unit.directives.whereType(); - - for (final import in imports) { - final importStr = import.uri.stringValue; - if (importStr == null) continue; - - final resolved = resolveImport( - sourceFile, - importStr, - fileSet, - projectPath: projectPath, - ); - if (resolved == null) continue; - - final targetLayer = fileToLayer[resolved]; - if (targetLayer == null) continue; - if (targetLayer.name == sourceLayer.name) continue; - - if (!sourceLayer.allowedDeps.contains(targetLayer.name)) { - final line = lineNumberForOffset(lineInfo, import.uri.offset); - final allowedStr = sourceLayer.allowedDeps.isEmpty - ? '无' - : sourceLayer.allowedDeps.join(', '); - - issues.add(StaticIssue( + final sources = workspace ?? SourceWorkspace(); + final availableFiles = allFiles ?? files; + final graph = importGraph ?? + ImportGraph.build( + files: availableFiles, + sourceFiles: files, + workspace: sources, + projectPath: projectPath, + ); + final boundaries = [ + for (final layer in layers) + BoundaryDefinition( + name: layer.name, + path: layer.path, + allowedDeps: layer.allowedDeps, + ), + ]; + final violations = DependencyBoundaryEngine.analyze( + sourceFiles: files, + allFiles: availableFiles, + boundaries: boundaries, + graph: graph, + workspace: sources, + projectPath: projectPath, + ); + + return [ + for (final violation in violations) + StaticIssue( id: 'layer_violation', title: '层间依赖违规', - file: sourceFile, - line: line, + file: violation.edge.source, + line: violation.edge.line, level: RiskLevel.high, domain: IssueDomain.architecture, priority: Priority.p0, - message: '${sourceLayer.name} 层不可依赖 ${targetLayer.name} 层', - detail: '导入: $importStr\n' - '源层: ${sourceLayer.name} (${sourceLayer.path})\n' - '目标层: ${targetLayer.name} (${targetLayer.path})\n' - '允许依赖: $allowedStr', + message: '${violation.source.name} 层不可依赖 ${violation.target.name} 层', + detail: '导入: ${violation.edge.uri}\n' + '源层: ${violation.source.name} (${violation.source.path})\n' + '目标层: ${violation.target.name} (${violation.target.path})\n' + '允许依赖: ${violation.source.allowedDeps.isEmpty ? '无' : violation.source.allowedDeps.join(', ')}', suggestion: - '将导入的内容移至 ${sourceLayer.allowedDeps.isEmpty ? 'core 或更抽象层' : '${sourceLayer.allowedDeps.join(' 或 ')}层'}', + '将导入的内容移至 ${violation.source.allowedDeps.isEmpty ? 'core 或更抽象层' : '${violation.source.allowedDeps.join(' 或 ')}层'}', metadata: { - 'sourceLayer': sourceLayer.name, - 'targetLayer': targetLayer.name, - 'imported': importStr, - 'allowedDeps': sourceLayer.allowedDeps, + 'sourceLayer': violation.source.name, + 'targetLayer': violation.target.name, + 'imported': violation.edge.uri, + 'allowedDeps': violation.source.allowedDeps, }, - )); - } - } - - return issues; + ), + ]; } + + static RuleMeta describe() => const RuleMeta( + id: 'layer_violation', + name: '层间依赖违规', + domain: 'architecture', + riskLevel: 'high', + priority: 'p0', + purpose: '检测架构层之间的非法依赖', + riskReason: '违反分层架构导致耦合增加,难以维护和替换', + badExample: 'presentation 层直接导入 data 层的实现', + fixSuggestion: '将跨层调用通过抽象接口进行,或调整 allowed_deps 配置', + configKeys: ['architecture.layers'], + cicdSafe: true, + ); } diff --git a/packages/flutterguard_cli/lib/src/rules/lifecycle_resource.dart b/packages/flutterguard_cli/lib/src/rules/lifecycle_resource.dart index de91d59..62be681 100644 --- a/packages/flutterguard_cli/lib/src/rules/lifecycle_resource.dart +++ b/packages/flutterguard_cli/lib/src/rules/lifecycle_resource.dart @@ -1,13 +1,12 @@ -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 '../rule_meta.dart'; import '../source_utils.dart'; +import '../source_workspace.dart'; import '../static_issue.dart'; const _resourceTypes = { @@ -27,17 +26,19 @@ class LifecycleResourceRule { const LifecycleResourceRule(this.config); - List analyze(List files) { + List analyze( + List files, { + SourceWorkspace? workspace, + }) { if (!config.enabled) return []; final issues = []; + final sources = workspace ?? SourceWorkspace(); 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 (_) {} + final source = sources.source(file); + if (source == null) continue; + issues.addAll(_checkFile(file, source.unit, source.lineInfo)); } return issues; @@ -115,4 +116,18 @@ class LifecycleResourceRule { return issues; } + + static RuleMeta describe() => const RuleMeta( + id: 'lifecycle_resource_not_disposed', + name: '资源未释放', + domain: 'performance', + riskLevel: 'medium', + priority: 'p1', + purpose: + '检测 StreamSubscription/Timer/AnimationController 等资源在 dispose 中未释放', + riskReason: '未释放的资源导致内存泄漏和性能下降', + badExample: '在 State 中创建 StreamSubscription 但 dispose() 中未调用 cancel()', + fixSuggestion: '在 dispose() 中对每个资源调用对应的 cancel()/dispose()/close()', + cicdSafe: true, + ); } diff --git a/packages/flutterguard_cli/lib/src/rules/missing_const_constructor.dart b/packages/flutterguard_cli/lib/src/rules/missing_const_constructor.dart index aadc50c..a126a0e 100644 --- a/packages/flutterguard_cli/lib/src/rules/missing_const_constructor.dart +++ b/packages/flutterguard_cli/lib/src/rules/missing_const_constructor.dart @@ -1,13 +1,12 @@ -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 '../rule_meta.dart'; import '../source_utils.dart'; +import '../source_workspace.dart'; import '../static_issue.dart'; final _widgetTypes = {'StatelessWidget', 'StatefulWidget'}; @@ -17,17 +16,19 @@ class MissingConstConstructorRule { const MissingConstConstructorRule(this.config); - List analyze(List files) { + List analyze( + List files, { + SourceWorkspace? workspace, + }) { if (!config.enabled) return []; final issues = []; + final sources = workspace ?? SourceWorkspace(); 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 (_) {} + final source = sources.source(file); + if (source == null) continue; + issues.addAll(_checkFile(file, source.unit, source.lineInfo)); } return issues; @@ -80,4 +81,18 @@ class MissingConstConstructorRule { return issues; } + + static RuleMeta describe() => const RuleMeta( + id: 'missing_const_constructor', + name: '缺少 const 构造函数', + domain: 'standards', + riskLevel: 'low', + priority: 'p2', + purpose: '检测 Widget 子类是否缺少 const 构造函数', + riskReason: '缺少 const 构造函数导致 widget 无法复用实例,影响渲染性能', + badExample: + 'class MyWidget extends StatelessWidget { ... } 无 const 构造函数', + fixSuggestion: '为 Widget 类添加 const 构造函数', + cicdSafe: true, + ); } diff --git a/packages/flutterguard_cli/lib/src/rules/module_violation.dart b/packages/flutterguard_cli/lib/src/rules/module_violation.dart index 30ec852..8a89f24 100644 --- a/packages/flutterguard_cli/lib/src/rules/module_violation.dart +++ b/packages/flutterguard_cli/lib/src/rules/module_violation.dart @@ -1,16 +1,10 @@ -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 'package:glob/glob.dart'; - +import '../boundary_engine.dart'; import '../config_loader.dart'; import '../domain.dart'; -import '../import_utils.dart'; -import '../path_utils.dart'; +import '../import_graph.dart'; import '../priority.dart'; -import '../source_utils.dart'; +import '../rule_meta.dart'; +import '../source_workspace.dart'; import '../static_issue.dart'; class ModuleViolationRule { @@ -19,108 +13,78 @@ class ModuleViolationRule { const ModuleViolationRule(this.modules, {this.projectPath}); - List analyze(List files) { + List analyze( + List files, { + List? allFiles, + SourceWorkspace? workspace, + ImportGraph? importGraph, + }) { if (modules.isEmpty) return []; - - final fileToModule = {}; - final fileSet = { - for (final file in files) normalizePath(file), - }; - - for (final file in fileSet) { - for (final module in modules) { - try { - final matches = projectPath == null - ? Glob(module.path.replaceAll('\\', '/')).matches(file) - : matchesProjectGlob(file, module.path, projectPath!); - if (matches) { - fileToModule[file] = module; - break; - } - } catch (_) {} - } - } - - final issues = []; - for (final file in fileSet) { - final sourceModule = fileToModule[file]; - if (sourceModule == null) continue; - - try { - final content = File(file).readAsStringSync(); - final result = parseString(content: content, path: file); - issues.addAll(_checkImports( - file, - sourceModule, - result.unit, - result.lineInfo, - fileToModule, - fileSet, - )); - } catch (_) {} - } - - return issues; - } - - List _checkImports( - String sourceFile, - ModuleConfig sourceModule, - CompilationUnit unit, - LineInfo lineInfo, - Map fileToModule, - Set fileSet, - ) { - final issues = []; - final imports = unit.directives.whereType(); - - for (final import in imports) { - final importStr = import.uri.stringValue; - if (importStr == null) continue; - - final resolved = resolveImport( - sourceFile, - importStr, - fileSet, - projectPath: projectPath, - ); - if (resolved == null) continue; - - final targetModule = fileToModule[resolved]; - if (targetModule == null) continue; - if (targetModule.name == sourceModule.name) continue; - - if (!sourceModule.allowedDeps.contains(targetModule.name)) { - final line = lineNumberForOffset(lineInfo, import.uri.offset); - final allowedStr = sourceModule.allowedDeps.isEmpty - ? '无' - : sourceModule.allowedDeps.join(', '); - - issues.add(StaticIssue( + final sources = workspace ?? SourceWorkspace(); + final availableFiles = allFiles ?? files; + final graph = importGraph ?? + ImportGraph.build( + files: availableFiles, + sourceFiles: files, + workspace: sources, + projectPath: projectPath, + ); + final boundaries = [ + for (final module in modules) + BoundaryDefinition( + name: module.name, + path: module.path, + allowedDeps: module.allowedDeps, + ), + ]; + final violations = DependencyBoundaryEngine.analyze( + sourceFiles: files, + allFiles: availableFiles, + boundaries: boundaries, + graph: graph, + workspace: sources, + projectPath: projectPath, + ); + + return [ + for (final violation in violations) + StaticIssue( id: 'module_violation', title: '模块间非法依赖', - file: sourceFile, - line: line, + file: violation.edge.source, + line: violation.edge.line, level: RiskLevel.high, domain: IssueDomain.architecture, priority: Priority.p0, - message: '模块 ${sourceModule.name} 不可依赖模块 ${targetModule.name}', - detail: '导入: $importStr\n' - '源模块: ${sourceModule.name} (${sourceModule.path})\n' - '目标模块: ${targetModule.name} (${targetModule.path})\n' - '允许依赖: $allowedStr', + message: + '模块 ${violation.source.name} 不可依赖模块 ${violation.target.name}', + detail: '导入: ${violation.edge.uri}\n' + '源模块: ${violation.source.name} (${violation.source.path})\n' + '目标模块: ${violation.target.name} (${violation.target.path})\n' + '允许依赖: ${violation.source.allowedDeps.isEmpty ? '无' : violation.source.allowedDeps.join(', ')}', suggestion: - '通过 ${sourceModule.allowedDeps.isEmpty ? 'core 层共享接口解耦' : '${sourceModule.allowedDeps.join(' 或 ')}解耦'}', + '通过 ${violation.source.allowedDeps.isEmpty ? 'core 层共享接口解耦' : '${violation.source.allowedDeps.join(' 或 ')}解耦'}', metadata: { - 'sourceModule': sourceModule.name, - 'targetModule': targetModule.name, - 'imported': importStr, - 'allowedDeps': sourceModule.allowedDeps, + 'sourceModule': violation.source.name, + 'targetModule': violation.target.name, + 'imported': violation.edge.uri, + 'allowedDeps': violation.source.allowedDeps, }, - )); - } - } - - return issues; + ), + ]; } + + static RuleMeta describe() => const RuleMeta( + id: 'module_violation', + name: '模块间依赖违规', + domain: 'architecture', + riskLevel: 'high', + priority: 'p0', + purpose: '检测业务模块之间的非法依赖', + riskReason: '模块间非法依赖破坏隔离性,导致耦合和回归风险', + badExample: 'mqtt 模块直接导入 ble 模块的类', + fixSuggestion: '提取公共依赖到 shared 模块,或通过事件总线解耦', + configKeys: ['architecture.modules'], + cicdSafe: true, + ); } diff --git a/packages/flutterguard_cli/lib/src/rules/mqtt_connection.dart b/packages/flutterguard_cli/lib/src/rules/mqtt_connection.dart index a4f6e97..cf84788 100644 --- a/packages/flutterguard_cli/lib/src/rules/mqtt_connection.dart +++ b/packages/flutterguard_cli/lib/src/rules/mqtt_connection.dart @@ -1,13 +1,12 @@ -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 '../rule_meta.dart'; import '../source_utils.dart'; +import '../source_workspace.dart'; import '../static_issue.dart'; const _mqttClientTypes = ['MqttClient', 'MQTT', 'MqttConnect']; @@ -18,17 +17,21 @@ class MqttConnectionRule { const MqttConnectionRule(this.config); - List analyze(List files) { + List analyze( + List files, { + SourceWorkspace? workspace, + }) { if (!config.enabled) return []; final issues = []; + final sources = workspace ?? SourceWorkspace(); 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 (_) {} + final source = sources.source(file); + if (source == null) continue; + issues.addAll( + _checkFile(file, source.content, source.unit, source.lineInfo), + ); } return issues; @@ -45,21 +48,20 @@ class MqttConnectionRule { _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; + 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'); + 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', @@ -69,7 +71,8 @@ class MqttConnectionRule { level: RiskLevel.high, domain: IssueDomain.architecture, priority: Priority.p0, - message: '类 "${cls.name.lexeme}" 包含 MQTT connect() 调用但缺少 disconnect()', + message: + '类 "${cls.name.lexeme}" 包含 MQTT connect() 调用但缺少 disconnect()', detail: '类: ${cls.name.lexeme}\n' 'MqttClient 需要连接与断开配对', suggestion: '在类中添加 disconnect() 方法并在 dispose 中调用', @@ -80,8 +83,10 @@ class MqttConnectionRule { )); } - if (methodNames.contains('subscribe') && !methodNames.contains('unsubscribe')) { - final subscribeMethod = methods.firstWhere((m) => m.name.lexeme == 'subscribe'); + 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', @@ -91,7 +96,8 @@ class MqttConnectionRule { level: RiskLevel.medium, domain: IssueDomain.architecture, priority: Priority.p0, - message: '类 "${cls.name.lexeme}" 包含 MQTT subscribe() 调用但缺少 unsubscribe()', + message: + '类 "${cls.name.lexeme}" 包含 MQTT subscribe() 调用但缺少 unsubscribe()', detail: '类: ${cls.name.lexeme}\n' 'MQTT 订阅应在不需要时取消', suggestion: '在类中添加 unsubscribe() 方法并在 dispose 中调用', @@ -137,4 +143,19 @@ class MqttConnectionRule { } } } + + static RuleMeta describe() => const RuleMeta( + id: 'mqtt_connection', + name: 'MQTT 连接管理异常', + domain: 'architecture', + riskLevel: 'high', + priority: 'p0', + purpose: + '检测 MQTT connect/disconnect、subscribe/unsubscribe 配对以及硬编码 broker URL', + riskReason: '未配对的 MQTT 调用导致连接泄漏;硬编码 URL 导致安全风险和配置问题', + badExample: + '调用 connect() 后无 disconnect();URL 直接写 tcp://mqtt.example.com:1883', + fixSuggestion: '在 dispose 中添加 disconnect();将 broker URL 提取到配置文件', + cicdSafe: true, + ); } diff --git a/packages/flutterguard_cli/lib/src/rules/pubspec_security.dart b/packages/flutterguard_cli/lib/src/rules/pubspec_security.dart index e94154e..152c6bc 100644 --- a/packages/flutterguard_cli/lib/src/rules/pubspec_security.dart +++ b/packages/flutterguard_cli/lib/src/rules/pubspec_security.dart @@ -6,6 +6,8 @@ import 'package:yaml/yaml.dart'; import '../config_loader.dart'; import '../domain.dart'; import '../priority.dart'; +import '../rule_meta.dart'; +import '../source_workspace.dart'; import '../static_issue.dart'; const _vulnerableDeps = { @@ -22,14 +24,20 @@ class PubspecSecurityRule { const PubspecSecurityRule(this.config); - List analyze(List files) { + List analyze( + List files, { + String? projectPath, + SourceWorkspace? workspace, + }) { if (!config.enabled) return []; final issues = []; + final candidateDirectories = projectPath == null + ? {for (final file in files) p.dirname(file)} + : {projectPath}; - for (final file in files) { - final dir = p.dirname(file); - final pubspec = p.join(dir, 'pubspec.yaml'); + for (final directory in candidateDirectories) { + final pubspec = p.join(directory, 'pubspec.yaml'); if (!File(pubspec).existsSync()) continue; try { @@ -37,7 +45,14 @@ class PubspecSecurityRule { final yaml = loadYaml(content); if (yaml is! YamlMap) continue; issues.addAll(_checkPubspec(pubspec, yaml)); - } catch (_) {} + } on Object catch (error) { + workspace?.addDiagnostic(ScanDiagnostic( + stage: 'pubspec_parse', + file: pubspec, + message: error.toString(), + severity: ScanDiagnosticSeverity.error, + )); + } } return _deduplicate(issues); @@ -103,7 +118,8 @@ class PubspecSecurityRule { if (_vulnerableDeps.containsKey(dep)) { final minVersion = _vulnerableDeps[dep]!; final currentVersion = _cleanVersion(version); - if (currentVersion.isNotEmpty && _compareVersion(currentVersion, minVersion) < 0) { + if (currentVersion.isNotEmpty && + _compareVersion(currentVersion, minVersion) < 0) { issues.add(StaticIssue( id: 'pubspec_security', title: '依赖安全 — 过旧版本', @@ -168,4 +184,17 @@ class PubspecSecurityRule { return true; }).toList(); } + + static RuleMeta describe() => const RuleMeta( + id: 'pubspec_security', + name: '依赖安全风险', + domain: 'standards', + riskLevel: 'medium', + priority: 'p2', + purpose: '检测依赖版本无界、已废弃包、过旧版本', + riskReason: '无界依赖引入不兼容更新;废弃包存在安全漏洞', + badExample: 'mqtt_client: ^9.0.0(低于 10.0.0);flutter_blue(已废弃)', + fixSuggestion: '固定大版本号;将 flutter_blue 迁移至 flutter_blue_plus', + cicdSafe: true, + ); } diff --git a/packages/flutterguard_cli/lib/src/rules/registry.dart b/packages/flutterguard_cli/lib/src/rules/registry.dart new file mode 100644 index 0000000..30777d5 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/rules/registry.dart @@ -0,0 +1,13 @@ +import 'package:flutterguard_cli/src/rule_meta.dart'; +import 'package:flutterguard_cli/src/rules/catalog.dart'; + +class RuleRegistry { + static final Map _registry = () { + final list = RuleCatalog.metadata(); + return {for (final m in list) m.id: m}; + }(); + + static List all() => _registry.values.toList(); + + static RuleMeta? find(String id) => _registry[id]; +} diff --git a/packages/flutterguard_cli/lib/src/sarif_report.dart b/packages/flutterguard_cli/lib/src/sarif_report.dart new file mode 100644 index 0000000..89e65f4 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/sarif_report.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; + +import 'package:path/path.dart' as p; + +import 'rules/registry.dart'; +import 'static_issue.dart'; + +class SarifReport { + static String generate({ + required String projectPath, + required List issues, + }) { + final rules = RuleRegistry.all()..sort((a, b) => a.id.compareTo(b.id)); + + final payload = { + r'$schema': 'https://json.schemastore.org/sarif-2.1.0.json', + 'version': '2.1.0', + 'runs': [ + { + 'tool': { + 'driver': { + 'name': 'FlutterGuard', + 'informationUri': 'https://github.com/lizy-coding/flutterguard', + 'rules': [ + for (final rule in rules) + { + 'id': rule.id, + 'name': rule.name, + 'shortDescription': {'text': rule.purpose}, + 'fullDescription': {'text': rule.riskReason}, + 'help': {'text': rule.fixSuggestion}, + 'defaultConfiguration': { + 'level': _levelFromRule(rule.riskLevel), + }, + } + ], + }, + }, + 'results': [ + for (final issue in issues) + { + 'ruleId': issue.id, + 'level': _level(issue.level), + 'message': {'text': issue.message}, + 'locations': [ + { + 'physicalLocation': { + 'artifactLocation': { + 'uri': _relativeUri(issue.file, projectPath), + }, + 'region': { + 'startLine': issue.line ?? 1, + }, + }, + } + ], + } + ], + } + ], + }; + + return const JsonEncoder.withIndent(' ').convert(payload); + } + + static String _relativeUri(String filePath, String projectPath) { + final relative = + p.isWithin(projectPath, filePath) || p.equals(projectPath, filePath) + ? p.relative(filePath, from: projectPath) + : filePath; + return relative.replaceAll('\\', '/'); + } + + static String _level(RiskLevel level) { + switch (level) { + case RiskLevel.high: + return 'error'; + case RiskLevel.medium: + return 'warning'; + case RiskLevel.low: + return 'note'; + } + } + + static String _levelFromRule(String riskLevel) { + switch (riskLevel.toLowerCase()) { + case 'high': + return 'error'; + case 'medium': + return 'warning'; + default: + return 'note'; + } + } +} diff --git a/packages/flutterguard_cli/lib/src/scan_context.dart b/packages/flutterguard_cli/lib/src/scan_context.dart new file mode 100644 index 0000000..bf2abed --- /dev/null +++ b/packages/flutterguard_cli/lib/src/scan_context.dart @@ -0,0 +1,26 @@ +import 'config_loader.dart'; +import 'source_workspace.dart'; + +enum ScanMode { full, changed } + +class ScanContext { + final String projectPath; + final ScanConfig config; + final List allFiles; + final List targetFiles; + final ScanMode mode; + final SourceWorkspace sources; + final Set changedFiles; + + const ScanContext({ + required this.projectPath, + required this.config, + required this.allFiles, + required this.targetFiles, + required this.mode, + required this.sources, + this.changedFiles = const {}, + }); + + bool get isChanged => mode == ScanMode.changed; +} diff --git a/packages/flutterguard_cli/lib/src/scanner.dart b/packages/flutterguard_cli/lib/src/scanner.dart index c51e59b..fe81963 100644 --- a/packages/flutterguard_cli/lib/src/scanner.dart +++ b/packages/flutterguard_cli/lib/src/scanner.dart @@ -2,22 +2,18 @@ import 'dart:io'; import 'package:path/path.dart' as p; +import 'baseline.dart'; import 'config_loader.dart'; import 'file_collector.dart'; +import 'path_utils.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 'scan_context.dart'; +import 'rules/catalog.dart'; +import 'sarif_report.dart'; import 'static_issue.dart'; +import 'source_workspace.dart'; +import 'suppression.dart'; class ScanException implements Exception { final String message; @@ -32,122 +28,179 @@ class ScanResult { final String projectPath; final String reportDir; final List files; + final List rawIssues; final List issues; + final int suppressedCount; + final int suppressedByBaselineCount; final int score; + final String scanMode; + final List diagnostics; + final SourceWorkspace sources; const ScanResult({ required this.projectPath, required this.reportDir, required this.files, + required this.rawIssues, required this.issues, + required this.suppressedCount, + required this.suppressedByBaselineCount, required this.score, + required this.scanMode, + required this.sources, + this.diagnostics = const [], }); } class FlutterGuardScanner { static ScanResult scan({ required String projectPath, - String configPath = 'flutterguard.yaml', + String? configPath, String outputDir = '.flutterguard', bool writeJson = false, + bool writeSarif = false, + @Deprecated('Color is a presentation concern; pass it to ReportGenerator.') bool noColor = false, + bool changedOnly = false, + String base = 'main', + String? baselinePath, + bool applySuppression = true, }) { - final resolvedProjectPath = - ProjectResolver.resolveProjectPath(projectPath); + final resolvedProjectPath = ProjectResolver.resolveProjectPath(projectPath); if (!Directory(resolvedProjectPath).existsSync()) { throw ScanException( 'Project path "$resolvedProjectPath" does not exist.', ); } - final resolvedConfigPath = - ProjectResolver.resolveConfigPath( - projectPath: resolvedProjectPath, - explicitConfig: configPath, - ); - final config = ScanConfig.fromFile(resolvedConfigPath); + final resolvedConfigPath = ProjectResolver.resolveConfigPath( + projectPath: resolvedProjectPath, + explicitConfig: configPath, + ); + final config = ScanConfig.fromFile( + resolvedConfigPath, + requireFile: configPath != null, + ); final files = FileCollector.collect(resolvedProjectPath, config); + if (files.isEmpty) { + throw const ScanException( + 'No Dart files matched the configured include/exclude patterns.', + ); + } + var scanMode = ScanMode.full; + var filesToScan = files; + var changedFiles = {}; + + if (changedOnly) { + try { + final changed = FileCollector.getChangedFiles( + resolvedProjectPath, + base, + ); + if (changed != null) { + changedFiles = changed.map(normalizePath).toSet(); + final changedDart = + changedFiles.where((f) => f.endsWith('.dart')).toSet(); + filesToScan = files.where((f) => changedDart.contains(f)).toList(); + scanMode = ScanMode.changed; + } + } on ChangedFilesException catch (e) { + throw ScanException(e.message); + } + } final reportDir = p.isAbsolute(outputDir) ? outputDir : p.join(resolvedProjectPath, outputDir); - final issues = _analyze( - files: files, - config: config, + final sources = SourceWorkspace(); + final context = ScanContext( projectPath: resolvedProjectPath, + config: config, + allFiles: files, + targetFiles: filesToScan, + mode: scanMode, + sources: sources, + changedFiles: changedFiles, ); + final rawIssues = _analyze(context); + + var issues = rawIssues; + var suppressedCount = 0; + if (applySuppression) { + final suppression = SuppressionFilter( + filesToScan, + workspace: sources, + ); + final visible = []; + for (final issue in issues) { + if (suppression.isSuppressed(issue)) { + suppressedCount++; + } else { + visible.add(issue); + } + } + issues = visible; + } + + var suppressedByBaselineCount = 0; + if (baselinePath != null) { + final resolvedBaselinePath = p.isAbsolute(baselinePath) + ? baselinePath + : p.join(resolvedProjectPath, baselinePath); + final baseline = Baseline.load(resolvedBaselinePath); + final visible = []; + for (final issue in issues) { + if (baseline.contains(issue, resolvedProjectPath)) { + suppressedByBaselineCount++; + } else { + visible.add(issue); + } + } + issues = visible; + } + final score = ReportGenerator.calculateScore(issues); - if (writeJson) { + if (writeJson || writeSarif) { Directory(reportDir).createSync(recursive: true); + } + if (writeJson) { final json = ReportGenerator.generateJson( projectPath: resolvedProjectPath, issues: issues, + scanMode: scanMode.name, + suppressedCount: suppressedCount, + suppressedByBaselineCount: suppressedByBaselineCount, + diagnostics: sources.diagnostics, ); File(p.join(reportDir, 'report.json')).writeAsStringSync(json); } + if (writeSarif) { + final sarif = SarifReport.generate( + projectPath: resolvedProjectPath, + issues: issues, + ); + File(p.join(reportDir, 'report.sarif')).writeAsStringSync(sarif); + } return ScanResult( projectPath: resolvedProjectPath, reportDir: reportDir, - files: files, + files: filesToScan, + rawIssues: rawIssues, issues: issues, + suppressedCount: suppressedCount, + suppressedByBaselineCount: suppressedByBaselineCount, score: score, + scanMode: scanMode.name, + sources: sources, + diagnostics: sources.diagnostics, ); } - static List _analyze({ - required List files, - required ScanConfig config, - required String projectPath, - }) { - final allIssues = []; - - allIssues.addAll(LargeUnitsRule( - largeFileConfig: config.rules.largeFile, - largeClassConfig: config.rules.largeClass, - largeBuildMethodConfig: config.rules.largeBuildMethod, - ).analyze(files)); - allIssues.addAll( - LifecycleResourceRule(config.rules.lifecycleResource).analyze(files), - ); - - if (config.architecture.layerViolationEnabled) { - allIssues.addAll(LayerViolationRule( - config.architecture.layers, - projectPath: projectPath, - ).analyze(files)); - } - if (config.architecture.moduleViolationEnabled) { - allIssues.addAll(ModuleViolationRule( - config.architecture.modules, - projectPath: projectPath, - ).analyze(files)); - } - - allIssues.addAll(MissingConstConstructorRule( - config.rules.missingConstConstructor, - ).analyze(files)); - allIssues.addAll(CircularDependencyRule( - 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)); + static List _analyze(ScanContext context) { + final allIssues = RuleCatalog.analyze(context); allIssues.sort((a, b) { final levelOrder = { diff --git a/packages/flutterguard_cli/lib/src/source_workspace.dart b/packages/flutterguard_cli/lib/src/source_workspace.dart new file mode 100644 index 0000000..7e8ca95 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/source_workspace.dart @@ -0,0 +1,97 @@ +import 'dart:io'; +import 'dart:convert'; + +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/source/line_info.dart'; + +import 'path_utils.dart'; + +enum ScanDiagnosticSeverity { warning, error } + +class ScanDiagnostic { + final String stage; + final String message; + final String? file; + final ScanDiagnosticSeverity severity; + + const ScanDiagnostic({ + required this.stage, + required this.message, + this.file, + this.severity = ScanDiagnosticSeverity.warning, + }); + + Map toJson() => { + 'stage': stage, + 'message': message, + 'file': file, + 'severity': severity.name, + }; +} + +class SourceUnit { + final String path; + final String content; + final List lines; + final CompilationUnit unit; + final LineInfo lineInfo; + + const SourceUnit({ + required this.path, + required this.content, + required this.lines, + required this.unit, + required this.lineInfo, + }); +} + +/// Per-scan source cache shared by every rule. +/// +/// A source file is read and parsed at most once. Failures are retained as +/// diagnostics instead of being silently swallowed by each rule. +class SourceWorkspace { + final Map _sources = {}; + final List _diagnostics = []; + + List get diagnostics => List.unmodifiable(_diagnostics); + + SourceUnit? source(String filePath) { + final path = normalizePath(filePath); + if (_sources.containsKey(path)) return _sources[path]; + + try { + final content = File(path).readAsStringSync(); + final parsed = parseString(content: content, path: path); + final source = SourceUnit( + path: path, + content: content, + lines: const LineSplitter().convert(content), + unit: parsed.unit, + lineInfo: parsed.lineInfo, + ); + _sources[path] = source; + return source; + } on FileSystemException catch (error) { + _recordFailure(path, 'read', error.message); + } on Object catch (error) { + _recordFailure(path, 'parse', error.toString()); + } + + _sources[path] = null; + return null; + } + + void addDiagnostic(ScanDiagnostic diagnostic) { + _diagnostics.add(diagnostic); + } + + void _recordFailure(String path, String stage, String message) { + _diagnostics.add(ScanDiagnostic( + stage: stage, + file: path, + message: message, + severity: ScanDiagnosticSeverity.error, + )); + } +} diff --git a/packages/flutterguard_cli/lib/src/suppression.dart b/packages/flutterguard_cli/lib/src/suppression.dart new file mode 100644 index 0000000..9bb2c47 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/suppression.dart @@ -0,0 +1,67 @@ +import 'dart:io'; + +import 'source_workspace.dart'; +import 'static_issue.dart'; + +class SuppressionFilter { + final Map>> _rulesByFileAndLine = {}; + + SuppressionFilter( + Iterable files, { + SourceWorkspace? workspace, + }) { + for (final file in files) { + _rulesByFileAndLine[file] = _parseFile(file, workspace: workspace); + } + } + + bool isSuppressed(StaticIssue issue) { + final line = issue.line; + if (line == null) return false; + final byLine = _rulesByFileAndLine[issue.file]; + if (byLine == null) return false; + final rules = byLine[line]; + if (rules == null) return false; + return rules.contains('all') || rules.contains(issue.id); + } + + static Map> _parseFile( + String path, { + SourceWorkspace? workspace, + }) { + final List lines; + if (workspace == null) { + final file = File(path); + if (!file.existsSync()) return const {}; + lines = file.readAsLinesSync(); + } else { + final source = workspace.source(path); + if (source == null) return const {}; + lines = source.lines; + } + + final result = >{}; + for (var index = 0; index < lines.length; index++) { + final parsed = _parseLine(lines[index]); + if (parsed == null) continue; + final lineNumber = index + 1; + result.putIfAbsent(lineNumber, () => {}).addAll(parsed); + result.putIfAbsent(lineNumber + 1, () => {}).addAll(parsed); + } + return result; + } + + static Set? _parseLine(String line) { + final match = RegExp(r'flutterguard:\s*ignore\s+([A-Za-z0-9_,\s-]+)') + .firstMatch(line); + if (match == null) return null; + final raw = match.group(1) ?? ''; + final ids = raw + .split(',') + .map((part) => part.trim()) + .where((part) => part.isNotEmpty) + .toSet(); + if (ids.isEmpty) return null; + return ids; + } +} diff --git a/packages/flutterguard_cli/pubspec.yaml b/packages/flutterguard_cli/pubspec.yaml index 5d5ed30..89f78ea 100644 --- a/packages/flutterguard_cli/pubspec.yaml +++ b/packages/flutterguard_cli/pubspec.yaml @@ -1,6 +1,6 @@ name: flutterguard_cli description: IoT Flutter static analysis CLI for architecture enforcement, code quality, and CI gating. Scans Dart source for layer violations, lifecycle resource leaks, circular dependencies, and more. -version: 0.2.0 +version: 0.5.0 repository: https://github.com/lizy-coding/flutterguard issue_tracker: https://github.com/lizy-coding/flutterguard/issues topics: diff --git a/packages/flutterguard_cli/test/AGENTS.md b/packages/flutterguard_cli/test/AGENTS.md index 8fa24a3..fd15f84 100644 --- a/packages/flutterguard_cli/test/AGENTS.md +++ b/packages/flutterguard_cli/test/AGENTS.md @@ -3,16 +3,21 @@ ## Responsibility 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 (26 tests, 5 groups). +## Test Files +- `scanner_test.dart`: reusable scanner/rule integration suite (57 tests, 7 groups). +- `cli_test.dart`: process-level CLI exit and report behavior (4 tests). ## Test Groups | Group | Tests | Coverage | |-------|-------|---------| | Static Rules | 18 | 8 existing rules + 5 IoT rules + config parsing + wiring | -| Report Generation | 2 | JSON and stdout output validation | -| Scanner Orchestration | 3 | Full scan, missing path exception, invalid config | +| Report Generation | 3 | JSON, stdout, and suppression summary output | +| Scanner Orchestration | 16 | Scan policy, root pubspec, diagnostics, suppression, baseline, SARIF, issue export | +| Changed-only | 7 | Git filtering, project rules, architecture target resolution, clean scans, invalid refs, non-Git fallback, cycle behavior | +| Registry | 3 | Rule metadata lookup | +| Config Tools | 7 | Init profiles, effective config, doctor, install diagnostics | | Path Handling | 3 | Windows globs, package imports, cross-platform import resolution | +| CLI Process | 4 | Exit codes, config scoping/enforcement, empty changed JSON report | ## Rules - Add a fixture for every new rule or regression case. diff --git a/packages/flutterguard_cli/test/cli_test.dart b/packages/flutterguard_cli/test/cli_test.dart new file mode 100644 index 0000000..72a879e --- /dev/null +++ b/packages/flutterguard_cli/test/cli_test.dart @@ -0,0 +1,188 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void _runGit(Directory directory, List args) { + final result = Process.runSync( + 'git', + args, + workingDirectory: directory.path, + ); + if (result.exitCode != 0) { + throw StateError('git ${args.join(' ')} failed: ${result.stderr}'); + } +} + +void main() { + test('scan exits with setup error when the project matches no Dart files', + () { + final project = Directory.systemTemp.createTempSync( + 'flutterguard_cli_empty_project_', + ); + addTearDown(() => project.deleteSync(recursive: true)); + + final entrypoint = p.join( + Directory.current.path, + 'bin', + 'flutterguard.dart', + ); + final result = Process.runSync( + Platform.resolvedExecutable, + [ + entrypoint, + 'scan', + project.path, + '--format', + 'json', + '--output', + '.flutterguard/test', + '--no-color', + ], + workingDirectory: Directory.current.path, + ); + + expect(result.exitCode, 2); + expect(result.stderr, contains('No Dart files matched')); + expect( + File(p.join(project.path, '.flutterguard', 'test', 'report.json')) + .existsSync(), + isFalse, + ); + }); + + test('changed-only clean scan succeeds and writes an empty JSON report', () { + final project = Directory.systemTemp.createTempSync( + 'flutterguard_cli_clean_project_', + ); + addTearDown(() => project.deleteSync(recursive: true)); + Directory(p.join(project.path, 'lib')).createSync(); + File(p.join(project.path, 'lib', 'clean.dart')) + .writeAsStringSync('class Clean {}\n'); + _runGit(project, ['init', '-b', 'main']); + _runGit(project, ['config', 'user.email', 'test@example.com']); + _runGit(project, ['config', 'user.name', 'FlutterGuard Test']); + _runGit(project, ['add', '.']); + _runGit(project, ['commit', '-m', 'initial']); + + final entrypoint = p.join( + Directory.current.path, + 'bin', + 'flutterguard.dart', + ); + final result = Process.runSync( + Platform.resolvedExecutable, + [ + entrypoint, + 'scan', + project.path, + '--changed-only', + '--base', + 'main', + '--format', + 'json', + '--output', + '.flutterguard/test', + '--no-color', + ], + workingDirectory: Directory.current.path, + ); + + expect(result.exitCode, 0); + expect(result.stdout, contains('No changed Dart files matched')); + final report = File( + p.join(project.path, '.flutterguard', 'test', 'report.json'), + ); + expect(report.existsSync(), isTrue); + final payload = + jsonDecode(report.readAsStringSync()) as Map; + expect(payload['scanMode'], 'changed'); + expect(payload['issues'], isEmpty); + }); + + test('explicitly selected default config must exist', () { + final project = Directory.systemTemp.createTempSync( + 'flutterguard_cli_required_config_', + ); + addTearDown(() => project.deleteSync(recursive: true)); + Directory(p.join(project.path, 'lib')).createSync(); + File(p.join(project.path, 'lib', 'plain.dart')) + .writeAsStringSync('class Plain {}\n'); + + final entrypoint = p.join( + Directory.current.path, + 'bin', + 'flutterguard.dart', + ); + final result = Process.runSync( + Platform.resolvedExecutable, + [ + entrypoint, + 'scan', + project.path, + '--config', + 'flutterguard.yaml', + '--no-color', + ], + workingDirectory: Directory.current.path, + ); + + expect(result.exitCode, 2); + expect(result.stderr, contains('Config file')); + expect(result.stderr, contains('does not exist')); + }); + + test('target project config is not shadowed by the working directory', () { + final sandbox = Directory.systemTemp.createTempSync( + 'flutterguard_cli_config_scope_', + ); + addTearDown(() => sandbox.deleteSync(recursive: true)); + final workingProject = Directory(p.join(sandbox.path, 'working')) + ..createSync(recursive: true); + final targetProject = Directory(p.join(sandbox.path, 'target')) + ..createSync(recursive: true); + Directory(p.join(targetProject.path, 'lib')).createSync(); + File(p.join(targetProject.path, 'lib', 'target.dart')) + .writeAsStringSync('class Target {}\n'); + File(p.join(workingProject.path, 'flutterguard.yaml')).writeAsStringSync(''' +include: + - cwd_only/** +'''); + File(p.join(targetProject.path, 'flutterguard.yaml')).writeAsStringSync(''' +include: + - lib/** +'''); + + final entrypoint = p.join( + Directory.current.path, + 'bin', + 'flutterguard.dart', + ); + final result = Process.runSync( + Platform.resolvedExecutable, + [ + entrypoint, + 'scan', + targetProject.path, + '--format', + 'json', + '--output', + '.flutterguard/test', + '--no-color', + ], + workingDirectory: workingProject.path, + ); + + expect(result.exitCode, 0); + expect( + File(p.join( + targetProject.path, + '.flutterguard', + 'test', + 'report.json', + )).existsSync(), + isTrue, + ); + }); +} diff --git a/packages/flutterguard_cli/test/scanner_test.dart b/packages/flutterguard_cli/test/scanner_test.dart index d506b69..3610eac 100644 --- a/packages/flutterguard_cli/test/scanner_test.dart +++ b/packages/flutterguard_cli/test/scanner_test.dart @@ -1,8 +1,13 @@ +import 'dart:convert'; import 'dart:io'; +import 'package:flutterguard_cli/src/baseline.dart'; import 'package:flutterguard_cli/src/config_loader.dart'; +import 'package:flutterguard_cli/src/config_tools.dart'; import 'package:flutterguard_cli/src/domain.dart'; import 'package:flutterguard_cli/src/import_utils.dart'; +import 'package:flutterguard_cli/src/install_doctor.dart'; +import 'package:flutterguard_cli/src/issue_export.dart'; import 'package:flutterguard_cli/src/path_utils.dart'; import 'package:flutterguard_cli/src/priority.dart'; import 'package:flutterguard_cli/src/report_generator.dart'; @@ -17,13 +22,39 @@ import 'package:flutterguard_cli/src/rules/missing_const_constructor.dart'; import 'package:flutterguard_cli/src/rules/module_violation.dart'; import 'package:flutterguard_cli/src/rules/mqtt_connection.dart'; import 'package:flutterguard_cli/src/rules/pubspec_security.dart'; +import 'package:flutterguard_cli/src/rules/registry.dart'; +import 'package:flutterguard_cli/src/sarif_report.dart'; import 'package:flutterguard_cli/src/scanner.dart'; +import 'package:flutterguard_cli/src/source_workspace.dart'; import 'package:flutterguard_cli/src/static_issue.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; String get fixturesPath => p.join(Directory.current.path, 'test', 'fixtures'); +void _runGit(Directory dir, List args) { + final result = Process.runSync('git', args, workingDirectory: dir.path); + if (result.exitCode != 0) { + throw StateError('git ${args.join(' ')} failed: ${result.stderr}'); + } +} + +void _writeMinimalProjectConfig(Directory dir) { + File(p.join(dir.path, 'flutterguard.yaml')).writeAsStringSync(''' +include: + - lib/** +architecture: + detect_cycles: true +'''); +} + +void _writeWidgetIssue(String path, String className) { + File(path).writeAsStringSync(''' +class StatelessWidget {} +class $className extends StatelessWidget {} +'''); +} + void main() { group('Static Rules', () { test('scan detects large file', () { @@ -179,14 +210,15 @@ void main() { final issues = IotSecurityRule(config).analyze(files); - expect(issues.any((i) => i.metadata['securityCheck'] == 'hardcoded_secret'), + 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); + expect(issues.any((i) => i.metadata['securityCheck'] == 'insecure_ble'), + isTrue); }); test('scan detects device lifecycle issues', () { @@ -196,7 +228,8 @@ void main() { 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['initMethod'] == 'initState'), isTrue); expect( issues.any((i) => i.metadata['teardownMethod'] == 'dispose'), isTrue); }); @@ -208,10 +241,12 @@ void main() { 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); + 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', () { @@ -222,7 +257,8 @@ void main() { expect(issues.any((i) => i.id == 'ble_scanning'), isTrue); expect( - issues.any((i) => i.metadata['check'] == 'startScan_without_stopScan'), + issues + .any((i) => i.metadata['check'] == 'startScan_without_stopScan'), isTrue); }); @@ -338,6 +374,7 @@ dependencies: expect(json, contains('"version"')); expect(json, contains('"projectPath"')); + expect(json, contains('"scanMode"')); expect(json, contains('"score"')); expect(json, contains('"issues"')); expect(json, contains('"byDomain"')); @@ -354,9 +391,105 @@ dependencies: expect(report, contains('扫描文件: 3')); expect(report, contains('问题总数: ')); }); + + test('json summary includes suppression counters', () { + final json = ReportGenerator.generateJson( + projectPath: '/test', + issues: const [], + suppressedCount: 2, + suppressedByBaselineCount: 3, + diagnostics: const [ + ScanDiagnostic(stage: 'read', message: 'unreadable'), + ], + ); + final decoded = jsonDecode(json) as Map; + final summary = decoded['summary'] as Map; + + expect(summary['suppressed'], 2); + expect(summary['suppressedByBaseline'], 3); + expect(summary['diagnostics'], 1); + expect(decoded['diagnostics'], hasLength(1)); + }); }); group('Scanner Orchestration', () { + test('scanner allows built-in defaults when the default config is absent', + () { + final dir = + Directory.systemTemp.createTempSync('flutterguard_no_config_'); + addTearDown(() => dir.deleteSync(recursive: true)); + Directory(p.join(dir.path, 'lib')).createSync(); + File(p.join(dir.path, 'lib', 'plain.dart')) + .writeAsStringSync('class Plain {}\n'); + + final result = FlutterGuardScanner.scan(projectPath: dir.path); + + expect(result.files, [p.join(dir.path, 'lib', 'plain.dart')]); + }); + + test('scanner analyzes the project-root pubspec', () { + final dir = + Directory.systemTemp.createTempSync('flutterguard_root_pubspec_'); + addTearDown(() => dir.deleteSync(recursive: true)); + Directory(p.join(dir.path, 'lib')).createSync(); + File(p.join(dir.path, 'lib', 'plain.dart')) + .writeAsStringSync('class Plain {}\n'); + File(p.join(dir.path, 'pubspec.yaml')).writeAsStringSync(''' +name: root_pubspec_test +dependencies: + mqtt_client: ^9.0.0 +'''); + + final result = FlutterGuardScanner.scan(projectPath: dir.path); + + expect( + result.issues.where((issue) => issue.id == 'pubspec_security'), + isNotEmpty, + ); + }); + + test('source workspace retains read failures as diagnostics', () { + final workspace = SourceWorkspace(); + + expect(workspace.source(p.join(fixturesPath, 'missing.dart')), isNull); + expect(workspace.diagnostics, hasLength(1)); + expect(workspace.diagnostics.single.stage, 'read'); + }); + + test('scanner rejects a missing custom config', () { + final dir = + Directory.systemTemp.createTempSync('flutterguard_custom_config_'); + addTearDown(() => dir.deleteSync(recursive: true)); + + expect( + () => FlutterGuardScanner.scan( + projectPath: dir.path, + configPath: 'policy/flutterguard.yaml', + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('does not exist'), + ), + ), + ); + }); + + test('scanner rejects a missing explicitly selected default config', () { + final dir = + Directory.systemTemp.createTempSync('flutterguard_required_config_'); + addTearDown(() => dir.deleteSync(recursive: true)); + + expect( + () => FlutterGuardScanner.scan( + projectPath: dir.path, + configPath: 'flutterguard.yaml', + ), + throwsA(isA()), + ); + }); + test('scanner runs all configured rules and returns sorted result', () { final result = FlutterGuardScanner.scan( projectPath: Directory.current.path, @@ -390,6 +523,632 @@ dependencies: throwsA(isA()), ); }); + + test('config parser rejects a required file that is absent', () { + expect( + () => ScanConfig.fromFile( + p.join(fixturesPath, 'does_not_exist.yaml'), + requireFile: true, + ), + throwsA(isA()), + ); + }); + + test('same-line suppression filters matching rule only', () { + final dir = Directory.systemTemp.createTempSync('flutterguard_suppress_'); + addTearDown(() => dir.deleteSync(recursive: true)); + Directory(p.join(dir.path, 'lib')).createSync(recursive: true); + _writeMinimalProjectConfig(dir); + File(p.join(dir.path, 'lib', 'ignored.dart')).writeAsStringSync(''' +class StatelessWidget {} +class IgnoredWidget extends StatelessWidget {} // flutterguard: ignore missing_const_constructor +'''); + File(p.join(dir.path, 'lib', 'visible.dart')).writeAsStringSync(''' +class StatelessWidget {} +class VisibleWidget extends StatelessWidget {} // flutterguard: ignore large_file +'''); + + final result = FlutterGuardScanner.scan(projectPath: dir.path); + + expect(result.rawIssues, hasLength(2)); + expect(result.issues, hasLength(1)); + expect(result.suppressedCount, 1); + expect(result.issues.single.metadata['className'], 'VisibleWidget'); + }); + + test('previous-line ignore all filters next line issues', () { + final dir = + Directory.systemTemp.createTempSync('flutterguard_ignore_all_'); + addTearDown(() => dir.deleteSync(recursive: true)); + Directory(p.join(dir.path, 'lib')).createSync(recursive: true); + _writeMinimalProjectConfig(dir); + File(p.join(dir.path, 'lib', 'widget.dart')).writeAsStringSync(''' +class StatelessWidget {} +// flutterguard: ignore all +class IgnoredWidget extends StatelessWidget {} +'''); + + final result = FlutterGuardScanner.scan(projectPath: dir.path); + + expect(result.rawIssues, hasLength(1)); + expect(result.issues, isEmpty); + expect(result.suppressedCount, 1); + }); + + test('baseline filters matching fingerprints and leaves new issues visible', + () { + final dir = Directory.systemTemp.createTempSync('flutterguard_baseline_'); + addTearDown(() => dir.deleteSync(recursive: true)); + Directory(p.join(dir.path, 'lib')).createSync(recursive: true); + _writeMinimalProjectConfig(dir); + _writeWidgetIssue(p.join(dir.path, 'lib', 'old.dart'), 'OldWidget'); + + final initial = FlutterGuardScanner.scan( + projectPath: dir.path, + applySuppression: false, + ); + final baselinePath = p.join(dir.path, '.flutterguard', 'baseline.json'); + Directory(p.dirname(baselinePath)).createSync(recursive: true); + File(baselinePath).writeAsStringSync(Baseline.encode( + projectPath: initial.projectPath, + issues: initial.rawIssues, + )); + + _writeWidgetIssue(p.join(dir.path, 'lib', 'new.dart'), 'NewWidget'); + final result = FlutterGuardScanner.scan( + projectPath: dir.path, + baselinePath: '.flutterguard/baseline.json', + ); + + expect(result.rawIssues, hasLength(2)); + expect(result.suppressedByBaselineCount, 1); + expect(result.issues, hasLength(1)); + expect(result.issues.single.file, endsWith('new.dart')); + }); + + test('baseline stats prune and no-growth helpers work', () { + final dir = + Directory.systemTemp.createTempSync('flutterguard_baseline_tools_'); + addTearDown(() => dir.deleteSync(recursive: true)); + Directory(p.join(dir.path, 'lib')).createSync(recursive: true); + _writeMinimalProjectConfig(dir); + _writeWidgetIssue(p.join(dir.path, 'lib', 'old.dart'), 'OldWidget'); + + final initial = FlutterGuardScanner.scan( + projectPath: dir.path, + applySuppression: false, + ); + final staleIssue = StaticIssue( + id: 'missing_const_constructor', + title: 'Stale', + file: p.join(dir.path, 'lib', 'deleted.dart'), + line: 2, + level: RiskLevel.low, + domain: IssueDomain.standards, + priority: Priority.p2, + message: 'stale issue', + suggestion: 'fix', + ); + final baselineJson = Baseline.encode( + projectPath: initial.projectPath, + issues: [...initial.rawIssues, staleIssue], + ); + final baseline = Baseline.loadFromString(baselineJson); + + expect(baseline.fingerprints, hasLength(2)); + + final pruned = Baseline.loadFromString(Baseline.prune( + projectPath: initial.projectPath, + baseline: baseline, + issues: initial.rawIssues, + )); + expect(pruned.fingerprints, hasLength(1)); + + _writeWidgetIssue(p.join(dir.path, 'lib', 'new.dart'), 'NewWidget'); + final current = FlutterGuardScanner.scan( + projectPath: dir.path, + applySuppression: false, + ); + final newFingerprints = Baseline.newFingerprints( + projectPath: current.projectPath, + baseline: pruned, + issues: current.rawIssues, + ); + expect(newFingerprints, hasLength(1)); + }); + + test('missing baseline file fails the scan', () { + final dir = Directory.systemTemp.createTempSync('flutterguard_no_base_'); + addTearDown(() => dir.deleteSync(recursive: true)); + Directory(p.join(dir.path, 'lib')).createSync(recursive: true); + _writeMinimalProjectConfig(dir); + _writeWidgetIssue(p.join(dir.path, 'lib', 'one.dart'), 'OneWidget'); + + expect( + () => FlutterGuardScanner.scan( + projectPath: dir.path, + baselinePath: '.flutterguard/missing.json', + ), + throwsA(isA()), + ); + }); + + test('sarif report contains rules results severity and line fallback', () { + final issues = [ + StaticIssue( + id: 'iot_security', + title: 'High', + file: p.join('/repo', 'lib', 'a.dart'), + line: 12, + level: RiskLevel.high, + domain: IssueDomain.standards, + priority: Priority.p0, + message: 'high issue', + suggestion: 'fix', + ), + StaticIssue( + id: 'ble_scanning', + title: 'Medium', + file: p.join('/repo', 'lib', 'b.dart'), + level: RiskLevel.medium, + domain: IssueDomain.performance, + priority: Priority.p1, + message: 'medium issue', + suggestion: 'fix', + ), + StaticIssue( + id: 'missing_const_constructor', + title: 'Low', + file: p.join('/repo', 'lib', 'c.dart'), + line: 3, + level: RiskLevel.low, + domain: IssueDomain.standards, + priority: Priority.p2, + message: 'low issue', + suggestion: 'fix', + ), + ]; + + final decoded = jsonDecode(SarifReport.generate( + projectPath: '/repo', + issues: issues, + )) as Map; + final runs = decoded['runs'] as List; + final run = runs.single as Map; + final results = run['results'] as List; + + expect(decoded['version'], '2.1.0'); + expect(jsonEncode(run), contains('"rules"')); + expect(results.map((r) => (r as Map)['level']), [ + 'error', + 'warning', + 'note', + ]); + final second = results[1] as Map; + final locations = second['locations'] as List; + final physical = (locations.single as Map)['physicalLocation'] as Map; + final region = physical['region'] as Map; + expect(region['startLine'], 1); + expect(jsonEncode(second), contains('lib/b.dart')); + }); + + test('issue export includes rule metadata context and fingerprint', () { + final dir = Directory.systemTemp.createTempSync('flutterguard_export_'); + addTearDown(() => dir.deleteSync(recursive: true)); + Directory(p.join(dir.path, 'lib')).createSync(recursive: true); + _writeMinimalProjectConfig(dir); + final file = p.join(dir.path, 'lib', 'widget.dart'); + _writeWidgetIssue(file, 'ExportedWidget'); + + final result = FlutterGuardScanner.scan( + projectPath: dir.path, + applySuppression: false, + ); + final exported = IssueExporter.export( + projectPath: result.projectPath, + issues: result.rawIssues, + ruleId: 'missing_const_constructor', + filePath: 'lib/widget.dart', + line: 2, + ); + final decoded = jsonDecode(exported) as Map; + + expect(decoded['fingerprint'], isA()); + expect( + jsonEncode(decoded['rule']), contains('missing_const_constructor')); + expect(jsonEncode(decoded['context']), contains('ExportedWidget')); + expect(jsonEncode(decoded['issue']), contains('lib/widget.dart')); + }); + }); + + group('Changed-only mode', () { + test('changed_only_filters_files', () { + final dir = Directory.systemTemp.createTempSync('flutterguard_changed_'); + addTearDown(() => dir.deleteSync(recursive: true)); + Directory(p.join(dir.path, 'lib')).createSync(recursive: true); + _writeMinimalProjectConfig(dir); + final changedFile = p.join(dir.path, 'lib', 'changed 设备.dart'); + final unchangedFile = p.join(dir.path, 'lib', 'unchanged.dart'); + _writeWidgetIssue(changedFile, 'ChangedWidget'); + _writeWidgetIssue(unchangedFile, 'UnchangedWidget'); + + _runGit(dir, ['init', '-b', 'main']); + _runGit(dir, ['config', 'user.email', 'test@example.com']); + _runGit(dir, ['config', 'user.name', 'FlutterGuard Test']); + _runGit(dir, ['add', '.']); + _runGit(dir, ['commit', '-m', 'initial']); + File(changedFile).writeAsStringSync(''' +class StatelessWidget {} +class ChangedWidget extends StatelessWidget {} +class AnotherChangedWidget extends StatelessWidget {} +'''); + + final result = FlutterGuardScanner.scan( + projectPath: dir.path, + changedOnly: true, + base: 'main', + ); + + expect(result.scanMode, 'changed'); + expect(result.files, [changedFile]); + expect(result.issues, isNotEmpty); + expect(result.issues.every((i) => i.file == changedFile), isTrue); + }); + + test('changed_only resolves imports to unchanged architecture targets', () { + final dir = Directory.systemTemp.createTempSync( + 'flutterguard_changed_boundary_', + ); + addTearDown(() => dir.deleteSync(recursive: true)); + Directory(p.join(dir.path, 'lib', 'ui')).createSync(recursive: true); + Directory(p.join(dir.path, 'lib', 'data')).createSync(recursive: true); + File(p.join(dir.path, 'flutterguard.yaml')).writeAsStringSync(''' +include: + - lib/** +architecture: + layers: + - name: ui + path: lib/ui/** + allowed_deps: [] + - name: data + path: lib/data/** + allowed_deps: [] +'''); + final source = p.join(dir.path, 'lib', 'ui', 'screen.dart'); + final target = p.join(dir.path, 'lib', 'data', 'repository.dart'); + File(source).writeAsStringSync( + "import '../data/repository.dart';\nclass Screen {}\n", + ); + File(target).writeAsStringSync('class Repository {}\n'); + _runGit(dir, ['init', '-b', 'main']); + _runGit(dir, ['config', 'user.email', 'test@example.com']); + _runGit(dir, ['config', 'user.name', 'FlutterGuard Test']); + _runGit(dir, ['add', '.']); + _runGit(dir, ['commit', '-m', 'initial']); + File(source).writeAsStringSync( + "import '../data/repository.dart';\nclass Screen {}\n// changed\n", + ); + + final result = FlutterGuardScanner.scan( + projectPath: dir.path, + changedOnly: true, + base: 'main', + ); + + expect(result.files, [source]); + expect( + result.issues.where((issue) => issue.id == 'layer_violation'), + hasLength(1), + ); + }); + + test('changed_only_full_scan_when_no_git', () { + final dir = Directory.systemTemp.createTempSync('flutterguard_no_git_'); + addTearDown(() => dir.deleteSync(recursive: true)); + Directory(p.join(dir.path, 'lib')).createSync(recursive: true); + _writeMinimalProjectConfig(dir); + File(p.join(dir.path, 'lib', 'one.dart')).writeAsStringSync( + "import 'two.dart';\nclass One {}\n", + ); + File(p.join(dir.path, 'lib', 'two.dart')).writeAsStringSync( + "import 'one.dart';\nclass Two {}\n", + ); + + final result = FlutterGuardScanner.scan( + projectPath: dir.path, + changedOnly: true, + ); + + expect(result.scanMode, 'full'); + expect(result.files, hasLength(2)); + expect( + result.issues.where((issue) => issue.id == 'circular_dependency'), + isNotEmpty, + ); + }); + + test('changed_only clean repository returns an empty changed scan', () { + final dir = Directory.systemTemp.createTempSync('flutterguard_clean_'); + addTearDown(() => dir.deleteSync(recursive: true)); + Directory(p.join(dir.path, 'lib')).createSync(recursive: true); + _writeMinimalProjectConfig(dir); + File(p.join(dir.path, 'lib', 'clean.dart')) + .writeAsStringSync('class Clean {}\n'); + _runGit(dir, ['init', '-b', 'main']); + _runGit(dir, ['config', 'user.email', 'test@example.com']); + _runGit(dir, ['config', 'user.name', 'FlutterGuard Test']); + _runGit(dir, ['add', '.']); + _runGit(dir, ['commit', '-m', 'initial']); + + final result = FlutterGuardScanner.scan( + projectPath: dir.path, + changedOnly: true, + base: 'main', + ); + + expect(result.scanMode, 'changed'); + expect(result.files, isEmpty); + expect(result.issues, isEmpty); + }); + + test('changed_only runs project rules when pubspec changes', () { + final dir = Directory.systemTemp.createTempSync( + 'flutterguard_changed_pubspec_', + ); + addTearDown(() => dir.deleteSync(recursive: true)); + Directory(p.join(dir.path, 'lib')).createSync(recursive: true); + _writeMinimalProjectConfig(dir); + File(p.join(dir.path, 'lib', 'clean.dart')) + .writeAsStringSync('class Clean {}\n'); + final pubspec = File(p.join(dir.path, 'pubspec.yaml')) + ..writeAsStringSync('name: changed_pubspec_test\n'); + _runGit(dir, ['init', '-b', 'main']); + _runGit(dir, ['config', 'user.email', 'test@example.com']); + _runGit(dir, ['config', 'user.name', 'FlutterGuard Test']); + _runGit(dir, ['add', '.']); + _runGit(dir, ['commit', '-m', 'initial']); + pubspec.writeAsStringSync(''' +name: changed_pubspec_test +dependencies: + mqtt_client: ^9.0.0 +'''); + + final result = FlutterGuardScanner.scan( + projectPath: dir.path, + changedOnly: true, + base: 'main', + ); + + expect(result.files, isEmpty); + expect( + result.issues.where((issue) => issue.id == 'pubspec_security'), + isNotEmpty, + ); + }); + + test('changed_only rejects invalid or option-like base refs', () { + final dir = Directory.systemTemp.createTempSync( + 'flutterguard_invalid_base_', + ); + addTearDown(() => dir.deleteSync(recursive: true)); + Directory(p.join(dir.path, 'lib')).createSync(recursive: true); + _writeMinimalProjectConfig(dir); + File(p.join(dir.path, 'lib', 'tracked.dart')) + .writeAsStringSync('class Tracked {}\n'); + _runGit(dir, ['init', '-b', 'main']); + _runGit(dir, ['config', 'user.email', 'test@example.com']); + _runGit(dir, ['config', 'user.name', 'FlutterGuard Test']); + _runGit(dir, ['add', '.']); + _runGit(dir, ['commit', '-m', 'initial']); + + expect( + () => FlutterGuardScanner.scan( + projectPath: dir.path, + changedOnly: true, + base: 'missing-ref', + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Invalid Git base'), + ), + ), + ); + expect( + () => FlutterGuardScanner.scan( + projectPath: dir.path, + changedOnly: true, + base: '--cached', + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Invalid Git base'), + ), + ), + ); + }); + + test('changed_only_skips_circular_dependency', () { + final dir = Directory.systemTemp.createTempSync('flutterguard_cycle_'); + addTearDown(() => dir.deleteSync(recursive: true)); + Directory(p.join(dir.path, 'lib')).createSync(recursive: true); + _writeMinimalProjectConfig(dir); + File(p.join(dir.path, 'lib', 'a.dart')).writeAsStringSync( + "import 'b.dart';\nclass A {}\n", + ); + File(p.join(dir.path, 'lib', 'b.dart')).writeAsStringSync( + "import 'c.dart';\nclass B {}\n", + ); + File(p.join(dir.path, 'lib', 'c.dart')).writeAsStringSync( + "import 'a.dart';\nclass C {}\n", + ); + _runGit(dir, ['init', '-b', 'main']); + _runGit(dir, ['config', 'user.email', 'test@example.com']); + _runGit(dir, ['config', 'user.name', 'FlutterGuard Test']); + _runGit(dir, ['commit', '--allow-empty', '-m', 'initial']); + + final result = FlutterGuardScanner.scan( + projectPath: dir.path, + changedOnly: true, + base: 'main', + ); + + expect(result.scanMode, 'changed'); + expect(result.files, hasLength(3)); + expect( + result.issues.where((i) => i.id == 'circular_dependency'), + isEmpty, + ); + }); + }); + + group('Rules Registry', () { + test('registry_contains_all_13_rules', () { + expect(RuleRegistry.all(), hasLength(13)); + }); + + test('registry_find_returns_correct_meta', () { + final meta = RuleRegistry.find('large_file'); + + expect(meta, isNotNull); + expect(meta!.id, 'large_file'); + expect(meta.domain, 'standards'); + }); + + test('registry_find_unknown_returns_null', () { + expect(RuleRegistry.find('nonexistent'), isNull); + }); + }); + + group('Config Tools', () { + test('init template includes optional architecture block', () { + final basic = ConfigTools.initTemplate(withArchitecture: false); + final withArchitecture = ConfigTools.initTemplate(withArchitecture: true); + + expect(basic, contains('large_file:')); + expect(basic, isNot(contains('architecture:'))); + expect(withArchitecture, contains('architecture:')); + expect(withArchitecture, contains('mqtt_feature')); + }); + + test('init template profiles tune rule defaults', () { + final migration = ConfigTools.initTemplate( + withArchitecture: false, + profile: 'migration', + ); + final security = ConfigTools.initTemplate( + withArchitecture: false, + profile: 'iot-security', + ); + + expect(migration, contains('maxLines: 800')); + expect(migration, contains('missing_const_constructor:')); + expect(migration, contains('enabled: false')); + expect(security, contains('iot_security:')); + expect(security, contains('requireTls: true')); + expect( + () => ConfigTools.initTemplate( + withArchitecture: false, + profile: 'unknown', + ), + throwsA(isA()), + ); + }); + + test('effective config print includes merged defaults', () { + final config = ScanConfig.fromFile( + p.join(fixturesPath, 'does_not_exist.yaml'), + ); + + final yaml = ConfigTools.effectiveYaml(config); + + expect(yaml, contains('include:')); + expect(yaml, contains('iot_security:')); + expect(yaml, contains('maxScanDurationMs: 10000')); + expect(yaml, contains('detect_cycles: false')); + }); + + test('doctor reports unknown architecture dependencies', () { + final dir = Directory.systemTemp.createTempSync('flutterguard_doctor_'); + addTearDown(() => dir.deleteSync(recursive: true)); + Directory(p.join(dir.path, 'lib', 'presentation')) + .createSync(recursive: true); + File(p.join(dir.path, 'lib', 'presentation', 'page.dart')) + .writeAsStringSync('class Page {}\n'); + File(p.join(dir.path, 'flutterguard.yaml')).writeAsStringSync(''' +include: + - lib/** +architecture: + layers: + - name: presentation + path: lib/presentation/** + allowed_deps: [domain] +'''); + + final result = ConfigTools.doctor( + projectPath: dir.path, + configPath: 'flutterguard.yaml', + ); + + expect(result.hasErrors, isTrue); + expect( + result.messages.any((message) => + message.severity == DoctorSeverity.error && + message.message.contains('unknown dependency "domain"')), + isTrue, + ); + }); + + test('doctor rejects configs that match no Dart files', () { + final dir = Directory.systemTemp.createTempSync('flutterguard_empty_'); + addTearDown(() => dir.deleteSync(recursive: true)); + File(p.join(dir.path, 'pubspec.yaml')).writeAsStringSync('name: app\n'); + File(p.join(dir.path, 'flutterguard.yaml')).writeAsStringSync(''' +include: + - lib/** +'''); + + final result = ConfigTools.doctor( + projectPath: dir.path, + configPath: 'flutterguard.yaml', + ); + + expect(result.hasErrors, isTrue); + expect( + result.messages.any((message) => + message.severity == DoctorSeverity.error && + message.message.contains('No Dart files matched')), + isTrue, + ); + }); + + test('config tools prefer project config for default config name', () { + final dir = Directory.systemTemp.createTempSync('flutterguard_config_'); + addTearDown(() => dir.deleteSync(recursive: true)); + File(p.join(dir.path, 'pubspec.yaml')).writeAsStringSync('name: app\n'); + File(p.join(dir.path, 'flutterguard.yaml')).writeAsStringSync(''' +include: + - custom_lib/** +'''); + + final resolved = ConfigTools.resolveConfigPathForProject( + projectPath: dir.path, + configPath: 'flutterguard.yaml', + ); + + expect(resolved, p.join(dir.path, 'flutterguard.yaml')); + }); + + test('install doctor reports version and path checks', () { + final report = InstallDoctor.generate(version: '0.4.0-test'); + + expect(report, contains('FlutterGuard install doctor')); + expect(report, contains('0.4.0-test')); + expect(report, contains('PATH entries named flutterguard')); + }); }); group('Path handling', () { diff --git a/pubspec.yaml b/pubspec.yaml index b800eba..535975c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: flutterguard publish_to: none environment: - sdk: ">=3.3.0 <4.0.0" + sdk: ^3.11.5 dependencies: flutterguard_cli: diff --git a/scripts/flutterguard-dev b/scripts/flutterguard-dev new file mode 100755 index 0000000..dc742c2 --- /dev/null +++ b/scripts/flutterguard-dev @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +exec dart "$ROOT_DIR/packages/flutterguard_cli/bin/flutterguard.dart" "$@" diff --git a/scripts/flutterguard-dev.ps1 b/scripts/flutterguard-dev.ps1 new file mode 100644 index 0000000..e452889 --- /dev/null +++ b/scripts/flutterguard-dev.ps1 @@ -0,0 +1,5 @@ +$ErrorActionPreference = "Stop" + +$rootDir = Split-Path -Parent $PSScriptRoot +$entrypoint = Join-Path $rootDir "packages\flutterguard_cli\bin\flutterguard.dart" +dart $entrypoint @args diff --git a/scripts/package_release.ps1 b/scripts/package_release.ps1 new file mode 100644 index 0000000..24a038f --- /dev/null +++ b/scripts/package_release.ps1 @@ -0,0 +1,26 @@ +$ErrorActionPreference = "Stop" + +$rootDir = Split-Path -Parent $PSScriptRoot +$pubspec = Join-Path $rootDir "packages\flutterguard_cli\pubspec.yaml" +$versionLine = Select-String -Path $pubspec -Pattern "^version:" | Select-Object -First 1 +$version = ($versionLine.Line -split "\s+")[1] +$src = Join-Path $rootDir "packages\flutterguard_cli\bin\flutterguard.dart" +$dist = Join-Path $rootDir "dist" +$name = "flutterguard-$version-windows-x64" +$outDir = Join-Path $dist $name +$bin = Join-Path $outDir "flutterguard.exe" +$archive = Join-Path $dist "$name.zip" + +if (Test-Path $outDir) { + Remove-Item -Recurse -Force $outDir +} +New-Item -ItemType Directory -Force -Path $outDir | Out-Null + +dart compile exe $src -o $bin +& $bin --version + +if (Test-Path $archive) { + Remove-Item -Force $archive +} +Compress-Archive -Path $outDir -DestinationPath $archive +Write-Host "Release artifact: $archive" diff --git a/scripts/package_release.sh b/scripts/package_release.sh new file mode 100755 index 0000000..ac0caf7 --- /dev/null +++ b/scripts/package_release.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +VERSION="$(grep '^version:' "$ROOT_DIR/packages/flutterguard_cli/pubspec.yaml" | awk '{print $2}')" +SRC="$ROOT_DIR/packages/flutterguard_cli/bin/flutterguard.dart" +DIST="$ROOT_DIR/dist" + +case "$(uname -s)" in + Darwin) OS="macos" ;; + Linux) OS="linux" ;; + *) echo "Unsupported OS: $(uname -s)" >&2; exit 1 ;; +esac + +case "$(uname -m)" in + arm64|aarch64) ARCH="arm64" ;; + x86_64|amd64) ARCH="x64" ;; + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; +esac + +NAME="flutterguard-${VERSION}-${OS}-${ARCH}" +BIN="$DIST/$NAME/flutterguard" +ARCHIVE="$DIST/$NAME.tar.gz" + +rm -rf "$DIST/$NAME" +mkdir -p "$DIST/$NAME" + +dart compile exe "$SRC" -o "$BIN" +chmod +x "$BIN" +"$BIN" --version + +tar -czf "$ARCHIVE" -C "$DIST" "$NAME" +echo "Release artifact: $ARCHIVE" diff --git a/scripts/scan_ci.ps1 b/scripts/scan_ci.ps1 index 2516f83..2c0bd08 100644 --- a/scripts/scan_ci.ps1 +++ b/scripts/scan_ci.ps1 @@ -2,7 +2,9 @@ $ErrorActionPreference = "Stop" $failOn = $env:FLUTTERGUARD_FAIL_ON ?? "high" $minScore = $env:FLUTTERGUARD_MIN_SCORE ?? "80" -$target = if ($args.Count -gt 0) { $args[0] } else { "." } +$rootDir = Split-Path -Parent $PSScriptRoot +$target = if ($args.Count -gt 0) { $args[0] } else { Join-Path $rootDir "examples\scan_demo" } +$launcher = Join-Path $PSScriptRoot "flutterguard-dev.ps1" Write-Host "==> FlutterGuard CI Scan" -ForegroundColor Cyan Write-Host " Target: $target" @@ -10,13 +12,18 @@ Write-Host " Fail-on: $failOn" Write-Host " Min-score: $minScore" Write-Host "" -flutterguard scan $target --format json --fail-on $failOn --min-score $minScore +& $launcher scan $target --format json --fail-on $failOn --min-score $minScore +$status = $LASTEXITCODE -if ($LASTEXITCODE -eq 0) { +if ($status -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 + if ($status -eq 1) { + Write-Host "CI gate failed! Check $target/.flutterguard/report.json for details." -ForegroundColor Red + } else { + Write-Host "FlutterGuard scan setup failed with exit code $status." -ForegroundColor Red + } + exit $status } diff --git a/scripts/scan_ci.sh b/scripts/scan_ci.sh index 5dfe09d..2c64ead 100755 --- a/scripts/scan_ci.sh +++ b/scripts/scan_ci.sh @@ -1,9 +1,11 @@ #!/usr/bin/env bash set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" FAIL_ON="${FLUTTERGUARD_FAIL_ON:-high}" MIN_SCORE="${FLUTTERGUARD_MIN_SCORE:-80}" -TARGET="${1:-.}" +TARGET="${1:-$ROOT_DIR/examples/scan_demo}" echo "==> FlutterGuard CI Scan" echo " Target: $TARGET" @@ -11,13 +13,16 @@ 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 +if "$SCRIPT_DIR/flutterguard-dev" scan "$TARGET" --format json --fail-on "$FAIL_ON" --min-score "$MIN_SCORE"; then echo "" echo "All checks passed!" else + status=$? echo "" - echo "CI gate failed! Check .flutterguard/report.json for details." - exit 1 + if [ "$status" -eq 1 ]; then + echo "CI gate failed! Check $TARGET/.flutterguard/report.json for details." + else + echo "FlutterGuard scan setup failed with exit code $status." + fi + exit "$status" fi