From f0b119221135d0602309bac131d42f321dd9a857 Mon Sep 17 00:00:00 2001 From: forest Date: Tue, 23 Jun 2026 09:05:39 +0800 Subject: [PATCH 01/19] docs: clarify configuration strategy --- CONFIGURATION_STRATEGY.md | 168 ++++++++++++++++++ README.md | 11 +- README.zh.md | 11 +- packages/flutterguard_cli/README.md | 12 +- .../flutterguard_cli/bin/flutterguard.dart | 38 +++- 5 files changed, 228 insertions(+), 12 deletions(-) create mode 100644 CONFIGURATION_STRATEGY.md diff --git a/CONFIGURATION_STRATEGY.md b/CONFIGURATION_STRATEGY.md new file mode 100644 index 0000000..a7e102b --- /dev/null +++ b/CONFIGURATION_STRATEGY.md @@ -0,0 +1,168 @@ +# 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. + +### 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. + +### 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 + +### Level 3: Architecture Config + +Best when the project has agreed boundaries. + +```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 +- no files found: mention project path plus include/exclude patterns +- 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. + +## Future Enhancements + +The next usability improvements should be command-level helpers: + +- `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 +the scan output focused while still making configuration discoverable. diff --git a/README.md b/README.md index 899a13c..96b6cf1 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 @@ -215,6 +215,15 @@ 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. Add a basic config only when you need custom thresholds or excludes. +3. Add CI gates with `--format json --fail-on high --min-score 80`. +4. 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 diff --git a/README.zh.md b/README.zh.md index f11e6ce..314cdf3 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) ## 它是什么 @@ -214,6 +214,15 @@ FlutterGuard 从当前目录向上遍历,自动发现项目根目录(查找 在项目根目录创建 `flutterguard.yaml`。 +推荐策略: + +1. 先零配置运行:`flutterguard scan`。 +2. 只有需要自定义阈值或排除文件时,再添加基础配置。 +3. CI 场景使用 `--format json --fail-on high --min-score 80`。 +4. 只有项目边界已经明确时,再添加 architecture layers/modules。 + +完整决策模型见 [配置策略](CONFIGURATION_STRATEGY.md)。 + ### 基础配置(大多数用户适用) ```yaml diff --git a/packages/flutterguard_cli/README.md b/packages/flutterguard_cli/README.md index 9ebebb8..49d989f 100644 --- a/packages/flutterguard_cli/README.md +++ b/packages/flutterguard_cli/README.md @@ -20,7 +20,7 @@ flutterguard scan -p /path/to/flutter_project flutterguard scan -p . --format json --fail-on high --min-score 80 ``` -## Checks (8 rule IDs) +## Checks | Rule | Level | What it checks | |------|-------|----------------| @@ -32,11 +32,19 @@ 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 add YAML only when you need custom thresholds, +excludes, CI gates, or explicit architecture boundaries. + ```yaml rules: large_file: @@ -60,7 +68,7 @@ architecture: detect_cycles: true ``` -If no config file exists, defaults are used (all rules enabled, no architecture constraints). +If no config file exists, defaults are used (all default rules enabled, no architecture constraints). ## CLI Reference diff --git a/packages/flutterguard_cli/bin/flutterguard.dart b/packages/flutterguard_cli/bin/flutterguard.dart index dbb0fa0..7fe788a 100644 --- a/packages/flutterguard_cli/bin/flutterguard.dart +++ b/packages/flutterguard_cli/bin/flutterguard.dart @@ -31,8 +31,7 @@ void main(List args) { help: 'Show detailed output with code context', negatable: false) ..addFlag('no-color', - help: 'Disable ANSI terminal colors', - negatable: false) + help: 'Disable ANSI terminal colors', negatable: false) ..addOption('fail-on', defaultsTo: 'none', allowed: ['none', 'high', 'medium', 'low'], @@ -133,7 +132,11 @@ void _handleScan(ArgResults args) { } if (result.files.isEmpty) { - stderr.writeln('No Dart files found. Check your include/exclude patterns.'); + stderr.writeln('No Dart files found.'); + stderr.writeln( + 'Check that the project path is correct and that include/exclude patterns match Dart files.'); + stderr.writeln( + 'Typical config: include: [lib/**], exclude generated files, then add architecture rules only when boundaries are known.'); exit(0); } @@ -178,18 +181,25 @@ 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(); 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 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(); + 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 +207,19 @@ 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. CI config: add --format json --fail-on high --min-score 80.'); + stdout.writeln( + ' 4. Architecture config: declare layers/modules explicitly before enabling boundary gates.'); + stdout.writeln(); + stdout.writeln('Docs: README.md and CONFIGURATION_STRATEGY.md'); } From aafb464a042d0c4986caa5a6100f94eb2f0be322 Mon Sep 17 00:00:00 2001 From: forest Date: Tue, 23 Jun 2026 09:14:23 +0800 Subject: [PATCH 02/19] feat: add configuration tooling commands --- CONFIGURATION_STRATEGY.md | 22 +- README.md | 18 +- README.zh.md | 18 +- packages/flutterguard_cli/README.md | 16 +- .../flutterguard_cli/bin/flutterguard.dart | 179 +++++++ .../lib/src/config_tools.dart | 445 ++++++++++++++++++ .../flutterguard_cli/test/scanner_test.dart | 118 ++++- 7 files changed, 795 insertions(+), 21 deletions(-) create mode 100644 packages/flutterguard_cli/lib/src/config_tools.dart diff --git a/CONFIGURATION_STRATEGY.md b/CONFIGURATION_STRATEGY.md index a7e102b..258b3af 100644 --- a/CONFIGURATION_STRATEGY.md +++ b/CONFIGURATION_STRATEGY.md @@ -37,7 +37,7 @@ Behavior: - 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. +architecture boundaries yet. No YAML file is required. ### Level 1: Basic Project Config @@ -83,6 +83,13 @@ rules: 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. @@ -96,11 +103,18 @@ 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: @@ -154,9 +168,9 @@ CLI responses should stay short and operational: Avoid long explanations in command output. Point to this guide when the user needs examples or architecture detail. -## Future Enhancements +## Config Tooling -The next usability improvements should be command-level helpers: +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 @@ -165,4 +179,4 @@ The next usability improvements should be command-level helpers: architecture overlap These commands are preferable to adding more prose to `scan`, because they keep -the scan output focused while still making configuration discoverable. +scan output focused while still making configuration discoverable. diff --git a/README.md b/README.md index 96b6cf1..4a8a8aa 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,13 @@ flutterguard --help # Scan the current directory flutterguard scan +# Create and validate a starter config +flutterguard init +flutterguard config doctor + +# 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 @@ -174,6 +181,10 @@ Commands: | Command | Description | |---------|-------------| | `flutterguard scan []` | Scan a project (path defaults to current directory) | +| `flutterguard init` | Create a starter `flutterguard.yaml` | +| `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 --help` / `-h` | Show usage | | `flutterguard --version` / `-V` | Show version | @@ -218,9 +229,10 @@ Create `flutterguard.yaml` in your project root. Recommended strategy: 1. Start with zero config: `flutterguard scan`. -2. Add a basic config only when you need custom thresholds or excludes. -3. Add CI gates with `--format json --fail-on high --min-score 80`. -4. Add architecture layers/modules only after project boundaries are agreed. +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). diff --git a/README.zh.md b/README.zh.md index 314cdf3..d959f50 100644 --- a/README.zh.md +++ b/README.zh.md @@ -142,6 +142,13 @@ flutterguard --help # 扫描当前目录 flutterguard scan +# 创建并检查基础配置 +flutterguard init +flutterguard config doctor + +# 查看合并后的有效配置 +flutterguard config print + # 扫描指定项目 flutterguard scan ./my_flutter_app # macOS / Linux flutterguard scan .\my_flutter_app # Windows @@ -173,6 +180,10 @@ flutterguard scan examples/scan_demo | 命令 | 说明 | |------|------| | `flutterguard scan []` | 扫描项目(路径默认为当前目录) | +| `flutterguard init` | 创建基础 `flutterguard.yaml` | +| `flutterguard init --with-architecture` | 创建包含架构层/模块模板的配置 | +| `flutterguard config print` | 输出合并后的有效配置 | +| `flutterguard config doctor` | 检查配置、glob 和架构引用 | | `flutterguard --help` / `-h` | 显示帮助 | | `flutterguard --version` / `-V` | 显示版本 | @@ -217,9 +228,10 @@ FlutterGuard 从当前目录向上遍历,自动发现项目根目录(查找 推荐策略: 1. 先零配置运行:`flutterguard scan`。 -2. 只有需要自定义阈值或排除文件时,再添加基础配置。 -3. CI 场景使用 `--format json --fail-on high --min-score 80`。 -4. 只有项目边界已经明确时,再添加 architecture layers/modules。 +2. 需要自定义阈值或排除文件时,运行 `flutterguard init`。 +3. 使用 `flutterguard config print` 查看合并后的默认值。 +4. 启用 CI 门禁前先运行 `flutterguard config doctor`。 +5. 只有项目边界已经明确时,再添加 architecture layers/modules。 完整决策模型见 [配置策略](CONFIGURATION_STRATEGY.md)。 diff --git a/packages/flutterguard_cli/README.md b/packages/flutterguard_cli/README.md index 49d989f..539be56 100644 --- a/packages/flutterguard_cli/README.md +++ b/packages/flutterguard_cli/README.md @@ -16,6 +16,10 @@ dart pub global activate flutterguard_cli # Scan a Flutter project flutterguard scan -p /path/to/flutter_project +# Create and validate a starter config +flutterguard init +flutterguard config doctor + # JSON output with CI gate flutterguard scan -p . --format json --fail-on high --min-score 80 ``` @@ -42,8 +46,10 @@ flutterguard scan -p . --format json --fail-on high --min-score 80 Create `flutterguard.yaml` in your project root: -Start without config, then add YAML only when you need custom thresholds, -excludes, CI gates, or explicit architecture boundaries. +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: @@ -73,7 +79,7 @@ If no config file exists, defaults are used (all default rules enabled, no archi ## 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) @@ -81,6 +87,10 @@ flutterguard scan [options] -v, --verbose Show detailed context --fail-on CI gate: none | high | medium | low --min-score Minimum score threshold 0-100 + +flutterguard init [--with-architecture] [--force] +flutterguard config print +flutterguard config doctor ``` Exit codes: `0` success, `1` CI gate failed, `2` scan error. diff --git a/packages/flutterguard_cli/bin/flutterguard.dart b/packages/flutterguard_cli/bin/flutterguard.dart index 7fe788a..fe48a48 100644 --- a/packages/flutterguard_cli/bin/flutterguard.dart +++ b/packages/flutterguard_cli/bin/flutterguard.dart @@ -2,6 +2,9 @@ import 'dart:io'; import 'package:args/args.dart'; +import 'package:flutterguard_cli/src/config_loader.dart'; +import 'package:flutterguard_cli/src/config_tools.dart'; +import 'package:flutterguard_cli/src/project_resolver.dart'; import 'package:flutterguard_cli/src/report_generator.dart'; import 'package:flutterguard_cli/src/scanner.dart'; @@ -39,8 +42,44 @@ void main(List args) { ..addOption('min-score', help: 'Minimum score threshold (0-100)') ..addFlag('help', abbr: 'h', help: 'Show scan usage', negatable: false); + final initParser = 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) + ..addFlag('force', + help: 'Overwrite an existing config file', negatable: false) + ..addFlag('help', abbr: 'h', help: 'Show init usage', negatable: false); + + final configPrintParser = 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 print usage', negatable: false); + + final configDoctorParser = 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 doctor usage', negatable: false); + + final configParser = ArgParser() + ..addCommand('print', configPrintParser) + ..addCommand('doctor', configDoctorParser) + ..addFlag('help', abbr: 'h', help: 'Show config usage', negatable: false); + final parser = ArgParser() ..addCommand('scan', scanParser) + ..addCommand('init', initParser) + ..addCommand('config', configParser) ..addFlag('help', abbr: 'h', help: 'Show usage', negatable: false) ..addFlag('version', abbr: 'V', help: 'Show version', negatable: false); @@ -69,6 +108,15 @@ void main(List args) { exit(0); } _handleScan(command); + } 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 { _printUsage(parser); exit(0); @@ -81,6 +129,89 @@ void main(List args) { } } +void _handleInit(ArgResults args) { + try { + final outputPath = ConfigTools.writeInitConfig( + projectPath: args['path'] as String, + configPath: args['config'] as String, + withArchitecture: args['with-architecture'] as bool, + force: args['force'] as bool, + ); + stdout.writeln('Created FlutterGuard config: $outputPath'); + stdout.writeln('Next: flutterguard config doctor -p ${args['path']}'); + } on StateError catch (e) { + stderr.writeln('Error: ${e.message}'); + exit(2); + } +} + +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) { + try { + final projectPath = ProjectResolver.resolveProjectPath( + args['path'] as String, + ); + final configPath = ConfigTools.resolveConfigPathForProject( + projectPath: projectPath, + configPath: args['config'] as String, + ); + final config = ScanConfig.fromFile(configPath); + stdout.write(ConfigTools.effectiveYaml(config)); + } on FormatException catch (e) { + stderr.writeln('Error: ${e.message}'); + exit(2); + } +} + +void _handleConfigDoctor(ArgResults args) { + try { + final result = ConfigTools.doctor( + projectPath: args['path'] as String, + configPath: args['config'] as String, + ); + stdout.write(ConfigTools.formatDoctorResult(result)); + if (result.hasErrors) exit(1); + } on StateError catch (e) { + stderr.writeln('Error: ${e.message}'); + exit(2); + } on FormatException catch (e) { + stderr.writeln('Error: ${e.message}'); + exit(2); + } +} + List _extractPositionalPath(List args) { if (args.isEmpty) return args; @@ -183,6 +314,8 @@ void _printUsage(ArgParser parser) { stdout.writeln('Commands:'); stdout.writeln( ' scan [] Scan a Flutter project for architecture issues'); + stdout.writeln(' init Create a starter flutterguard.yaml'); + stdout.writeln(' config Print or validate effective configuration'); stdout.writeln(); stdout.writeln('Global Options:'); stdout.writeln(' -h, --help Show this help message'); @@ -191,6 +324,10 @@ void _printUsage(ArgParser parser) { stdout.writeln('Examples:'); stdout.writeln( ' flutterguard scan # Scan current directory'); + stdout + .writeln(' flutterguard init # Create basic config'); + stdout.writeln( + ' flutterguard config doctor # Validate config and globs'); stdout.writeln( ' flutterguard scan ./my_flutter_app # Scan specific project'); stdout.writeln(' flutterguard scan -p /path/to/app # Explicit path flag'); @@ -223,3 +360,45 @@ void _printScanUsage(ArgParser scanParser) { stdout.writeln(); stdout.writeln('Docs: README.md and CONFIGURATION_STRATEGY.md'); } + +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 --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/src/config_tools.dart b/packages/flutterguard_cli/lib/src/config_tools.dart new file mode 100644 index 0000000..b48a081 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/config_tools.dart @@ -0,0 +1,445 @@ +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 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}) { + return withArchitecture + ? '$minimalConfig$architectureBlock' + : minimalConfig; + } + + static String writeInitConfig({ + required String projectPath, + required String configPath, + required bool withArchitecture, + required bool force, + }) { + final resolvedProjectPath = ProjectResolver.resolveProjectPath(projectPath); + final outputPath = p.isAbsolute(configPath) + ? configPath + : p.join(resolvedProjectPath, configPath); + final file = File(outputPath); + if (file.existsSync() && !force) { + throw StateError( + 'Config file already exists at "$outputPath". Use --force to overwrite.', + ); + } + file.parent.createSync(recursive: true); + file.writeAsStringSync(initTemplate(withArchitecture: withArchitecture)); + return outputPath; + } + + static String resolveConfigPathForProject({ + required String projectPath, + required String configPath, + }) { + if (p.isAbsolute(configPath)) return configPath; + final fromProject = p.join(projectPath, configPath); + if (configPath == 'flutterguard.yaml') return fromProject; + if (File(fromProject).existsSync()) return fromProject; + 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, + required 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); + 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.warning, + '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/test/scanner_test.dart b/packages/flutterguard_cli/test/scanner_test.dart index d506b69..10689e0 100644 --- a/packages/flutterguard_cli/test/scanner_test.dart +++ b/packages/flutterguard_cli/test/scanner_test.dart @@ -1,6 +1,7 @@ import 'dart:io'; 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/path_utils.dart'; @@ -179,14 +180,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 +198,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 +211,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 +227,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); }); @@ -392,6 +398,102 @@ dependencies: }); }); + 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('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 warns when globs 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, isFalse); + expect( + result.messages.any((message) => + message.severity == DoctorSeverity.warning && + 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')); + }); + }); + group('Path handling', () { test('matches project-relative globs against Windows paths', () { final windows = p.Context(style: p.Style.windows, current: r'C:\repo'); From 570af990c8c14ae0d1ecff3772490e66c13a0a1e Mon Sep 17 00:00:00 2001 From: forest Date: Sun, 28 Jun 2026 17:35:05 +0800 Subject: [PATCH 03/19] Add changed-only scan and rule metadata commands --- .../flutterguard_cli/bin/flutterguard.dart | 124 ++++++++++++++++- .../lib/flutterguard_cli.dart | 2 + .../lib/src/file_collector.dart | 36 +++++ .../lib/src/report_generator.dart | 26 ++-- .../flutterguard_cli/lib/src/rule_meta.dart | 41 ++++++ .../lib/src/rules/ble_scanning.dart | 56 +++++--- .../lib/src/rules/circular_dependency.dart | 14 ++ .../lib/src/rules/device_lifecycle.dart | 20 ++- .../lib/src/rules/iot_security.dart | 16 +++ .../lib/src/rules/large_units.dart | 55 ++++++-- .../lib/src/rules/layer_violation.dart | 15 +++ .../lib/src/rules/lifecycle_resource.dart | 15 +++ .../src/rules/missing_const_constructor.dart | 15 +++ .../lib/src/rules/module_violation.dart | 15 +++ .../lib/src/rules/mqtt_connection.dart | 45 +++++-- .../lib/src/rules/pubspec_security.dart | 17 ++- .../lib/src/rules/registry.dart | 37 +++++ .../flutterguard_cli/lib/src/scanner.dart | 41 ++++-- .../flutterguard_cli/test/scanner_test.dart | 126 ++++++++++++++++++ 19 files changed, 649 insertions(+), 67 deletions(-) create mode 100644 packages/flutterguard_cli/lib/src/rule_meta.dart create mode 100644 packages/flutterguard_cli/lib/src/rules/registry.dart diff --git a/packages/flutterguard_cli/bin/flutterguard.dart b/packages/flutterguard_cli/bin/flutterguard.dart index fe48a48..bd06f97 100644 --- a/packages/flutterguard_cli/bin/flutterguard.dart +++ b/packages/flutterguard_cli/bin/flutterguard.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:args/args.dart'; @@ -6,6 +7,7 @@ import 'package:flutterguard_cli/src/config_loader.dart'; import 'package:flutterguard_cli/src/config_tools.dart'; import 'package:flutterguard_cli/src/project_resolver.dart'; import 'package:flutterguard_cli/src/report_generator.dart'; +import 'package:flutterguard_cli/src/rules/registry.dart'; import 'package:flutterguard_cli/src/scanner.dart'; const _version = '0.2.0'; @@ -35,6 +37,10 @@ void main(List args) { 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'], @@ -76,10 +82,23 @@ void main(List args) { ..addCommand('doctor', configDoctorParser) ..addFlag('help', abbr: 'h', help: 'Show config usage', negatable: false); + final rulesParser = ArgParser() + ..addOption('format', + abbr: 'f', + defaultsTo: 'table', + allowed: ['table', 'json'], + help: 'Output format') + ..addFlag('help', abbr: 'h', help: 'Show rules usage', negatable: false); + + final explainParser = ArgParser() + ..addFlag('help', abbr: 'h', help: 'Show explain usage', negatable: false); + final parser = ArgParser() ..addCommand('scan', scanParser) ..addCommand('init', initParser) ..addCommand('config', configParser) + ..addCommand('rules', rulesParser) + ..addCommand('explain', explainParser) ..addFlag('help', abbr: 'h', help: 'Show usage', negatable: false) ..addFlag('version', abbr: 'V', help: 'Show version', negatable: false); @@ -117,6 +136,18 @@ void main(List args) { } else if (command.name == 'config') { _handleConfig( command, configParser, configPrintParser, configDoctorParser); + } 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); @@ -253,6 +284,8 @@ void _handleScan(ArgResults args) { outputDir: args['output'] as String, writeJson: format == 'json', noColor: noColor, + changedOnly: args['changed-only'] as bool, + base: args['base'] as String, ); } on ScanException catch (e) { stderr.writeln('Error: ${e.message}'); @@ -295,6 +328,73 @@ void _handleScan(ArgResults args) { } } +void _handleRules(ArgResults args) { + final format = args['format'] as String; + final all = RuleRegistry.all(); + + if (format == 'json') { + stdout.writeln(const JsonEncoder.withIndent(' ').convert( + all.map((m) => m.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 查看详情'); +} + +void _handleExplain(ArgResults args) { + final rest = args.rest; + if (rest.isEmpty) { + stderr.writeln('Error: 请指定规则 ID'); + stderr.writeln('用法: flutterguard explain '); + stderr.writeln('可用规则: ${RuleRegistry.all().map((m) => m.id).join(", ")}'); + exit(2); + } + + final ruleId = rest.first; + final meta = RuleRegistry.find(ruleId); + if (meta == null) { + stderr.writeln('Error: 未找到规则 "$ruleId"'); + stderr.writeln('可用规则: ${RuleRegistry.all().map((m) => m.id).join(", ")}'); + 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'); + } + } +} + int? _parseMinScore(String? value) { if (value == null) return null; final score = int.tryParse(value); @@ -316,6 +416,8 @@ void _printUsage(ArgParser parser) { ' scan [] Scan a Flutter project for architecture issues'); stdout.writeln(' init Create a starter flutterguard.yaml'); stdout.writeln(' config Print or validate effective configuration'); + 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'); @@ -356,11 +458,31 @@ void _printScanUsage(ArgParser scanParser) { stdout.writeln( ' 3. CI config: add --format json --fail-on high --min-score 80.'); stdout.writeln( - ' 4. Architecture config: declare layers/modules explicitly before enabling boundary gates.'); + ' 4. Changed-only: add --changed-only --base main to scan git changes.'); + stdout.writeln( + ' 5. Architecture config: declare layers/modules explicitly before enabling boundary gates.'); stdout.writeln(); stdout.writeln('Docs: README.md and CONFIGURATION_STRATEGY.md'); } +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(); diff --git a/packages/flutterguard_cli/lib/flutterguard_cli.dart b/packages/flutterguard_cli/lib/flutterguard_cli.dart index 4f9448e..c63bd2d 100644 --- a/packages/flutterguard_cli/lib/flutterguard_cli.dart +++ b/packages/flutterguard_cli/lib/flutterguard_cli.dart @@ -6,6 +6,7 @@ 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/rules/ble_scanning.dart'; export 'src/rules/circular_dependency.dart'; export 'src/rules/device_lifecycle.dart'; @@ -17,6 +18,7 @@ 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/static_issue.dart'; diff --git a/packages/flutterguard_cli/lib/src/file_collector.dart b/packages/flutterguard_cli/lib/src/file_collector.dart index 41b35fd..5be69bd 100644 --- a/packages/flutterguard_cli/lib/src/file_collector.dart +++ b/packages/flutterguard_cli/lib/src/file_collector.dart @@ -2,6 +2,7 @@ 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'; @@ -30,4 +31,39 @@ class FileCollector { return allFiles.toList()..sort(); } + + static Set getChangedFiles(String projectPath, String base) { + final gitDir = Directory(p.join(projectPath, '.git')); + if (!gitDir.existsSync()) return {}; + + try { + final result = Process.runSync( + 'git', + ['diff', '--name-only', base, '--diff-filter=ACMR'], + workingDirectory: projectPath, + ); + final untracked = Process.runSync( + 'git', + ['ls-files', '--others', '--exclude-standard'], + workingDirectory: projectPath, + ); + + final changed = {}; + for (final line in (result.stdout as String).split('\n')) { + final trimmed = line.trim(); + if (trimmed.isNotEmpty) { + changed.add(p.normalize(p.join(projectPath, trimmed))); + } + } + for (final line in (untracked.stdout as String).split('\n')) { + final trimmed = line.trim(); + if (trimmed.isNotEmpty) { + changed.add(p.normalize(p.join(projectPath, trimmed))); + } + } + return changed; + } catch (_) { + return {}; + } + } } diff --git a/packages/flutterguard_cli/lib/src/report_generator.dart b/packages/flutterguard_cli/lib/src/report_generator.dart index fc6bda1..4f8d37e 100644 --- a/packages/flutterguard_cli/lib/src/report_generator.dart +++ b/packages/flutterguard_cli/lib/src/report_generator.dart @@ -57,6 +57,7 @@ class ReportGenerator { static String generateJson({ required String projectPath, required List issues, + String scanMode = 'full', }) { final byDomain = _buildSummaryByDomain(issues); final score = calculateScore(issues); @@ -65,6 +66,7 @@ class ReportGenerator { 'version': '1.0.0', 'generatedAt': DateTime.now().toIso8601String(), 'projectPath': projectPath, + 'scanMode': scanMode, 'score': score, 'summary': { 'total': issues.length, @@ -100,7 +102,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 +145,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 +177,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 +216,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/ble_scanning.dart b/packages/flutterguard_cli/lib/src/rules/ble_scanning.dart index d3b3904..9e9a1d0 100644 --- a/packages/flutterguard_cli/lib/src/rules/ble_scanning.dart +++ b/packages/flutterguard_cli/lib/src/rules/ble_scanning.dart @@ -7,6 +7,7 @@ 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 '../static_issue.dart'; @@ -42,28 +43,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 +82,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 +106,8 @@ class BleScanningRule { )); } - _checkScanTimeout(file, startScanMethod: methods, lineInfo: lineInfo, issues: issues); + _checkScanTimeout(file, + startScanMethod: methods, lineInfo: lineInfo, issues: issues); } return issues; @@ -139,7 +140,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 +150,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/circular_dependency.dart b/packages/flutterguard_cli/lib/src/rules/circular_dependency.dart index decd11b..e95ad40 100644 --- a/packages/flutterguard_cli/lib/src/rules/circular_dependency.dart +++ b/packages/flutterguard_cli/lib/src/rules/circular_dependency.dart @@ -8,6 +8,7 @@ import '../domain.dart'; import '../import_utils.dart'; import '../path_utils.dart'; import '../priority.dart'; +import '../rule_meta.dart'; import '../static_issue.dart'; enum _Color { white, gray, black } @@ -145,4 +146,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..b6d9cbb 100644 --- a/packages/flutterguard_cli/lib/src/rules/device_lifecycle.dart +++ b/packages/flutterguard_cli/lib/src/rules/device_lifecycle.dart @@ -7,6 +7,7 @@ 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 '../static_issue.dart'; @@ -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..937c529 100644 --- a/packages/flutterguard_cli/lib/src/rules/iot_security.dart +++ b/packages/flutterguard_cli/lib/src/rules/iot_security.dart @@ -3,6 +3,7 @@ import 'dart:io'; import '../config_loader.dart'; import '../domain.dart'; import '../priority.dart'; +import '../rule_meta.dart'; import '../static_issue.dart'; final _secretPattern = RegExp( @@ -169,4 +170,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..28ddf60 100644 --- a/packages/flutterguard_cli/lib/src/rules/large_units.dart +++ b/packages/flutterguard_cli/lib/src/rules/large_units.dart @@ -7,6 +7,7 @@ import 'package:path/path.dart' as p; import '../config_loader.dart'; import '../domain.dart'; import '../priority.dart'; +import '../rule_meta.dart'; import '../static_issue.dart'; class LargeUnitsRule { @@ -58,11 +59,9 @@ class LargeUnitsRule { level: RiskLevel.low, domain: IssueDomain.standards, priority: Priority.p2, - message: - '文件 ${lines.length} 行(阈值: ${largeFileConfig.maxLines} 行)', + message: '文件 ${lines.length} 行(阈值: ${largeFileConfig.maxLines} 行)', detail: '', - suggestion: - '建议将 ${p.basename(file)} 拆分为更小的模块文件', + suggestion: '建议将 ${p.basename(file)} 拆分为更小的模块文件', metadata: { 'actual': lines.length, 'threshold': largeFileConfig.maxLines, @@ -99,8 +98,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 +140,7 @@ class LargeUnitsRule { message: '${cls.name.lexeme}.build() $lineCount 行(阈值: ${largeBuildMethodConfig.maxLines} 行)', detail: '', - suggestion: - '将 build 方法中的部分提取为更小的子组件或方法', + suggestion: '将 build 方法中的部分提取为更小的子组件或方法', metadata: { 'actual': lineCount, 'threshold': largeBuildMethodConfig.maxLines, @@ -156,4 +153,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..6be78d8 100644 --- a/packages/flutterguard_cli/lib/src/rules/layer_violation.dart +++ b/packages/flutterguard_cli/lib/src/rules/layer_violation.dart @@ -10,6 +10,7 @@ import '../domain.dart'; import '../import_utils.dart'; import '../path_utils.dart'; import '../priority.dart'; +import '../rule_meta.dart'; import '../source_utils.dart'; import '../static_issue.dart'; @@ -123,4 +124,18 @@ class LayerViolationRule { 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..48ea9f2 100644 --- a/packages/flutterguard_cli/lib/src/rules/lifecycle_resource.dart +++ b/packages/flutterguard_cli/lib/src/rules/lifecycle_resource.dart @@ -7,6 +7,7 @@ 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 '../static_issue.dart'; @@ -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..6470de0 100644 --- a/packages/flutterguard_cli/lib/src/rules/missing_const_constructor.dart +++ b/packages/flutterguard_cli/lib/src/rules/missing_const_constructor.dart @@ -7,6 +7,7 @@ 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 '../static_issue.dart'; @@ -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..b497dc7 100644 --- a/packages/flutterguard_cli/lib/src/rules/module_violation.dart +++ b/packages/flutterguard_cli/lib/src/rules/module_violation.dart @@ -10,6 +10,7 @@ import '../domain.dart'; import '../import_utils.dart'; import '../path_utils.dart'; import '../priority.dart'; +import '../rule_meta.dart'; import '../source_utils.dart'; import '../static_issue.dart'; @@ -123,4 +124,18 @@ class ModuleViolationRule { 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..fc27cd3 100644 --- a/packages/flutterguard_cli/lib/src/rules/mqtt_connection.dart +++ b/packages/flutterguard_cli/lib/src/rules/mqtt_connection.dart @@ -7,6 +7,7 @@ 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 '../static_issue.dart'; @@ -45,21 +46,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 +69,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 +81,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 +94,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 +141,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..0d583ab 100644 --- a/packages/flutterguard_cli/lib/src/rules/pubspec_security.dart +++ b/packages/flutterguard_cli/lib/src/rules/pubspec_security.dart @@ -6,6 +6,7 @@ import 'package:yaml/yaml.dart'; import '../config_loader.dart'; import '../domain.dart'; import '../priority.dart'; +import '../rule_meta.dart'; import '../static_issue.dart'; const _vulnerableDeps = { @@ -103,7 +104,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 +170,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..845b375 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/rules/registry.dart @@ -0,0 +1,37 @@ +import 'package:flutterguard_cli/src/rule_meta.dart'; +import 'package:flutterguard_cli/src/rules/ble_scanning.dart'; +import 'package:flutterguard_cli/src/rules/circular_dependency.dart'; +import 'package:flutterguard_cli/src/rules/device_lifecycle.dart'; +import 'package:flutterguard_cli/src/rules/iot_security.dart'; +import 'package:flutterguard_cli/src/rules/large_units.dart'; +import 'package:flutterguard_cli/src/rules/layer_violation.dart'; +import 'package:flutterguard_cli/src/rules/lifecycle_resource.dart'; +import 'package:flutterguard_cli/src/rules/missing_const_constructor.dart'; +import 'package:flutterguard_cli/src/rules/module_violation.dart'; +import 'package:flutterguard_cli/src/rules/mqtt_connection.dart'; +import 'package:flutterguard_cli/src/rules/pubspec_security.dart'; + +class RuleRegistry { + static final Map _registry = () { + final list = [ + LargeUnitsRule.describeLargeFile(), + LargeUnitsRule.describeLargeClass(), + LargeUnitsRule.describeLargeBuildMethod(), + LifecycleResourceRule.describe(), + MissingConstConstructorRule.describe(), + DeviceLifecycleRule.describe(), + MqttConnectionRule.describe(), + BleScanningRule.describe(), + IotSecurityRule.describe(), + PubspecSecurityRule.describe(), + LayerViolationRule.describe(), + ModuleViolationRule.describe(), + CircularDependencyRule.describe(), + ]; + 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/scanner.dart b/packages/flutterguard_cli/lib/src/scanner.dart index c51e59b..2e21d2e 100644 --- a/packages/flutterguard_cli/lib/src/scanner.dart +++ b/packages/flutterguard_cli/lib/src/scanner.dart @@ -4,6 +4,7 @@ import 'package:path/path.dart' as p; 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'; @@ -34,6 +35,7 @@ class ScanResult { final List files; final List issues; final int score; + final String scanMode; const ScanResult({ required this.projectPath, @@ -41,6 +43,7 @@ class ScanResult { required this.files, required this.issues, required this.score, + required this.scanMode, }); } @@ -51,31 +54,46 @@ class FlutterGuardScanner { String outputDir = '.flutterguard', bool writeJson = false, bool noColor = false, + bool changedOnly = false, + String base = 'main', }) { - 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 resolvedConfigPath = ProjectResolver.resolveConfigPath( + projectPath: resolvedProjectPath, + explicitConfig: configPath, + ); final config = ScanConfig.fromFile(resolvedConfigPath); final files = FileCollector.collect(resolvedProjectPath, config); + var scanMode = 'full'; + var filesToScan = files; + + if (changedOnly) { + final changed = FileCollector.getChangedFiles(resolvedProjectPath, base); + if (changed.isNotEmpty) { + final changedDart = changed + .where((f) => f.endsWith('.dart')) + .map(normalizePath) + .toSet(); + filesToScan = files.where((f) => changedDart.contains(f)).toList(); + scanMode = filesToScan.isNotEmpty ? 'changed' : 'full'; + } + } final reportDir = p.isAbsolute(outputDir) ? outputDir : p.join(resolvedProjectPath, outputDir); final issues = _analyze( - files: files, + files: filesToScan, config: config, projectPath: resolvedProjectPath, + changedOnly: changedOnly, ); final score = ReportGenerator.calculateScore(issues); @@ -84,6 +102,7 @@ class FlutterGuardScanner { final json = ReportGenerator.generateJson( projectPath: resolvedProjectPath, issues: issues, + scanMode: scanMode, ); File(p.join(reportDir, 'report.json')).writeAsStringSync(json); } @@ -91,9 +110,10 @@ class FlutterGuardScanner { return ScanResult( projectPath: resolvedProjectPath, reportDir: reportDir, - files: files, + files: filesToScan, issues: issues, score: score, + scanMode: scanMode, ); } @@ -101,6 +121,7 @@ class FlutterGuardScanner { required List files, required ScanConfig config, required String projectPath, + bool changedOnly = false, }) { final allIssues = []; @@ -130,7 +151,7 @@ class FlutterGuardScanner { config.rules.missingConstConstructor, ).analyze(files)); allIssues.addAll(CircularDependencyRule( - enabled: config.architecture.detectCycles, + enabled: !changedOnly && config.architecture.detectCycles, projectPath: projectPath, ).analyze(files)); allIssues.addAll(DeviceLifecycleRule( diff --git a/packages/flutterguard_cli/test/scanner_test.dart b/packages/flutterguard_cli/test/scanner_test.dart index 10689e0..9c9d0e6 100644 --- a/packages/flutterguard_cli/test/scanner_test.dart +++ b/packages/flutterguard_cli/test/scanner_test.dart @@ -18,6 +18,7 @@ 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/scanner.dart'; import 'package:flutterguard_cli/src/static_issue.dart'; import 'package:path/path.dart' as p; @@ -25,6 +26,29 @@ 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', () { @@ -344,6 +368,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"')); @@ -398,6 +423,107 @@ dependencies: }); }); + 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_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); + _writeWidgetIssue(p.join(dir.path, 'lib', 'one.dart'), 'OneWidget'); + _writeWidgetIssue(p.join(dir.path, 'lib', 'two.dart'), 'TwoWidget'); + + final result = FlutterGuardScanner.scan( + projectPath: dir.path, + changedOnly: true, + ); + + expect(result.scanMode, 'full'); + expect(result.files, hasLength(2)); + expect(result.issues, hasLength(2)); + }); + + 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']); + + 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); From 6b739dea7c43b54b7deca60b437061359b2150db Mon Sep 17 00:00:00 2001 From: forest Date: Sun, 28 Jun 2026 17:43:28 +0800 Subject: [PATCH 04/19] =?UTF-8?q?cli:=20v0.3.0=20=E2=80=94=20--changed-onl?= =?UTF-8?q?y=20+=20rules/explain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 8 +- RELEASE_v0.3.0.md | 321 ++++++++++++++++++ packages/flutterguard_cli/CHANGELOG.md | 29 ++ .../flutterguard_cli/bin/flutterguard.dart | 2 +- packages/flutterguard_cli/pubspec.yaml | 2 +- 5 files changed, 358 insertions(+), 4 deletions(-) create mode 100644 RELEASE_v0.3.0.md diff --git a/AGENTS.md b/AGENTS.md index a705e6e..731d79c 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 (37 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 @@ -53,7 +55,9 @@ 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/ + 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) 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/packages/flutterguard_cli/CHANGELOG.md b/packages/flutterguard_cli/CHANGELOG.md index a4f336e..063f5bb 100644 --- a/packages/flutterguard_cli/CHANGELOG.md +++ b/packages/flutterguard_cli/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## 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/bin/flutterguard.dart b/packages/flutterguard_cli/bin/flutterguard.dart index bd06f97..cb91c66 100644 --- a/packages/flutterguard_cli/bin/flutterguard.dart +++ b/packages/flutterguard_cli/bin/flutterguard.dart @@ -10,7 +10,7 @@ import 'package:flutterguard_cli/src/report_generator.dart'; import 'package:flutterguard_cli/src/rules/registry.dart'; import 'package:flutterguard_cli/src/scanner.dart'; -const _version = '0.2.0'; +const _version = '0.3.0'; void main(List args) { final normalizedArgs = _extractPositionalPath(args); diff --git a/packages/flutterguard_cli/pubspec.yaml b/packages/flutterguard_cli/pubspec.yaml index 5d5ed30..8433c9d 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.3.0 repository: https://github.com/lizy-coding/flutterguard issue_tracker: https://github.com/lizy-coding/flutterguard/issues topics: From fa072715b05ea7d9720b77783804dfbfee2eb568 Mon Sep 17 00:00:00 2001 From: lizy Date: Wed, 8 Jul 2026 21:05:30 +0800 Subject: [PATCH 05/19] Prepare flutterguard_cli v0.4.0 --- CHANGELOG.md | 12 ++ README.md | 68 +++++++- README.zh.md | 68 +++++++- packages/flutterguard_cli/CHANGELOG.md | 16 ++ packages/flutterguard_cli/README.md | 42 ++++- .../flutterguard_cli/bin/flutterguard.dart | 138 +++++++++++++-- .../flutterguard_cli/lib/src/baseline.dart | 80 +++++++++ .../lib/src/report_generator.dart | 4 + .../lib/src/sarif_report.dart | 95 ++++++++++ .../flutterguard_cli/lib/src/scanner.dart | 63 ++++++- .../flutterguard_cli/lib/src/suppression.dart | 53 ++++++ packages/flutterguard_cli/pubspec.yaml | 2 +- .../flutterguard_cli/test/scanner_test.dart | 164 ++++++++++++++++++ 13 files changed, 783 insertions(+), 22 deletions(-) create mode 100644 packages/flutterguard_cli/lib/src/baseline.dart create mode 100644 packages/flutterguard_cli/lib/src/sarif_report.dart create mode 100644 packages/flutterguard_cli/lib/src/suppression.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ff0ba0..5ddcd53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 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. +- **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/README.md b/README.md index 4a8a8aa..35b26c4 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,13 @@ 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 scan . --baseline .flutterguard/baseline.json --fail-on high + +# GitHub Code Scanning output +flutterguard scan . --format sarif --baseline .flutterguard/baseline.json + # Show help flutterguard --help flutterguard scan --help @@ -181,10 +188,13 @@ 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 init` | Create a starter `flutterguard.yaml` | | `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 rules` | List available rules | +| `flutterguard explain ` | Explain one rule | | `flutterguard --help` / `-h` | Show usage | | `flutterguard --version` / `-V` | Show version | @@ -195,10 +205,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 | @@ -362,6 +375,8 @@ Example shape: "high": 1, "medium": 1, "low": 1, + "suppressed": 0, + "suppressedByBaseline": 0, "byDomain": { "architecture": { "high": 1, "medium": 0, "low": 0, "total": 1 } } @@ -370,6 +385,30 @@ 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 scan . --baseline .flutterguard/baseline.json --format json --fail-on high +``` + ## Scoring ``` @@ -407,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 diff --git a/README.zh.md b/README.zh.md index d959f50..b3f02dd 100644 --- a/README.zh.md +++ b/README.zh.md @@ -160,6 +160,13 @@ flutterguard scan -p D:\path\to\project # Windows # JSON 输出 + CI 门禁 flutterguard scan . --format json --fail-on high +# 启用强门禁前先为历史问题创建 baseline +flutterguard baseline create . +flutterguard scan . --baseline .flutterguard/baseline.json --fail-on high + +# GitHub Code Scanning 输出 +flutterguard scan . --format sarif --baseline .flutterguard/baseline.json + # 显示帮助 flutterguard --help flutterguard scan --help @@ -180,10 +187,13 @@ flutterguard scan examples/scan_demo | 命令 | 说明 | |------|------| | `flutterguard scan []` | 扫描项目(路径默认为当前目录) | +| `flutterguard baseline create []` | 为现有问题创建 baseline JSON 文件 | | `flutterguard init` | 创建基础 `flutterguard.yaml` | | `flutterguard init --with-architecture` | 创建包含架构层/模块模板的配置 | | `flutterguard config print` | 输出合并后的有效配置 | | `flutterguard config doctor` | 检查配置、glob 和架构引用 | +| `flutterguard rules` | 列出所有可用规则 | +| `flutterguard explain ` | 查看单条规则说明 | | `flutterguard --help` / `-h` | 显示帮助 | | `flutterguard --version` / `-V` | 显示版本 | @@ -194,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 帮助 | @@ -361,6 +374,8 @@ architecture: "high": 1, "medium": 1, "low": 1, + "suppressed": 0, + "suppressedByBaseline": 0, "byDomain": { "architecture": { "high": 1, "medium": 0, "low": 0, "total": 1 } } @@ -369,6 +384,30 @@ 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 scan . --baseline .flutterguard/baseline.json --format json --fail-on high +``` + ## 评分 ``` @@ -406,7 +445,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 diff --git a/packages/flutterguard_cli/CHANGELOG.md b/packages/flutterguard_cli/CHANGELOG.md index 063f5bb..c90c4f1 100644 --- a/packages/flutterguard_cli/CHANGELOG.md +++ b/packages/flutterguard_cli/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 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. + +### 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) diff --git a/packages/flutterguard_cli/README.md b/packages/flutterguard_cli/README.md index 539be56..22cca66 100644 --- a/packages/flutterguard_cli/README.md +++ b/packages/flutterguard_cli/README.md @@ -22,6 +22,13 @@ flutterguard config doctor # 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 scan . --baseline .flutterguard/baseline.json --fail-on high + +# GitHub Code Scanning output +flutterguard scan . --format sarif --baseline .flutterguard/baseline.json ``` ## Checks @@ -82,15 +89,21 @@ If no config file exists, defaults are used (all default rules enabled, no archi 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 init [--with-architecture] [--force] flutterguard config print flutterguard config doctor +flutterguard rules [--format table|json] +flutterguard explain ``` Exit codes: `0` success, `1` CI gate failed, `2` scan error. @@ -109,13 +122,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/flutterguard.dart b/packages/flutterguard_cli/bin/flutterguard.dart index cb91c66..3217a76 100644 --- a/packages/flutterguard_cli/bin/flutterguard.dart +++ b/packages/flutterguard_cli/bin/flutterguard.dart @@ -3,14 +3,16 @@ import 'dart:io'; import 'package:args/args.dart'; +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/project_resolver.dart'; import 'package:flutterguard_cli/src/report_generator.dart'; import 'package:flutterguard_cli/src/rules/registry.dart'; import 'package:flutterguard_cli/src/scanner.dart'; +import 'package:path/path.dart' as p; -const _version = '0.3.0'; +const _version = '0.4.0'; void main(List args) { final normalizedArgs = _extractPositionalPath(args); @@ -25,7 +27,7 @@ void main(List args) { ..addOption('format', abbr: 'f', defaultsTo: 'table', - allowed: ['table', 'json'], + allowed: ['table', 'json', 'sarif'], help: 'Output format') ..addOption('output', abbr: 'o', @@ -46,8 +48,28 @@ void main(List args) { 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); + final baselineCreateParser = 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); + + final baselineParser = ArgParser() + ..addCommand('create', baselineCreateParser) + ..addFlag('help', abbr: 'h', help: 'Show baseline usage', negatable: false); + final initParser = ArgParser() ..addOption('path', abbr: 'p', defaultsTo: '.', help: 'Project path for flutterguard.yaml') @@ -95,6 +117,7 @@ void main(List args) { final parser = ArgParser() ..addCommand('scan', scanParser) + ..addCommand('baseline', baselineParser) ..addCommand('init', initParser) ..addCommand('config', configParser) ..addCommand('rules', rulesParser) @@ -127,6 +150,8 @@ void main(List args) { exit(0); } _handleScan(command); + } else if (command.name == 'baseline') { + _handleBaseline(command, baselineParser, baselineCreateParser); } else if (command.name == 'init') { if (command['help'] == true) { _printInitUsage(initParser); @@ -283,9 +308,11 @@ void _handleScan(ArgResults args) { configPath: args['config'] as String, outputDir: args['output'] as String, writeJson: format == 'json', + writeSarif: format == 'sarif', noColor: noColor, changedOnly: args['changed-only'] as bool, base: args['base'] as String, + baselinePath: args['baseline'] as String?, ); } on ScanException catch (e) { stderr.writeln('Error: ${e.message}'); @@ -304,14 +331,24 @@ void _handleScan(ArgResults args) { exit(0); } - final stdoutOutput = ReportGenerator.generateStdout( - projectPath: result.projectPath, - issues: result.issues, - scannedFileCount: result.files.length, - verbose: verbose, - noColor: noColor, - ); - stdout.writeln(stdoutOutput); + 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 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)) { @@ -328,6 +365,56 @@ void _handleScan(ArgResults args) { } } +void _handleBaseline( + ArgResults command, + ArgParser baselineParser, + ArgParser baselineCreateParser, +) { + if (command['help'] == true || command.command == null) { + _printBaselineUsage(baselineParser); + exit(0); + } + + final subcommand = command.command!; + if (subcommand.name != 'create') { + _printBaselineUsage(baselineParser); + exit(0); + } + if (subcommand['help'] == true) { + _printBaselineCreateUsage(baselineCreateParser); + exit(0); + } + + final restPath = subcommand.rest.isNotEmpty ? subcommand.rest.first : null; + final projectPath = restPath ?? subcommand['path'] as String; + final output = subcommand['output'] as String; + + try { + final result = FlutterGuardScanner.scan( + projectPath: projectPath, + configPath: subcommand['config'] as String, + 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 (e) { + stderr.writeln('Error: ${e.message}'); + exit(2); + } on FormatException catch (e) { + stderr.writeln('Error: ${e.message}'); + exit(2); + } +} + void _handleRules(ArgResults args) { final format = args['format'] as String; final all = RuleRegistry.all(); @@ -414,6 +501,7 @@ void _printUsage(ArgParser parser) { stdout.writeln('Commands:'); stdout.writeln( ' scan [] Scan a Flutter project for architecture issues'); + stdout.writeln(' baseline Create a baseline for existing issues'); stdout.writeln(' init Create a starter flutterguard.yaml'); stdout.writeln(' config Print or validate effective configuration'); stdout.writeln(' rules List available rules'); @@ -434,6 +522,9 @@ void _printUsage(ArgParser parser) { ' 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(); stdout.writeln('Configuration strategy:'); stdout.writeln( @@ -456,15 +547,36 @@ void _printScanUsage(ArgParser scanParser) { stdout .writeln(' 2. Basic config: tune include/exclude and rule thresholds.'); stdout.writeln( - ' 3. CI config: add --format json --fail-on high --min-score 80.'); + ' 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( - ' 4. Changed-only: add --changed-only --base main to scan git changes.'); + ' 5. Changed-only: add --changed-only --base main to scan git changes.'); stdout.writeln( - ' 5. Architecture config: declare layers/modules explicitly before enabling boundary gates.'); + ' 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(); + 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 _printRulesUsage(ArgParser parser) { stdout.writeln('FlutterGuard — rules command'); stdout.writeln(); diff --git a/packages/flutterguard_cli/lib/src/baseline.dart b/packages/flutterguard_cli/lib/src/baseline.dart new file mode 100644 index 0000000..3d3397f --- /dev/null +++ b/packages/flutterguard_cli/lib/src/baseline.dart @@ -0,0 +1,80 @@ +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.'); + } + + final decoded = jsonDecode(file.readAsStringSync()); + 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 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/report_generator.dart b/packages/flutterguard_cli/lib/src/report_generator.dart index 4f8d37e..f26950e 100644 --- a/packages/flutterguard_cli/lib/src/report_generator.dart +++ b/packages/flutterguard_cli/lib/src/report_generator.dart @@ -58,6 +58,8 @@ class ReportGenerator { required String projectPath, required List issues, String scanMode = 'full', + int suppressedCount = 0, + int suppressedByBaselineCount = 0, }) { final byDomain = _buildSummaryByDomain(issues); final score = calculateScore(issues); @@ -73,6 +75,8 @@ class ReportGenerator { '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, 'byDomain': byDomain, }, 'issues': issues.map((i) => i.toJson()).toList(), 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/scanner.dart b/packages/flutterguard_cli/lib/src/scanner.dart index 2e21d2e..379a013 100644 --- a/packages/flutterguard_cli/lib/src/scanner.dart +++ b/packages/flutterguard_cli/lib/src/scanner.dart @@ -2,6 +2,7 @@ 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'; @@ -18,7 +19,9 @@ import 'rules/missing_const_constructor.dart'; import 'rules/module_violation.dart'; import 'rules/mqtt_connection.dart'; import 'rules/pubspec_security.dart'; +import 'sarif_report.dart'; import 'static_issue.dart'; +import 'suppression.dart'; class ScanException implements Exception { final String message; @@ -33,7 +36,10 @@ 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; @@ -41,7 +47,10 @@ class 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, }); @@ -53,9 +62,12 @@ class FlutterGuardScanner { String configPath = 'flutterguard.yaml', String outputDir = '.flutterguard', bool writeJson = false, + bool writeSarif = false, bool noColor = false, bool changedOnly = false, String base = 'main', + String? baselinePath, + bool applySuppression = true, }) { final resolvedProjectPath = ProjectResolver.resolveProjectPath(projectPath); if (!Directory(resolvedProjectPath).existsSync()) { @@ -89,29 +101,76 @@ class FlutterGuardScanner { ? outputDir : p.join(resolvedProjectPath, outputDir); - final issues = _analyze( + final rawIssues = _analyze( files: filesToScan, config: config, projectPath: resolvedProjectPath, changedOnly: changedOnly, ); + + var issues = rawIssues; + var suppressedCount = 0; + if (applySuppression) { + final suppression = SuppressionFilter(filesToScan); + 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, + suppressedCount: suppressedCount, + suppressedByBaselineCount: suppressedByBaselineCount, ); 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: filesToScan, + rawIssues: rawIssues, issues: issues, + suppressedCount: suppressedCount, + suppressedByBaselineCount: suppressedByBaselineCount, score: score, scanMode: scanMode, ); diff --git a/packages/flutterguard_cli/lib/src/suppression.dart b/packages/flutterguard_cli/lib/src/suppression.dart new file mode 100644 index 0000000..9747a46 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/suppression.dart @@ -0,0 +1,53 @@ +import 'dart:io'; + +import 'static_issue.dart'; + +class SuppressionFilter { + final Map>> _rulesByFileAndLine = {}; + + SuppressionFilter(Iterable files) { + for (final file in files) { + _rulesByFileAndLine[file] = _parseFile(file); + } + } + + 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) { + final file = File(path); + if (!file.existsSync()) return const {}; + + final result = >{}; + final lines = file.readAsLinesSync(); + 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 8433c9d..7456abb 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.3.0 +version: 0.4.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/scanner_test.dart b/packages/flutterguard_cli/test/scanner_test.dart index 9c9d0e6..4a6277f 100644 --- a/packages/flutterguard_cli/test/scanner_test.dart +++ b/packages/flutterguard_cli/test/scanner_test.dart @@ -1,5 +1,7 @@ +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'; @@ -19,6 +21,7 @@ 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/static_issue.dart'; import 'package:path/path.dart' as p; @@ -385,6 +388,20 @@ 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, + ); + final decoded = jsonDecode(json) as Map; + final summary = decoded['summary'] as Map; + + expect(summary['suppressed'], 2); + expect(summary['suppressedByBaseline'], 3); + }); }); group('Scanner Orchestration', () { @@ -421,6 +438,153 @@ dependencies: 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('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')); + }); }); group('Changed-only mode', () { From 5974a0362969dbc92200b2b9165d9b4277801df0 Mon Sep 17 00:00:00 2001 From: lizy Date: Thu, 9 Jul 2026 10:17:42 +0800 Subject: [PATCH 06/19] Add FlutterGuard roadmap --- ROADMAP.md | 174 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 ROADMAP.md diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..054404d --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,174 @@ +# 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. + +## Milestone 0.4.x — CI Adoption Hardening + +Goal: Make the existing CLI reliable and low-friction in real CI pipelines. + +Deliverables: + +- Improve rule accuracy with more real-world IoT Flutter fixtures. +- Add baseline management commands: + - `flutterguard baseline diff` + - `flutterguard baseline prune` + - `flutterguard baseline stats` +- Add CI guardrails to prevent baseline growth. +- Enhance SARIF output with richer rule metadata and precise locations where + available. +- Add checked-in GitHub Actions examples for JSON gates and SARIF upload. +- Add more suppression tests around comments, whitespace, and adjacent issues. + +Exit Criteria: + +- CI users can onboard existing projects without blocking on historical issues. +- Baseline files can be reviewed and reduced over time. +- SARIF uploads cleanly to GitHub Code Scanning. + +## Milestone 0.5 — 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.6 — 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.7 — 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.8+ — 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. From 7bf0ed80aad870fe333b60d605059cabb31b3c10 Mon Sep 17 00:00:00 2001 From: lizy Date: Thu, 9 Jul 2026 12:51:29 +0800 Subject: [PATCH 07/19] Add release packaging and source launchers --- .github/workflows/release.yml | 64 ++++++++++++++++++++++++++ CHANGELOG.md | 2 + README.md | 47 ++++++++----------- README.zh.md | 46 +++++++----------- packages/flutterguard_cli/CHANGELOG.md | 1 + packages/flutterguard_cli/README.md | 9 ++++ scripts/flutterguard-dev | 5 ++ scripts/flutterguard-dev.ps1 | 5 ++ scripts/package_release.ps1 | 26 +++++++++++ scripts/package_release.sh | 33 +++++++++++++ 10 files changed, 180 insertions(+), 58 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100755 scripts/flutterguard-dev create mode 100644 scripts/flutterguard-dev.ps1 create mode 100644 scripts/package_release.ps1 create mode 100755 scripts/package_release.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..856faba --- /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.3.0" + - 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.3.0" + - 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/CHANGELOG.md b/CHANGELOG.md index 5ddcd53..7974055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ - **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) diff --git a/README.md b/README.md index 35b26c4..ac939b6 100644 --- a/README.md +++ b/README.md @@ -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 . ``` diff --git a/README.zh.md b/README.zh.md index b3f02dd..4374533 100644 --- a/README.zh.md +++ b/README.zh.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 . ``` diff --git a/packages/flutterguard_cli/CHANGELOG.md b/packages/flutterguard_cli/CHANGELOG.md index c90c4f1..9e61513 100644 --- a/packages/flutterguard_cli/CHANGELOG.md +++ b/packages/flutterguard_cli/CHANGELOG.md @@ -10,6 +10,7 @@ - **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 diff --git a/packages/flutterguard_cli/README.md b/packages/flutterguard_cli/README.md index 22cca66..7e1e205 100644 --- a/packages/flutterguard_cli/README.md +++ b/packages/flutterguard_cli/README.md @@ -12,6 +12,7 @@ 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 @@ -31,6 +32,14 @@ flutterguard scan . --baseline .flutterguard/baseline.json --fail-on high flutterguard scan . --format sarif --baseline .flutterguard/baseline.json ``` +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 | 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" From 4ee8797a0f1a07f41ab7ffc4b234141ba64dc190 Mon Sep 17 00:00:00 2001 From: lizy Date: Thu, 9 Jul 2026 16:35:47 +0800 Subject: [PATCH 08/19] Add 0.4.1 adoption hardening commands --- CHANGELOG.md | 11 + README.md | 15 +- README.zh.md | 15 +- packages/flutterguard_cli/CHANGELOG.md | 10 + packages/flutterguard_cli/README.md | 14 +- .../flutterguard_cli/bin/flutterguard.dart | 408 +++++++++++++++++- .../flutterguard_cli/lib/src/baseline.dart | 58 ++- .../lib/src/config_tools.dart | 232 +++++++++- .../lib/src/install_doctor.dart | 62 +++ .../lib/src/issue_export.dart | 106 +++++ packages/flutterguard_cli/pubspec.yaml | 2 +- .../flutterguard_cli/test/scanner_test.dart | 113 +++++ 12 files changed, 1021 insertions(+), 25 deletions(-) create mode 100644 packages/flutterguard_cli/lib/src/install_doctor.dart create mode 100644 packages/flutterguard_cli/lib/src/issue_export.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7974055..926166a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 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 diff --git a/README.md b/README.md index ac939b6..517abd9 100644 --- a/README.md +++ b/README.md @@ -133,8 +133,9 @@ dart pub get flutterguard scan # Create and validate a starter config -flutterguard init +flutterguard init --profile migration flutterguard config doctor +flutterguard doctor install # Inspect the merged effective config flutterguard config print @@ -152,11 +153,16 @@ 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 @@ -178,10 +184,16 @@ Commands: |---------|-------------| | `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 | @@ -395,6 +407,7 @@ 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 ``` diff --git a/README.zh.md b/README.zh.md index 4374533..5396299 100644 --- a/README.zh.md +++ b/README.zh.md @@ -131,8 +131,9 @@ dart pub get flutterguard scan # 创建并检查基础配置 -flutterguard init +flutterguard init --profile migration flutterguard config doctor +flutterguard doctor install # 查看合并后的有效配置 flutterguard config print @@ -150,11 +151,16 @@ 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 @@ -176,10 +182,16 @@ 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` | 显示帮助 | @@ -393,6 +405,7 @@ architecture: ```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 ``` diff --git a/packages/flutterguard_cli/CHANGELOG.md b/packages/flutterguard_cli/CHANGELOG.md index 9e61513..ca83f62 100644 --- a/packages/flutterguard_cli/CHANGELOG.md +++ b/packages/flutterguard_cli/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 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 diff --git a/packages/flutterguard_cli/README.md b/packages/flutterguard_cli/README.md index 7e1e205..abe183f 100644 --- a/packages/flutterguard_cli/README.md +++ b/packages/flutterguard_cli/README.md @@ -18,18 +18,24 @@ flutterguard --version flutterguard scan -p /path/to/flutter_project # Create and validate a starter config -flutterguard init +flutterguard init --profile migration flutterguard config doctor +flutterguard doctor install # JSON output with CI gate flutterguard scan -p . --format json --fail-on high --min-score 80 # Baseline existing issues before enforcing CI flutterguard baseline create . +flutterguard baseline stats +flutterguard baseline check . --baseline .flutterguard/baseline.json --no-growth flutterguard scan . --baseline .flutterguard/baseline.json --fail-on high # GitHub Code Scanning output flutterguard scan . --format sarif --baseline .flutterguard/baseline.json + +# Export one finding for feedback +flutterguard issue export --rule mqtt_connection --file lib/device/mqtt.dart --line 42 ``` When working from a source checkout, prefer the local launcher so you always @@ -108,9 +114,15 @@ flutterguard scan [] [options] --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 ``` diff --git a/packages/flutterguard_cli/bin/flutterguard.dart b/packages/flutterguard_cli/bin/flutterguard.dart index 3217a76..8381737 100644 --- a/packages/flutterguard_cli/bin/flutterguard.dart +++ b/packages/flutterguard_cli/bin/flutterguard.dart @@ -6,13 +6,15 @@ import 'package:args/args.dart'; 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/install_doctor.dart'; +import 'package:flutterguard_cli/src/issue_export.dart'; import 'package:flutterguard_cli/src/project_resolver.dart'; import 'package:flutterguard_cli/src/report_generator.dart'; import 'package:flutterguard_cli/src/rules/registry.dart'; import 'package:flutterguard_cli/src/scanner.dart'; import 'package:path/path.dart' as p; -const _version = '0.4.0'; +const _version = '0.4.1'; void main(List args) { final normalizedArgs = _extractPositionalPath(args); @@ -66,8 +68,53 @@ void main(List args) { ..addFlag('help', abbr: 'h', help: 'Show baseline create usage', negatable: false); + final baselineStatsParser = 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); + + final baselinePruneParser = 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); + + final baselineCheckParser = 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); + final baselineParser = ArgParser() ..addCommand('create', baselineCreateParser) + ..addCommand('stats', baselineStatsParser) + ..addCommand('prune', baselinePruneParser) + ..addCommand('check', baselineCheckParser) ..addFlag('help', abbr: 'h', help: 'Show baseline usage', negatable: false); final initParser = ArgParser() @@ -79,10 +126,42 @@ void main(List args) { 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); + final installDoctorParser = ArgParser() + ..addFlag('help', + abbr: 'h', help: 'Show install doctor usage', negatable: false); + + final doctorParser = ArgParser() + ..addCommand('install', installDoctorParser) + ..addFlag('help', abbr: 'h', help: 'Show doctor usage', negatable: false); + + final issueExportParser = 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); + + final issueParser = ArgParser() + ..addCommand('export', issueExportParser) + ..addFlag('help', abbr: 'h', help: 'Show issue usage', negatable: false); + final configPrintParser = ArgParser() ..addOption('path', abbr: 'p', defaultsTo: '.', help: 'Project path for config resolution') @@ -118,8 +197,10 @@ void main(List args) { final parser = ArgParser() ..addCommand('scan', scanParser) ..addCommand('baseline', baselineParser) + ..addCommand('doctor', doctorParser) ..addCommand('init', initParser) ..addCommand('config', configParser) + ..addCommand('issue', issueParser) ..addCommand('rules', rulesParser) ..addCommand('explain', explainParser) ..addFlag('help', abbr: 'h', help: 'Show usage', negatable: false) @@ -151,7 +232,16 @@ void main(List args) { } _handleScan(command); } else if (command.name == 'baseline') { - _handleBaseline(command, baselineParser, baselineCreateParser); + _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); @@ -161,6 +251,8 @@ void main(List args) { } 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); @@ -187,14 +279,18 @@ void main(List args) { void _handleInit(ArgResults args) { try { + final restPath = args.rest.isNotEmpty ? args.rest.first : null; final outputPath = ConfigTools.writeInitConfig( - projectPath: args['path'] as String, + projectPath: restPath ?? args['path'] as String, 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 ${args['path']}'); + stdout.writeln( + 'Next: flutterguard config doctor -p ${restPath ?? args['path']}', + ); } on StateError catch (e) { stderr.writeln('Error: ${e.message}'); exit(2); @@ -369,6 +465,9 @@ void _handleBaseline( ArgResults command, ArgParser baselineParser, ArgParser baselineCreateParser, + ArgParser baselineStatsParser, + ArgParser baselinePruneParser, + ArgParser baselineCheckParser, ) { if (command['help'] == true || command.command == null) { _printBaselineUsage(baselineParser); @@ -376,23 +475,52 @@ void _handleBaseline( } final subcommand = command.command!; - if (subcommand.name != 'create') { - _printBaselineUsage(baselineParser); - exit(0); + if (subcommand.name == 'create') { + if (subcommand['help'] == true) { + _printBaselineCreateUsage(baselineCreateParser); + exit(0); + } + _handleBaselineCreate(subcommand); + return; } - if (subcommand['help'] == true) { - _printBaselineCreateUsage(baselineCreateParser); - exit(0); + 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); +} - final restPath = subcommand.rest.isNotEmpty ? subcommand.rest.first : null; - final projectPath = restPath ?? subcommand['path'] as String; - final output = subcommand['output'] as String; +void _handleBaselineCreate(ArgResults args) { + final restPath = args.rest.isNotEmpty ? args.rest.first : null; + final projectPath = restPath ?? args['path'] as String; + final output = args['output'] as String; try { final result = FlutterGuardScanner.scan( projectPath: projectPath, - configPath: subcommand['config'] as String, + configPath: args['config'] as String, applySuppression: false, ); final outputPath = @@ -415,6 +543,186 @@ void _handleBaseline( } } +void _handleBaselineStats(ArgResults args) { + try { + final stats = Baseline.stats(args['baseline'] as String); + stdout.writeln(const JsonEncoder.withIndent(' ').convert(stats)); + } on FormatException catch (e) { + stderr.writeln('Error: ${e.message}'); + exit(2); + } +} + +void _handleBaselinePrune(ArgResults args) { + final restPath = args.rest.isNotEmpty ? args.rest.first : null; + final projectPath = restPath ?? args['path'] as String; + final baselinePath = args['baseline'] as String; + try { + final result = FlutterGuardScanner.scan( + projectPath: projectPath, + configPath: args['config'] as String, + applySuppression: false, + ); + final resolvedBaselinePath = _resolveProjectFile( + 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 (e) { + stderr.writeln('Error: ${e.message}'); + exit(2); + } on FormatException catch (e) { + stderr.writeln('Error: ${e.message}'); + exit(2); + } +} + +void _handleBaselineCheck(ArgResults args) { + final restPath = args.rest.isNotEmpty ? args.rest.first : null; + final projectPath = restPath ?? args['path'] as String; + try { + final result = FlutterGuardScanner.scan( + projectPath: projectPath, + configPath: args['config'] as String, + applySuppression: false, + ); + final baseline = Baseline.load(_resolveProjectFile( + 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.', + ); + if (newFingerprints.isNotEmpty) { + for (final fingerprint in newFingerprints.take(20)) { + stdout.writeln(' $fingerprint'); + } + } + if (args['no-growth'] == true && newFingerprints.isNotEmpty) { + exit(1); + } + } on ScanException catch (e) { + stderr.writeln('Error: ${e.message}'); + exit(2); + } on FormatException catch (e) { + stderr.writeln('Error: ${e.message}'); + exit(2); + } +} + +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); + } + stdout.write(InstallDoctor.generate(version: _version)); + return; + } + _printDoctorUsage(doctorParser); + exit(0); +} + +void _handleIssue( + ArgResults command, + ArgParser issueParser, + ArgParser issueExportParser, +) { + if (command['help'] == true || command.command == null) { + _printIssueUsage(issueParser); + exit(0); + } + final subcommand = command.command!; + if (subcommand.name != 'export') { + _printIssueUsage(issueParser); + exit(0); + } + if (subcommand['help'] == true) { + _printIssueExportUsage(issueExportParser); + exit(0); + } + try { + final contextLines = int.tryParse(subcommand['context'] as String); + if (contextLines == null || contextLines < 0) { + throw const FormatException( + 'Expected --context to be a non-negative integer.'); + } + final lineRaw = subcommand['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: subcommand['path'] as String, + configPath: subcommand['config'] as String, + applySuppression: false, + ); + final exported = IssueExporter.export( + projectPath: result.projectPath, + issues: result.rawIssues, + ruleId: subcommand['rule'] as String?, + filePath: subcommand['file'] as String?, + line: line, + contextLines: contextLines, + ); + final output = subcommand['output'] as String?; + if (output == null) { + stdout.writeln(exported); + return; + } + final outputPath = _resolveProjectFile(result.projectPath, output); + File(outputPath).parent.createSync(recursive: true); + File(outputPath).writeAsStringSync(exported); + stdout.writeln('Exported issue feedback bundle: $outputPath'); + } on ScanException catch (e) { + stderr.writeln('Error: ${e.message}'); + exit(2); + } on FormatException catch (e) { + stderr.writeln('Error: ${e.message}'); + exit(2); + } +} + +String _resolveProjectFile(String projectPath, String filePath) { + return p.isAbsolute(filePath) ? filePath : p.join(projectPath, filePath); +} + void _handleRules(ArgResults args) { final format = args['format'] as String; final all = RuleRegistry.all(); @@ -502,8 +810,10 @@ void _printUsage(ArgParser parser) { 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(); @@ -525,6 +835,8 @@ void _printUsage(ArgParser parser) { 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( @@ -565,6 +877,9 @@ void _printBaselineUsage(ArgParser parser) { 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); } @@ -577,6 +892,68 @@ void _printBaselineCreateUsage(ArgParser parser) { 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(); @@ -598,12 +975,13 @@ void _printExplainUsage(ArgParser parser) { void _printInitUsage(ArgParser initParser) { stdout.writeln('FlutterGuard — init command'); stdout.writeln(); - stdout.writeln('Usage: flutterguard init [options]'); + 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'); } diff --git a/packages/flutterguard_cli/lib/src/baseline.dart b/packages/flutterguard_cli/lib/src/baseline.dart index 3d3397f..4bc8823 100644 --- a/packages/flutterguard_cli/lib/src/baseline.dart +++ b/packages/flutterguard_cli/lib/src/baseline.dart @@ -35,7 +35,11 @@ class Baseline { throw FormatException('Baseline file "$path" does not exist.'); } - final decoded = jsonDecode(file.readAsStringSync()); + 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.'); } @@ -49,6 +53,58 @@ class Baseline { return Baseline(fingerprints.map((v) => v.toString()).toSet()); } + static Map stats(String path) { + final baseline = load(path); + return { + 'path': path, + 'fingerprints': baseline.fingerprints.length, + }; + } + + static List newFingerprints({ + required String projectPath, + required Baseline baseline, + required List issues, + }) { + final current = issues + .map((issue) => fingerprint(issue, projectPath)) + .where((fingerprint) => !baseline.fingerprints.contains(fingerprint)) + .toSet() + .toList() + ..sort(); + return current; + } + + static String encodeFingerprints({ + required String projectPath, + required List fingerprints, + }) { + final sorted = fingerprints.toSet().toList()..sort(); + final payload = { + 'version': '1.0.0', + 'generatedAt': DateTime.now().toIso8601String(), + 'projectPath': projectPath, + 'issueCount': sorted.length, + 'fingerprints': sorted, + }; + return const JsonEncoder.withIndent(' ').convert(payload); + } + + static String prune({ + required String projectPath, + required Baseline baseline, + required List issues, + }) { + final current = issues + .map((issue) => fingerprint(issue, projectPath)) + .where(baseline.fingerprints.contains) + .toList(); + return encodeFingerprints( + projectPath: projectPath, + fingerprints: current, + ); + } + static String encode({ required String projectPath, required List issues, diff --git a/packages/flutterguard_cli/lib/src/config_tools.dart b/packages/flutterguard_cli/lib/src/config_tools.dart index b48a081..a4d0016 100644 --- a/packages/flutterguard_cli/lib/src/config_tools.dart +++ b/packages/flutterguard_cli/lib/src/config_tools.dart @@ -36,6 +36,15 @@ class DoctorResult { } class ConfigTools { + static const profiles = { + 'recommended', + 'strict', + 'migration', + 'iot-security', + 'architecture-only', + 'performance-only', + }; + static const minimalConfig = ''' include: - lib/** @@ -109,10 +118,25 @@ architecture: enabled: true '''; - static String initTemplate({required bool withArchitecture}) { - return withArchitecture - ? '$minimalConfig$architectureBlock' - : minimalConfig; + 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({ @@ -120,6 +144,7 @@ architecture: required String configPath, required bool withArchitecture, required bool force, + String profile = 'recommended', }) { final resolvedProjectPath = ProjectResolver.resolveProjectPath(projectPath); final outputPath = p.isAbsolute(configPath) @@ -132,10 +157,207 @@ architecture: ); } file.parent.createSync(recursive: true); - file.writeAsStringSync(initTemplate(withArchitecture: withArchitecture)); + 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, required String configPath, 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..2741e78 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/issue_export.dart @@ -0,0 +1,106 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'baseline.dart'; +import 'rules/registry.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, + }) { + 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), + '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) { + final line = issue.line; + final file = File(issue.file); + if (line == null || !file.existsSync()) { + return { + 'available': false, + 'lines': const [], + }; + } + + final 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/pubspec.yaml b/packages/flutterguard_cli/pubspec.yaml index 7456abb..3045006 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.4.0 +version: 0.4.1 repository: https://github.com/lizy-coding/flutterguard issue_tracker: https://github.com/lizy-coding/flutterguard/issues topics: diff --git a/packages/flutterguard_cli/test/scanner_test.dart b/packages/flutterguard_cli/test/scanner_test.dart index 4a6277f..3695c29 100644 --- a/packages/flutterguard_cli/test/scanner_test.dart +++ b/packages/flutterguard_cli/test/scanner_test.dart @@ -6,6 +6,8 @@ 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'; @@ -511,6 +513,57 @@ class IgnoredWidget extends StatelessWidget {} 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)); @@ -585,6 +638,34 @@ class IgnoredWidget extends StatelessWidget {} 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', () { @@ -699,6 +780,30 @@ class AnotherChangedWidget extends StatelessWidget {} 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'), @@ -782,6 +887,14 @@ include: 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', () { From 3473721665d238bac2b5993a4ce0ccd1608cbe6e Mon Sep 17 00:00:00 2001 From: lizy Date: Fri, 10 Jul 2026 21:32:47 +0800 Subject: [PATCH 09/19] cli: anchor config resolution to target project root and require explicit configs to exist - Resolve relative --config paths from the target project root, never from CWD. - Add requireFile parameter to ScanConfig.fromFile to reject missing explicit configs. - Document the non-merging override chain in PROJECT_RULES.md. - Update CONFIGURATION_STRATEGY.md to reflect the new config path semantics. --- CONFIGURATION_STRATEGY.md | 3 ++- PROJECT_RULES.md | 9 ++++---- .../lib/src/config_loader.dart | 20 ++++++++--------- .../lib/src/project_resolver.dart | 22 +++++++++++-------- 4 files changed, 30 insertions(+), 24 deletions(-) diff --git a/CONFIGURATION_STRATEGY.md b/CONFIGURATION_STRATEGY.md index 258b3af..6ebbb76 100644 --- a/CONFIGURATION_STRATEGY.md +++ b/CONFIGURATION_STRATEGY.md @@ -161,7 +161,8 @@ Important distinction: CLI responses should stay short and operational: - `--help`: show common commands, option summary, and the four-level config path -- no files found: mention project path plus include/exclude patterns +- 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 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/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/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) { From 7c7261a7e37a3a43a347d1e65221f0a6a6859ede Mon Sep 17 00:00:00 2001 From: lizy Date: Fri, 10 Jul 2026 21:32:59 +0800 Subject: [PATCH 10/19] cli: reject no-match full scans, preserve exit codes, and harden changed-only Git file collection - Throw ScanException when a full scan matches no configured Dart files. - Doctor now reports empty file match as error instead of warning. - Harden getChangedFiles with proper Git ref validation and null-byte splitting. - Preserve setup-error exit codes in CI script examples in README. - Update exit code documentation for all three README files. --- README.md | 27 ++-- README.zh.md | 27 ++-- packages/flutterguard_cli/README.md | 4 +- .../flutterguard_cli/bin/flutterguard.dart | 35 +++--- .../lib/src/config_tools.dart | 15 ++- .../lib/src/file_collector.dart | 116 +++++++++++++++--- .../flutterguard_cli/lib/src/scanner.dart | 37 ++++-- 7 files changed, 182 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 517abd9..e6f3d3f 100644 --- a/README.md +++ b/README.md @@ -221,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. --- @@ -513,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 ``` @@ -530,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 5396299..40e9f22 100644 --- a/README.zh.md +++ b/README.zh.md @@ -219,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` 不存在时使用内置默认值;任何显式选择的配置都必须存在。 --- @@ -511,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 ``` @@ -528,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/packages/flutterguard_cli/README.md b/packages/flutterguard_cli/README.md index abe183f..b5f2eee 100644 --- a/packages/flutterguard_cli/README.md +++ b/packages/flutterguard_cli/README.md @@ -96,7 +96,7 @@ architecture: detect_cycles: true ``` -If no config file exists, defaults are used (all default 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 @@ -127,7 +127,7 @@ 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 diff --git a/packages/flutterguard_cli/bin/flutterguard.dart b/packages/flutterguard_cli/bin/flutterguard.dart index 8381737..6196ea5 100644 --- a/packages/flutterguard_cli/bin/flutterguard.dart +++ b/packages/flutterguard_cli/bin/flutterguard.dart @@ -335,11 +335,15 @@ void _handleConfigPrint(ArgResults args) { final projectPath = ProjectResolver.resolveProjectPath( args['path'] as String, ); + final explicitConfig = _explicitConfigPath(args); final configPath = ConfigTools.resolveConfigPathForProject( projectPath: projectPath, - configPath: args['config'] as String, + configPath: explicitConfig, + ); + final config = ScanConfig.fromFile( + configPath, + requireFile: explicitConfig != null, ); - final config = ScanConfig.fromFile(configPath); stdout.write(ConfigTools.effectiveYaml(config)); } on FormatException catch (e) { stderr.writeln('Error: ${e.message}'); @@ -351,7 +355,7 @@ void _handleConfigDoctor(ArgResults args) { try { final result = ConfigTools.doctor( projectPath: args['path'] as String, - configPath: args['config'] as String, + configPath: _explicitConfigPath(args), ); stdout.write(ConfigTools.formatDoctorResult(result)); if (result.hasErrors) exit(1); @@ -401,7 +405,7 @@ void _handleScan(ArgResults args) { try { result = FlutterGuardScanner.scan( projectPath: args['path'] as String, - configPath: args['config'] as String, + configPath: _explicitConfigPath(args), outputDir: args['output'] as String, writeJson: format == 'json', writeSarif: format == 'sarif', @@ -419,12 +423,11 @@ void _handleScan(ArgResults args) { } if (result.files.isEmpty) { - stderr.writeln('No Dart files found.'); - stderr.writeln( - 'Check that the project path is correct and that include/exclude patterns match Dart files.'); - stderr.writeln( - 'Typical config: include: [lib/**], exclude generated files, then add architecture rules only when boundaries are known.'); - exit(0); + 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') { @@ -520,7 +523,7 @@ void _handleBaselineCreate(ArgResults args) { try { final result = FlutterGuardScanner.scan( projectPath: projectPath, - configPath: args['config'] as String, + configPath: _explicitConfigPath(args), applySuppression: false, ); final outputPath = @@ -560,7 +563,7 @@ void _handleBaselinePrune(ArgResults args) { try { final result = FlutterGuardScanner.scan( projectPath: projectPath, - configPath: args['config'] as String, + configPath: _explicitConfigPath(args), applySuppression: false, ); final resolvedBaselinePath = _resolveProjectFile( @@ -604,7 +607,7 @@ void _handleBaselineCheck(ArgResults args) { try { final result = FlutterGuardScanner.scan( projectPath: projectPath, - configPath: args['config'] as String, + configPath: _explicitConfigPath(args), applySuppression: false, ); final baseline = Baseline.load(_resolveProjectFile( @@ -690,7 +693,7 @@ void _handleIssue( final result = FlutterGuardScanner.scan( projectPath: subcommand['path'] as String, - configPath: subcommand['config'] as String, + configPath: _explicitConfigPath(subcommand), applySuppression: false, ); final exported = IssueExporter.export( @@ -723,6 +726,10 @@ String _resolveProjectFile(String projectPath, String filePath) { return p.isAbsolute(filePath) ? filePath : p.join(projectPath, filePath); } +String? _explicitConfigPath(ArgResults args) { + return args.wasParsed('config') ? args['config'] as String : null; +} + void _handleRules(ArgResults args) { final format = args['format'] as String; final all = RuleRegistry.all(); diff --git a/packages/flutterguard_cli/lib/src/config_tools.dart b/packages/flutterguard_cli/lib/src/config_tools.dart index a4d0016..b0db822 100644 --- a/packages/flutterguard_cli/lib/src/config_tools.dart +++ b/packages/flutterguard_cli/lib/src/config_tools.dart @@ -360,12 +360,8 @@ rules: static String resolveConfigPathForProject({ required String projectPath, - required String configPath, + String? configPath, }) { - if (p.isAbsolute(configPath)) return configPath; - final fromProject = p.join(projectPath, configPath); - if (configPath == 'flutterguard.yaml') return fromProject; - if (File(fromProject).existsSync()) return fromProject; return ProjectResolver.resolveConfigPath( projectPath: projectPath, explicitConfig: configPath, @@ -430,7 +426,7 @@ rules: static DoctorResult doctor({ required String projectPath, - required String configPath, + String? configPath, }) { final resolvedProjectPath = ProjectResolver.resolveProjectPath(projectPath); if (!Directory(resolvedProjectPath).existsSync()) { @@ -442,7 +438,10 @@ rules: configPath: configPath, ); final configExists = File(resolvedConfigPath).existsSync(); - final config = ScanConfig.fromFile(resolvedConfigPath); + final config = ScanConfig.fromFile( + resolvedConfigPath, + requireFile: configPath != null, + ); final files = FileCollector.collect(resolvedProjectPath, config); final messages = []; @@ -454,7 +453,7 @@ rules: } if (files.isEmpty) { messages.add(DoctorMessage( - DoctorSeverity.warning, + DoctorSeverity.error, 'No Dart files matched include/exclude patterns.', )); } diff --git a/packages/flutterguard_cli/lib/src/file_collector.dart b/packages/flutterguard_cli/lib/src/file_collector.dart index 5be69bd..bbf439a 100644 --- a/packages/flutterguard_cli/lib/src/file_collector.dart +++ b/packages/flutterguard_cli/lib/src/file_collector.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:glob/glob.dart'; @@ -7,6 +8,15 @@ 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); @@ -32,38 +42,108 @@ class FileCollector { return allFiles.toList()..sort(); } - static Set getChangedFiles(String projectPath, String base) { - final gitDir = Directory(p.join(projectPath, '.git')); - if (!gitDir.existsSync()) return {}; - + static Set? getChangedFiles(String projectPath, String base) { try { - final result = Process.runSync( + final rootResult = Process.runSync( 'git', - ['diff', '--name-only', base, '--diff-filter=ACMR'], + ['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'], - workingDirectory: projectPath, + ['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 line in (result.stdout as String).split('\n')) { - final trimmed = line.trim(); - if (trimmed.isNotEmpty) { - changed.add(p.normalize(p.join(projectPath, trimmed))); + 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 line in (untracked.stdout as String).split('\n')) { - final trimmed = line.trim(); - if (trimmed.isNotEmpty) { - changed.add(p.normalize(p.join(projectPath, trimmed))); + 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; - } catch (_) { - return {}; + } 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/scanner.dart b/packages/flutterguard_cli/lib/src/scanner.dart index 379a013..b17ade2 100644 --- a/packages/flutterguard_cli/lib/src/scanner.dart +++ b/packages/flutterguard_cli/lib/src/scanner.dart @@ -59,7 +59,7 @@ class ScanResult { class FlutterGuardScanner { static ScanResult scan({ required String projectPath, - String configPath = 'flutterguard.yaml', + String? configPath, String outputDir = '.flutterguard', bool writeJson = false, bool writeSarif = false, @@ -80,20 +80,35 @@ class FlutterGuardScanner { projectPath: resolvedProjectPath, explicitConfig: configPath, ); - final config = ScanConfig.fromFile(resolvedConfigPath); + 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 = 'full'; var filesToScan = files; if (changedOnly) { - final changed = FileCollector.getChangedFiles(resolvedProjectPath, base); - if (changed.isNotEmpty) { - final changedDart = changed - .where((f) => f.endsWith('.dart')) - .map(normalizePath) - .toSet(); - filesToScan = files.where((f) => changedDart.contains(f)).toList(); - scanMode = filesToScan.isNotEmpty ? 'changed' : 'full'; + try { + final changed = FileCollector.getChangedFiles( + resolvedProjectPath, + base, + ); + if (changed != null) { + final changedDart = changed + .where((f) => f.endsWith('.dart')) + .map(normalizePath) + .toSet(); + filesToScan = files.where((f) => changedDart.contains(f)).toList(); + scanMode = 'changed'; + } + } on ChangedFilesException catch (e) { + throw ScanException(e.message); } } @@ -105,7 +120,7 @@ class FlutterGuardScanner { files: filesToScan, config: config, projectPath: resolvedProjectPath, - changedOnly: changedOnly, + changedOnly: scanMode == 'changed', ); var issues = rawIssues; From b7aad2d4f5ecd537cb56a9759a53e3049e3f251f Mon Sep 17 00:00:00 2001 From: lizy Date: Fri, 10 Jul 2026 21:33:08 +0800 Subject: [PATCH 11/19] ci: validate current checkout on OS matrix instead of scanning with pub.dev installation - Run analyze, tests, and scan against the current monorepo checkout. - Use fail-fast: false to collect results from all OS runners. - Upload report with if-no-files-found: error to surface missing reports early. - Update local CI scripts to use workspace launcher and preserve exit codes. --- .github/workflows/flutterguard.yml | 26 ++++++++++++++++++-------- scripts/scan_ci.ps1 | 17 ++++++++++++----- scripts/scan_ci.sh | 17 +++++++++++------ 3 files changed, 41 insertions(+), 19 deletions(-) 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/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 From 84b7fc2d7fcea5dc0eb8250192fdd04cf76900cc Mon Sep 17 00:00:00 2001 From: lizy Date: Fri, 10 Jul 2026 21:33:17 +0800 Subject: [PATCH 12/19] cli: add tests for config hardening, empty scan policy, and changed-only ref validation - Test scanner allows built-in defaults when default config is absent. - Test scanner rejects missing custom config and missing explicit default config. - Test requireFile rejection in ScanConfig.fromFile. - Test changed-only clean repo produces empty scan (not full fallback). - Test changed-only rejects invalid and option-like Git base refs. - Test doctor returns error (not warning) for empty file matches. - Add cli_test.dart for process-level CLI exit and report behavior. --- packages/flutterguard_cli/test/cli_test.dart | 188 ++++++++++++++++++ .../flutterguard_cli/test/scanner_test.dart | 151 +++++++++++++- 2 files changed, 332 insertions(+), 7 deletions(-) create mode 100644 packages/flutterguard_cli/test/cli_test.dart 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 3695c29..920a4ca 100644 --- a/packages/flutterguard_cli/test/scanner_test.dart +++ b/packages/flutterguard_cli/test/scanner_test.dart @@ -407,6 +407,54 @@ dependencies: }); 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 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, @@ -441,6 +489,16 @@ dependencies: ); }); + 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)); @@ -674,7 +732,7 @@ class IgnoredWidget extends StatelessWidget {} 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 changedFile = p.join(dir.path, 'lib', 'changed 设备.dart'); final unchangedFile = p.join(dir.path, 'lib', 'unchanged.dart'); _writeWidgetIssue(changedFile, 'ChangedWidget'); _writeWidgetIssue(unchangedFile, 'UnchangedWidget'); @@ -707,8 +765,12 @@ class AnotherChangedWidget extends StatelessWidget {} 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'); - _writeWidgetIssue(p.join(dir.path, 'lib', 'two.dart'), 'TwoWidget'); + 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, @@ -717,7 +779,79 @@ class AnotherChangedWidget extends StatelessWidget {} expect(result.scanMode, 'full'); expect(result.files, hasLength(2)); - expect(result.issues, 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 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', () { @@ -735,6 +869,9 @@ class AnotherChangedWidget extends StatelessWidget {} "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, @@ -848,7 +985,7 @@ architecture: ); }); - test('doctor warns when globs match no Dart files', () { + 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'); @@ -862,10 +999,10 @@ include: configPath: 'flutterguard.yaml', ); - expect(result.hasErrors, isFalse); + expect(result.hasErrors, isTrue); expect( result.messages.any((message) => - message.severity == DoctorSeverity.warning && + message.severity == DoctorSeverity.error && message.message.contains('No Dart files matched')), isTrue, ); From 33f65abab28449a5933687c6ac6bbcce76a9691a Mon Sep 17 00:00:00 2001 From: lizy Date: Fri, 10 Jul 2026 21:33:25 +0800 Subject: [PATCH 13/19] docs: update AGENTS.md and CHANGELOG for config hardening, scan policy, and CI changes --- AGENTS.md | 2 +- CHANGELOG.md | 6 ++++++ packages/flutterguard_cli/AGENTS.md | 4 ++-- packages/flutterguard_cli/CHANGELOG.md | 7 +++++++ packages/flutterguard_cli/test/AGENTS.md | 13 +++++++++---- 5 files changed, 25 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 731d79c..fc08d70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ 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 (37 tests) | +| `dart run melos run test:cli` | CLI tests only (57 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 | diff --git a/CHANGELOG.md b/CHANGELOG.md index 926166a..87b9687 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 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 diff --git a/packages/flutterguard_cli/AGENTS.md b/packages/flutterguard_cli/AGENTS.md index 43524f1..65088e5 100644 --- a/packages/flutterguard_cli/AGENTS.md +++ b/packages/flutterguard_cli/AGENTS.md @@ -43,8 +43,8 @@ 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) +- test files: `test/scanner_test.dart` (53 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 scanner.dart ## Current Toolchain Flow diff --git a/packages/flutterguard_cli/CHANGELOG.md b/packages/flutterguard_cli/CHANGELOG.md index ca83f62..314b403 100644 --- a/packages/flutterguard_cli/CHANGELOG.md +++ b/packages/flutterguard_cli/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +- **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 diff --git a/packages/flutterguard_cli/test/AGENTS.md b/packages/flutterguard_cli/test/AGENTS.md index 8fa24a3..81d639a 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 (53 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 | 14 | Scan policy, config resolution, suppression, baseline, SARIF, issue export | +| Changed-only | 5 | Git filtering, 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. From f7a26bf81605338faf84bcda4474e3cebba760ae Mon Sep 17 00:00:00 2001 From: lizy Date: Sun, 12 Jul 2026 21:17:23 +0800 Subject: [PATCH 14/19] cli: add shared scan analysis infrastructure Introduce ScanContext for scope/mode, SourceWorkspace for shared source/AST caching, ImportGraph for resolved Dart imports, and DependencyBoundaryEngine for layer/module boundary analysis. --- .../lib/src/boundary_engine.dart | 79 +++++++++++++++ .../lib/src/import_graph.dart | 74 ++++++++++++++ .../lib/src/scan_context.dart | 26 +++++ .../lib/src/source_workspace.dart | 97 +++++++++++++++++++ 4 files changed, 276 insertions(+) create mode 100644 packages/flutterguard_cli/lib/src/boundary_engine.dart create mode 100644 packages/flutterguard_cli/lib/src/import_graph.dart create mode 100644 packages/flutterguard_cli/lib/src/scan_context.dart create mode 100644 packages/flutterguard_cli/lib/src/source_workspace.dart 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/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/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/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, + )); + } +} From a8507072a9a347ef88e60bded5ce8f2d4417fb96 Mon Sep 17 00:00:00 2001 From: lizy Date: Sun, 12 Jul 2026 21:17:30 +0800 Subject: [PATCH 15/19] cli: wire rules through catalog and shared workspace Add rules/catalog.dart as the metadata and execution source of truth, refactor all rule classes to consume the shared SourceWorkspace, ImportGraph, and boundary engine, and update scanner orchestration, registry, suppression, issue export, and report generation. --- .../lib/flutterguard_cli.dart | 3 + .../lib/src/issue_export.dart | 13 +- .../lib/src/report_generator.dart | 5 + .../lib/src/rules/ble_scanning.dart | 20 +- .../lib/src/rules/catalog.dart | 172 ++++++++++++++++++ .../lib/src/rules/circular_dependency.dart | 68 ++----- .../lib/src/rules/device_lifecycle.dart | 18 +- .../lib/src/rules/iot_security.dart | 16 +- .../lib/src/rules/large_units.dart | 75 ++++---- .../lib/src/rules/layer_violation.dart | 155 ++++++---------- .../lib/src/rules/lifecycle_resource.dart | 18 +- .../src/rules/missing_const_constructor.dart | 18 +- .../lib/src/rules/module_violation.dart | 157 ++++++---------- .../lib/src/rules/mqtt_connection.dart | 20 +- .../lib/src/rules/pubspec_security.dart | 24 ++- .../lib/src/rules/registry.dart | 28 +-- .../flutterguard_cli/lib/src/scanner.dart | 110 ++++------- .../flutterguard_cli/lib/src/suppression.dart | 26 ++- 18 files changed, 482 insertions(+), 464 deletions(-) create mode 100644 packages/flutterguard_cli/lib/src/rules/catalog.dart diff --git a/packages/flutterguard_cli/lib/flutterguard_cli.dart b/packages/flutterguard_cli/lib/flutterguard_cli.dart index c63bd2d..e5c800d 100644 --- a/packages/flutterguard_cli/lib/flutterguard_cli.dart +++ b/packages/flutterguard_cli/lib/flutterguard_cli.dart @@ -7,7 +7,9 @@ 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'; @@ -21,4 +23,5 @@ 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/issue_export.dart b/packages/flutterguard_cli/lib/src/issue_export.dart index 2741e78..643e445 100644 --- a/packages/flutterguard_cli/lib/src/issue_export.dart +++ b/packages/flutterguard_cli/lib/src/issue_export.dart @@ -5,6 +5,7 @@ import 'package:path/path.dart' as p; import 'baseline.dart'; import 'rules/registry.dart'; +import 'source_workspace.dart'; import 'static_issue.dart'; class IssueExporter { @@ -15,6 +16,7 @@ class IssueExporter { String? filePath, int? line, int contextLines = 2, + SourceWorkspace? workspace, }) { final issue = _findIssue( projectPath: projectPath, @@ -36,7 +38,7 @@ class IssueExporter { 'fingerprint': Baseline.fingerprint(issue, projectPath), 'issue': issue.toJson()..['file'] = relativePath, 'rule': meta?.toJson(), - 'context': _context(issue, contextLines), + 'context': _context(issue, contextLines, workspace: workspace), 'feedbackTemplate': { 'whyFalsePositiveOrFalseNegative': '', 'expectedBehavior': '', @@ -70,7 +72,11 @@ class IssueExporter { return null; } - static Map _context(StaticIssue issue, int contextLines) { + static Map _context( + StaticIssue issue, + int contextLines, { + SourceWorkspace? workspace, + }) { final line = issue.line; final file = File(issue.file); if (line == null || !file.existsSync()) { @@ -80,7 +86,8 @@ class IssueExporter { }; } - final lines = file.readAsLinesSync(); + 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 { diff --git a/packages/flutterguard_cli/lib/src/report_generator.dart b/packages/flutterguard_cli/lib/src/report_generator.dart index f26950e..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 { @@ -60,6 +61,7 @@ class ReportGenerator { String scanMode = 'full', int suppressedCount = 0, int suppressedByBaselineCount = 0, + List diagnostics = const [], }) { final byDomain = _buildSummaryByDomain(issues); final score = calculateScore(issues); @@ -77,9 +79,12 @@ class ReportGenerator { '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); diff --git a/packages/flutterguard_cli/lib/src/rules/ble_scanning.dart b/packages/flutterguard_cli/lib/src/rules/ble_scanning.dart index 9e9a1d0..47ce38d 100644 --- a/packages/flutterguard_cli/lib/src/rules/ble_scanning.dart +++ b/packages/flutterguard_cli/lib/src/rules/ble_scanning.dart @@ -1,6 +1,3 @@ -import 'dart:io'; - -import 'package:analyzer/dart/analysis/utilities.dart'; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/source/line_info.dart'; @@ -9,6 +6,7 @@ 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']; @@ -18,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; 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 e95ad40..09e7725 100644 --- a/packages/flutterguard_cli/lib/src/rules/circular_dependency.dart +++ b/packages/flutterguard_cli/lib/src/rules/circular_dependency.dart @@ -1,14 +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 } @@ -19,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 = {}; diff --git a/packages/flutterguard_cli/lib/src/rules/device_lifecycle.dart b/packages/flutterguard_cli/lib/src/rules/device_lifecycle.dart index b6d9cbb..c48cab5 100644 --- a/packages/flutterguard_cli/lib/src/rules/device_lifecycle.dart +++ b/packages/flutterguard_cli/lib/src/rules/device_lifecycle.dart @@ -1,6 +1,3 @@ -import 'dart:io'; - -import 'package:analyzer/dart/analysis/utilities.dart'; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/source/line_info.dart'; @@ -9,6 +6,7 @@ import '../domain.dart'; import '../priority.dart'; import '../rule_meta.dart'; import '../source_utils.dart'; +import '../source_workspace.dart'; import '../static_issue.dart'; const _lifecyclePairs = { @@ -25,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; diff --git a/packages/flutterguard_cli/lib/src/rules/iot_security.dart b/packages/flutterguard_cli/lib/src/rules/iot_security.dart index 937c529..eca57a0 100644 --- a/packages/flutterguard_cli/lib/src/rules/iot_security.dart +++ b/packages/flutterguard_cli/lib/src/rules/iot_security.dart @@ -1,9 +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( @@ -19,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; diff --git a/packages/flutterguard_cli/lib/src/rules/large_units.dart b/packages/flutterguard_cli/lib/src/rules/large_units.dart index 28ddf60..fce94a0 100644 --- a/packages/flutterguard_cli/lib/src/rules/large_units.dart +++ b/packages/flutterguard_cli/lib/src/rules/large_units.dart @@ -1,6 +1,3 @@ -import 'dart:io'; - -import 'package:analyzer/dart/analysis/utilities.dart'; import 'package:analyzer/dart/ast/ast.dart'; import 'package:path/path.dart' as p; @@ -8,6 +5,7 @@ import '../config_loader.dart'; import '../domain.dart'; import '../priority.dart'; import '../rule_meta.dart'; +import '../source_workspace.dart'; import '../static_issue.dart'; class LargeUnitsRule { @@ -21,55 +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 []; } diff --git a/packages/flutterguard_cli/lib/src/rules/layer_violation.dart b/packages/flutterguard_cli/lib/src/rules/layer_violation.dart index 6be78d8..2f9109f 100644 --- a/packages/flutterguard_cli/lib/src/rules/layer_violation.dart +++ b/packages/flutterguard_cli/lib/src/rules/layer_violation.dart @@ -1,17 +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 '../rule_meta.dart'; -import '../source_utils.dart'; +import '../source_workspace.dart'; import '../static_issue.dart'; class LayerViolationRule { @@ -20,109 +13,65 @@ 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( diff --git a/packages/flutterguard_cli/lib/src/rules/lifecycle_resource.dart b/packages/flutterguard_cli/lib/src/rules/lifecycle_resource.dart index 48ea9f2..62be681 100644 --- a/packages/flutterguard_cli/lib/src/rules/lifecycle_resource.dart +++ b/packages/flutterguard_cli/lib/src/rules/lifecycle_resource.dart @@ -1,6 +1,3 @@ -import 'dart:io'; - -import 'package:analyzer/dart/analysis/utilities.dart'; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/source/line_info.dart'; @@ -9,6 +6,7 @@ import '../domain.dart'; import '../priority.dart'; import '../rule_meta.dart'; import '../source_utils.dart'; +import '../source_workspace.dart'; import '../static_issue.dart'; const _resourceTypes = { @@ -28,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; 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 6470de0..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,6 +1,3 @@ -import 'dart:io'; - -import 'package:analyzer/dart/analysis/utilities.dart'; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/source/line_info.dart'; @@ -9,6 +6,7 @@ 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'}; @@ -18,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; diff --git a/packages/flutterguard_cli/lib/src/rules/module_violation.dart b/packages/flutterguard_cli/lib/src/rules/module_violation.dart index b497dc7..8a89f24 100644 --- a/packages/flutterguard_cli/lib/src/rules/module_violation.dart +++ b/packages/flutterguard_cli/lib/src/rules/module_violation.dart @@ -1,17 +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 '../rule_meta.dart'; -import '../source_utils.dart'; +import '../source_workspace.dart'; import '../static_issue.dart'; class ModuleViolationRule { @@ -20,109 +13,65 @@ 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( diff --git a/packages/flutterguard_cli/lib/src/rules/mqtt_connection.dart b/packages/flutterguard_cli/lib/src/rules/mqtt_connection.dart index fc27cd3..cf84788 100644 --- a/packages/flutterguard_cli/lib/src/rules/mqtt_connection.dart +++ b/packages/flutterguard_cli/lib/src/rules/mqtt_connection.dart @@ -1,6 +1,3 @@ -import 'dart:io'; - -import 'package:analyzer/dart/analysis/utilities.dart'; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/source/line_info.dart'; @@ -9,6 +6,7 @@ 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']; @@ -19,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; diff --git a/packages/flutterguard_cli/lib/src/rules/pubspec_security.dart b/packages/flutterguard_cli/lib/src/rules/pubspec_security.dart index 0d583ab..152c6bc 100644 --- a/packages/flutterguard_cli/lib/src/rules/pubspec_security.dart +++ b/packages/flutterguard_cli/lib/src/rules/pubspec_security.dart @@ -7,6 +7,7 @@ import '../config_loader.dart'; import '../domain.dart'; import '../priority.dart'; import '../rule_meta.dart'; +import '../source_workspace.dart'; import '../static_issue.dart'; const _vulnerableDeps = { @@ -23,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 { @@ -38,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); diff --git a/packages/flutterguard_cli/lib/src/rules/registry.dart b/packages/flutterguard_cli/lib/src/rules/registry.dart index 845b375..30777d5 100644 --- a/packages/flutterguard_cli/lib/src/rules/registry.dart +++ b/packages/flutterguard_cli/lib/src/rules/registry.dart @@ -1,33 +1,9 @@ import 'package:flutterguard_cli/src/rule_meta.dart'; -import 'package:flutterguard_cli/src/rules/ble_scanning.dart'; -import 'package:flutterguard_cli/src/rules/circular_dependency.dart'; -import 'package:flutterguard_cli/src/rules/device_lifecycle.dart'; -import 'package:flutterguard_cli/src/rules/iot_security.dart'; -import 'package:flutterguard_cli/src/rules/large_units.dart'; -import 'package:flutterguard_cli/src/rules/layer_violation.dart'; -import 'package:flutterguard_cli/src/rules/lifecycle_resource.dart'; -import 'package:flutterguard_cli/src/rules/missing_const_constructor.dart'; -import 'package:flutterguard_cli/src/rules/module_violation.dart'; -import 'package:flutterguard_cli/src/rules/mqtt_connection.dart'; -import 'package:flutterguard_cli/src/rules/pubspec_security.dart'; +import 'package:flutterguard_cli/src/rules/catalog.dart'; class RuleRegistry { static final Map _registry = () { - final list = [ - LargeUnitsRule.describeLargeFile(), - LargeUnitsRule.describeLargeClass(), - LargeUnitsRule.describeLargeBuildMethod(), - LifecycleResourceRule.describe(), - MissingConstConstructorRule.describe(), - DeviceLifecycleRule.describe(), - MqttConnectionRule.describe(), - BleScanningRule.describe(), - IotSecurityRule.describe(), - PubspecSecurityRule.describe(), - LayerViolationRule.describe(), - ModuleViolationRule.describe(), - CircularDependencyRule.describe(), - ]; + final list = RuleCatalog.metadata(); return {for (final m in list) m.id: m}; }(); diff --git a/packages/flutterguard_cli/lib/src/scanner.dart b/packages/flutterguard_cli/lib/src/scanner.dart index b17ade2..fe81963 100644 --- a/packages/flutterguard_cli/lib/src/scanner.dart +++ b/packages/flutterguard_cli/lib/src/scanner.dart @@ -8,19 +8,11 @@ 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 { @@ -42,6 +34,8 @@ class ScanResult { final int suppressedByBaselineCount; final int score; final String scanMode; + final List diagnostics; + final SourceWorkspace sources; const ScanResult({ required this.projectPath, @@ -53,6 +47,8 @@ class ScanResult { required this.suppressedByBaselineCount, required this.score, required this.scanMode, + required this.sources, + this.diagnostics = const [], }); } @@ -63,6 +59,7 @@ class FlutterGuardScanner { String outputDir = '.flutterguard', bool writeJson = false, bool writeSarif = false, + @Deprecated('Color is a presentation concern; pass it to ReportGenerator.') bool noColor = false, bool changedOnly = false, String base = 'main', @@ -90,8 +87,9 @@ class FlutterGuardScanner { 'No Dart files matched the configured include/exclude patterns.', ); } - var scanMode = 'full'; + var scanMode = ScanMode.full; var filesToScan = files; + var changedFiles = {}; if (changedOnly) { try { @@ -100,12 +98,11 @@ class FlutterGuardScanner { base, ); if (changed != null) { - final changedDart = changed - .where((f) => f.endsWith('.dart')) - .map(normalizePath) - .toSet(); + changedFiles = changed.map(normalizePath).toSet(); + final changedDart = + changedFiles.where((f) => f.endsWith('.dart')).toSet(); filesToScan = files.where((f) => changedDart.contains(f)).toList(); - scanMode = 'changed'; + scanMode = ScanMode.changed; } } on ChangedFilesException catch (e) { throw ScanException(e.message); @@ -116,17 +113,25 @@ class FlutterGuardScanner { ? outputDir : p.join(resolvedProjectPath, outputDir); - final rawIssues = _analyze( - files: filesToScan, - config: config, + final sources = SourceWorkspace(); + final context = ScanContext( projectPath: resolvedProjectPath, - changedOnly: scanMode == 'changed', + 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); + final suppression = SuppressionFilter( + filesToScan, + workspace: sources, + ); final visible = []; for (final issue in issues) { if (suppression.isSuppressed(issue)) { @@ -164,9 +169,10 @@ class FlutterGuardScanner { final json = ReportGenerator.generateJson( projectPath: resolvedProjectPath, issues: issues, - scanMode: scanMode, + scanMode: scanMode.name, suppressedCount: suppressedCount, suppressedByBaselineCount: suppressedByBaselineCount, + diagnostics: sources.diagnostics, ); File(p.join(reportDir, 'report.json')).writeAsStringSync(json); } @@ -187,62 +193,14 @@ class FlutterGuardScanner { suppressedCount: suppressedCount, suppressedByBaselineCount: suppressedByBaselineCount, score: score, - scanMode: scanMode, + scanMode: scanMode.name, + sources: sources, + diagnostics: sources.diagnostics, ); } - static List _analyze({ - required List files, - required ScanConfig config, - required String projectPath, - bool changedOnly = false, - }) { - 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: !changedOnly && 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/suppression.dart b/packages/flutterguard_cli/lib/src/suppression.dart index 9747a46..9bb2c47 100644 --- a/packages/flutterguard_cli/lib/src/suppression.dart +++ b/packages/flutterguard_cli/lib/src/suppression.dart @@ -1,13 +1,17 @@ import 'dart:io'; +import 'source_workspace.dart'; import 'static_issue.dart'; class SuppressionFilter { final Map>> _rulesByFileAndLine = {}; - SuppressionFilter(Iterable files) { + SuppressionFilter( + Iterable files, { + SourceWorkspace? workspace, + }) { for (final file in files) { - _rulesByFileAndLine[file] = _parseFile(file); + _rulesByFileAndLine[file] = _parseFile(file, workspace: workspace); } } @@ -21,12 +25,22 @@ class SuppressionFilter { return rules.contains('all') || rules.contains(issue.id); } - static Map> _parseFile(String path) { - final file = File(path); - if (!file.existsSync()) return const {}; + 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 = >{}; - final lines = file.readAsLinesSync(); for (var index = 0; index < lines.length; index++) { final parsed = _parseLine(lines[index]); if (parsed == null) continue; From edc417c5611c77e40db85dc21531d13994c7860e Mon Sep 17 00:00:00 2001 From: lizy Date: Sun, 12 Jul 2026 21:17:35 +0800 Subject: [PATCH 16/19] cli: split command handlers into lib/src/cli Reduce bin/flutterguard.dart to top-level routing, help, and exit codes, moving scan, baseline, config, issue, and rule command behavior into dedicated handlers under lib/src/cli/. --- .../flutterguard_cli/bin/flutterguard.dart | 584 ++---------------- .../lib/src/cli/baseline_commands.dart | 130 ++++ .../lib/src/cli/cli_parsers.dart | 202 ++++++ .../lib/src/cli/config_commands.dart | 71 +++ .../lib/src/cli/issue_commands.dart | 61 ++ .../lib/src/cli/rule_commands.dart | 76 +++ .../lib/src/cli/scan_command.dart | 99 +++ 7 files changed, 681 insertions(+), 542 deletions(-) create mode 100644 packages/flutterguard_cli/lib/src/cli/baseline_commands.dart create mode 100644 packages/flutterguard_cli/lib/src/cli/cli_parsers.dart create mode 100644 packages/flutterguard_cli/lib/src/cli/config_commands.dart create mode 100644 packages/flutterguard_cli/lib/src/cli/issue_commands.dart create mode 100644 packages/flutterguard_cli/lib/src/cli/rule_commands.dart create mode 100644 packages/flutterguard_cli/lib/src/cli/scan_command.dart diff --git a/packages/flutterguard_cli/bin/flutterguard.dart b/packages/flutterguard_cli/bin/flutterguard.dart index 6196ea5..ad79380 100644 --- a/packages/flutterguard_cli/bin/flutterguard.dart +++ b/packages/flutterguard_cli/bin/flutterguard.dart @@ -1,210 +1,37 @@ -import 'dart:convert'; import 'dart:io'; import 'package:args/args.dart'; -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/install_doctor.dart'; -import 'package:flutterguard_cli/src/issue_export.dart'; -import 'package:flutterguard_cli/src/project_resolver.dart'; -import 'package:flutterguard_cli/src/report_generator.dart'; -import 'package:flutterguard_cli/src/rules/registry.dart'; -import 'package:flutterguard_cli/src/scanner.dart'; -import 'package:path/path.dart' as p; +import 'package:flutterguard_cli/src/cli/scan_command.dart'; +import 'package:flutterguard_cli/src/cli/baseline_commands.dart'; +import 'package:flutterguard_cli/src/cli/cli_parsers.dart'; +import 'package:flutterguard_cli/src/cli/config_commands.dart'; +import 'package:flutterguard_cli/src/cli/issue_commands.dart'; +import 'package:flutterguard_cli/src/cli/rule_commands.dart'; const _version = '0.4.1'; void main(List args) { final normalizedArgs = _extractPositionalPath(args); - final 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', '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); - - final baselineCreateParser = 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); - - final baselineStatsParser = 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); - - final baselinePruneParser = 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); - - final baselineCheckParser = 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); - - final baselineParser = ArgParser() - ..addCommand('create', baselineCreateParser) - ..addCommand('stats', baselineStatsParser) - ..addCommand('prune', baselinePruneParser) - ..addCommand('check', baselineCheckParser) - ..addFlag('help', abbr: 'h', help: 'Show baseline usage', negatable: false); - - final initParser = 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); - - final installDoctorParser = ArgParser() - ..addFlag('help', - abbr: 'h', help: 'Show install doctor usage', negatable: false); - - final doctorParser = ArgParser() - ..addCommand('install', installDoctorParser) - ..addFlag('help', abbr: 'h', help: 'Show doctor usage', negatable: false); - - final issueExportParser = 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); - - final issueParser = ArgParser() - ..addCommand('export', issueExportParser) - ..addFlag('help', abbr: 'h', help: 'Show issue usage', negatable: false); - - final configPrintParser = 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 print usage', negatable: false); - - final configDoctorParser = 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 doctor usage', negatable: false); - - final configParser = ArgParser() - ..addCommand('print', configPrintParser) - ..addCommand('doctor', configDoctorParser) - ..addFlag('help', abbr: 'h', help: 'Show config usage', negatable: false); - - final rulesParser = ArgParser() - ..addOption('format', - abbr: 'f', - defaultsTo: 'table', - allowed: ['table', 'json'], - help: 'Output format') - ..addFlag('help', abbr: 'h', help: 'Show rules usage', negatable: false); - - final explainParser = ArgParser() - ..addFlag('help', abbr: 'h', help: 'Show explain usage', negatable: false); - - final parser = ArgParser() - ..addCommand('scan', scanParser) - ..addCommand('baseline', baselineParser) - ..addCommand('doctor', doctorParser) - ..addCommand('init', initParser) - ..addCommand('config', configParser) - ..addCommand('issue', issueParser) - ..addCommand('rules', rulesParser) - ..addCommand('explain', explainParser) - ..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); @@ -278,23 +105,7 @@ void main(List args) { } void _handleInit(ArgResults args) { - try { - final restPath = args.rest.isNotEmpty ? args.rest.first : null; - final outputPath = ConfigTools.writeInitConfig( - projectPath: restPath ?? args['path'] as String, - 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 ${restPath ?? args['path']}', - ); - } on StateError catch (e) { - stderr.writeln('Error: ${e.message}'); - exit(2); - } + ConfigCommands.init(args); } void _handleConfig( @@ -331,41 +142,14 @@ void _handleConfig( } void _handleConfigPrint(ArgResults args) { - try { - final projectPath = ProjectResolver.resolveProjectPath( - args['path'] as String, - ); - final explicitConfig = _explicitConfigPath(args); - final configPath = ConfigTools.resolveConfigPathForProject( - projectPath: projectPath, - configPath: explicitConfig, - ); - final config = ScanConfig.fromFile( - configPath, - requireFile: explicitConfig != null, - ); - stdout.write(ConfigTools.effectiveYaml(config)); - } on FormatException catch (e) { - stderr.writeln('Error: ${e.message}'); - exit(2); - } + ConfigCommands.printEffective( + args, + configPath: _explicitConfigPath(args), + ); } void _handleConfigDoctor(ArgResults args) { - try { - final result = ConfigTools.doctor( - projectPath: args['path'] as String, - configPath: _explicitConfigPath(args), - ); - stdout.write(ConfigTools.formatDoctorResult(result)); - if (result.hasErrors) exit(1); - } on StateError catch (e) { - stderr.writeln('Error: ${e.message}'); - exit(2); - } on FormatException catch (e) { - stderr.writeln('Error: ${e.message}'); - exit(2); - } + ConfigCommands.doctor(args, configPath: _explicitConfigPath(args)); } List _extractPositionalPath(List args) { @@ -394,74 +178,7 @@ 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: _explicitConfigPath(args), - outputDir: args['output'] as String, - writeJson: format == 'json', - writeSarif: format == 'sarif', - noColor: noColor, - changedOnly: args['changed-only'] as bool, - base: args['base'] as String, - baselinePath: args['baseline'] as String?, - ); - } on ScanException catch (e) { - stderr.writeln('Error: ${e.message}'); - exit(2); - } on FormatException catch (e) { - stderr.writeln('Error: ${e.message}'); - exit(2); - } - - if (result.files.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 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); - } - } - - if (minScore != null && result.score < minScore) { - stderr.writeln( - 'CI gate failed: Score ${result.score} is below minimum $minScore.'); - exit(1); - } + ScanCommand.run(args, configPath: _explicitConfigPath(args)); } void _handleBaseline( @@ -516,127 +233,19 @@ void _handleBaseline( } void _handleBaselineCreate(ArgResults args) { - final restPath = args.rest.isNotEmpty ? args.rest.first : null; - final projectPath = restPath ?? args['path'] as String; - final output = args['output'] as String; - - try { - final result = FlutterGuardScanner.scan( - projectPath: projectPath, - configPath: _explicitConfigPath(args), - 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 (e) { - stderr.writeln('Error: ${e.message}'); - exit(2); - } on FormatException catch (e) { - stderr.writeln('Error: ${e.message}'); - exit(2); - } + BaselineCommands.create(args, configPath: _explicitConfigPath(args)); } void _handleBaselineStats(ArgResults args) { - try { - final stats = Baseline.stats(args['baseline'] as String); - stdout.writeln(const JsonEncoder.withIndent(' ').convert(stats)); - } on FormatException catch (e) { - stderr.writeln('Error: ${e.message}'); - exit(2); - } + BaselineCommands.stats(args); } void _handleBaselinePrune(ArgResults args) { - final restPath = args.rest.isNotEmpty ? args.rest.first : null; - final projectPath = restPath ?? args['path'] as String; - final baselinePath = args['baseline'] as String; - try { - final result = FlutterGuardScanner.scan( - projectPath: projectPath, - configPath: _explicitConfigPath(args), - applySuppression: false, - ); - final resolvedBaselinePath = _resolveProjectFile( - 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 (e) { - stderr.writeln('Error: ${e.message}'); - exit(2); - } on FormatException catch (e) { - stderr.writeln('Error: ${e.message}'); - exit(2); - } + BaselineCommands.prune(args, configPath: _explicitConfigPath(args)); } void _handleBaselineCheck(ArgResults args) { - final restPath = args.rest.isNotEmpty ? args.rest.first : null; - final projectPath = restPath ?? args['path'] as String; - try { - final result = FlutterGuardScanner.scan( - projectPath: projectPath, - configPath: _explicitConfigPath(args), - applySuppression: false, - ); - final baseline = Baseline.load(_resolveProjectFile( - 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.', - ); - if (newFingerprints.isNotEmpty) { - for (final fingerprint in newFingerprints.take(20)) { - stdout.writeln(' $fingerprint'); - } - } - if (args['no-growth'] == true && newFingerprints.isNotEmpty) { - exit(1); - } - } on ScanException catch (e) { - stderr.writeln('Error: ${e.message}'); - exit(2); - } on FormatException catch (e) { - stderr.writeln('Error: ${e.message}'); - exit(2); - } + BaselineCommands.check(args, configPath: _explicitConfigPath(args)); } void _handleDoctor( @@ -654,7 +263,7 @@ void _handleDoctor( _printInstallDoctorUsage(installDoctorParser); exit(0); } - stdout.write(InstallDoctor.generate(version: _version)); + ConfigCommands.installDoctor(version: _version); return; } _printDoctorUsage(doctorParser); @@ -679,51 +288,10 @@ void _handleIssue( _printIssueExportUsage(issueExportParser); exit(0); } - try { - final contextLines = int.tryParse(subcommand['context'] as String); - if (contextLines == null || contextLines < 0) { - throw const FormatException( - 'Expected --context to be a non-negative integer.'); - } - final lineRaw = subcommand['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: subcommand['path'] as String, - configPath: _explicitConfigPath(subcommand), - applySuppression: false, - ); - final exported = IssueExporter.export( - projectPath: result.projectPath, - issues: result.rawIssues, - ruleId: subcommand['rule'] as String?, - filePath: subcommand['file'] as String?, - line: line, - contextLines: contextLines, - ); - final output = subcommand['output'] as String?; - if (output == null) { - stdout.writeln(exported); - return; - } - final outputPath = _resolveProjectFile(result.projectPath, output); - File(outputPath).parent.createSync(recursive: true); - File(outputPath).writeAsStringSync(exported); - stdout.writeln('Exported issue feedback bundle: $outputPath'); - } on ScanException catch (e) { - stderr.writeln('Error: ${e.message}'); - exit(2); - } on FormatException catch (e) { - stderr.writeln('Error: ${e.message}'); - exit(2); - } -} - -String _resolveProjectFile(String projectPath, String filePath) { - return p.isAbsolute(filePath) ? filePath : p.join(projectPath, filePath); + IssueCommands.export( + subcommand, + configPath: _explicitConfigPath(subcommand), + ); } String? _explicitConfigPath(ArgResults args) { @@ -731,79 +299,11 @@ String? _explicitConfigPath(ArgResults args) { } void _handleRules(ArgResults args) { - final format = args['format'] as String; - final all = RuleRegistry.all(); - - if (format == 'json') { - stdout.writeln(const JsonEncoder.withIndent(' ').convert( - all.map((m) => m.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 查看详情'); + RuleCommands.list(args); } void _handleExplain(ArgResults args) { - final rest = args.rest; - if (rest.isEmpty) { - stderr.writeln('Error: 请指定规则 ID'); - stderr.writeln('用法: flutterguard explain '); - stderr.writeln('可用规则: ${RuleRegistry.all().map((m) => m.id).join(", ")}'); - exit(2); - } - - final ruleId = rest.first; - final meta = RuleRegistry.find(ruleId); - if (meta == null) { - stderr.writeln('Error: 未找到规则 "$ruleId"'); - stderr.writeln('可用规则: ${RuleRegistry.all().map((m) => m.id).join(", ")}'); - 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'); - } - } -} - -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; + RuleCommands.explain(args); } void _printUsage(ArgParser parser) { 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; + } +} From 24641a8441772e2516ffed2a95851a0414e9f8ec Mon Sep 17 00:00:00 2001 From: lizy Date: Sun, 12 Jul 2026 21:17:40 +0800 Subject: [PATCH 17/19] docs: update AGENTS.md and tests for catalog/cli refactor Document the new shared analysis modules, rules/catalog wiring, and lib/src/cli command layout, and extend scanner tests to cover the refactored execution path. --- AGENTS.md | 9 +- packages/flutterguard_cli/AGENTS.md | 22 ++-- packages/flutterguard_cli/bin/AGENTS.md | 7 +- packages/flutterguard_cli/lib/src/AGENTS.md | 7 +- .../flutterguard_cli/lib/src/rules/AGENTS.md | 5 +- packages/flutterguard_cli/test/AGENTS.md | 6 +- .../flutterguard_cli/test/scanner_test.dart | 117 ++++++++++++++++++ 7 files changed, 153 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fc08d70..5ade6d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ 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 (57 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 | @@ -46,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 @@ -57,6 +61,7 @@ packages/flutterguard_cli/lib/src/ 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 @@ -75,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/packages/flutterguard_cli/AGENTS.md b/packages/flutterguard_cli/AGENTS.md index 65088e5..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 files: `test/scanner_test.dart` (53 tests) and `test/cli_test.dart` (4 process-level tests) +- 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 scanner.dart +- 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/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/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/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/test/AGENTS.md b/packages/flutterguard_cli/test/AGENTS.md index 81d639a..fd15f84 100644 --- a/packages/flutterguard_cli/test/AGENTS.md +++ b/packages/flutterguard_cli/test/AGENTS.md @@ -4,7 +4,7 @@ Tests verify rule behavior, scanner orchestration, report generation, and cross-platform path handling. ## Test Files -- `scanner_test.dart`: reusable scanner/rule integration suite (53 tests, 7 groups). +- `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 @@ -12,8 +12,8 @@ Tests verify rule behavior, scanner orchestration, report generation, and cross- |-------|-------|---------| | Static Rules | 18 | 8 existing rules + 5 IoT rules + config parsing + wiring | | Report Generation | 3 | JSON, stdout, and suppression summary output | -| Scanner Orchestration | 14 | Scan policy, config resolution, suppression, baseline, SARIF, issue export | -| Changed-only | 5 | Git filtering, clean scans, invalid refs, non-Git fallback, cycle behavior | +| 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 | diff --git a/packages/flutterguard_cli/test/scanner_test.dart b/packages/flutterguard_cli/test/scanner_test.dart index 920a4ca..3610eac 100644 --- a/packages/flutterguard_cli/test/scanner_test.dart +++ b/packages/flutterguard_cli/test/scanner_test.dart @@ -25,6 +25,7 @@ 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'; @@ -397,12 +398,17 @@ dependencies: 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)); }); }); @@ -421,6 +427,35 @@ dependencies: 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_'); @@ -760,6 +795,53 @@ class AnotherChangedWidget extends StatelessWidget {} 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)); @@ -809,6 +891,41 @@ class AnotherChangedWidget extends StatelessWidget {} 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_', From b78f931f3d8edce3cd50de4c9cc7f60f2b460b29 Mon Sep 17 00:00:00 2001 From: lizy Date: Sun, 12 Jul 2026 21:22:08 +0800 Subject: [PATCH 18/19] cli: release v0.5.0 Bump flutterguard_cli to 0.5.0 with catalog/cli refactor changelog, adopt the workspace ^3.11.5 SDK constraint, and align the release workflow Dart SDK accordingly. --- .github/workflows/release.yml | 4 ++-- packages/flutterguard_cli/CHANGELOG.md | 8 +++++++- packages/flutterguard_cli/pubspec.yaml | 2 +- pubspec.yaml | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 856faba..d1623f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v4 - uses: dart-lang/setup-dart@v1 with: - sdk: "3.3.0" + sdk: "3.11.5" - run: dart pub get - run: dart run melos bootstrap - run: dart run melos run analyze @@ -42,7 +42,7 @@ jobs: - uses: actions/checkout@v4 - uses: dart-lang/setup-dart@v1 with: - sdk: "3.3.0" + sdk: "3.11.5" - run: dart pub get - run: ${{ matrix.script }} - uses: actions/upload-artifact@v4 diff --git a/packages/flutterguard_cli/CHANGELOG.md b/packages/flutterguard_cli/CHANGELOG.md index 314b403..ab05a9f 100644 --- a/packages/flutterguard_cli/CHANGELOG.md +++ b/packages/flutterguard_cli/CHANGELOG.md @@ -1,7 +1,13 @@ # Changelog -## Unreleased +## 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. diff --git a/packages/flutterguard_cli/pubspec.yaml b/packages/flutterguard_cli/pubspec.yaml index 3045006..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.4.1 +version: 0.5.0 repository: https://github.com/lizy-coding/flutterguard issue_tracker: https://github.com/lizy-coding/flutterguard/issues topics: 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: From 6c843668f28b2143b0f859eda2c04ff294592082 Mon Sep 17 00:00:00 2001 From: lizy Date: Sun, 12 Jul 2026 21:26:42 +0800 Subject: [PATCH 19/19] docs: realign roadmap with v0.5.0 architecture refactor Record the post-0.5.0 architecture invariants (catalog wiring, thin bin/, lib/src/cli command split, shared workspace/import-graph/boundary engine) as binding constraints, mark 0.4.x CI hardening done, and renumber the developer-workflow and later milestones to 0.6+. --- ROADMAP.md | 65 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 054404d..6d1a306 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,30 +14,41 @@ observability, APM, cloud dashboards, crash reporting, or SDK instrumentation. management, and actionable reports. - Avoid archived runtime packages for new feature work. -## Milestone 0.4.x — CI Adoption Hardening - -Goal: Make the existing CLI reliable and low-friction in real CI pipelines. - -Deliverables: - -- Improve rule accuracy with more real-world IoT Flutter fixtures. -- Add baseline management commands: - - `flutterguard baseline diff` - - `flutterguard baseline prune` - - `flutterguard baseline stats` -- Add CI guardrails to prevent baseline growth. -- Enhance SARIF output with richer rule metadata and precise locations where - available. -- Add checked-in GitHub Actions examples for JSON gates and SARIF upload. -- Add more suppression tests around comments, whitespace, and adjacent issues. - -Exit Criteria: - -- CI users can onboard existing projects without blocking on historical issues. -- Baseline files can be reviewed and reduced over time. -- SARIF uploads cleanly to GitHub Code Scanning. - -## Milestone 0.5 — Developer Workflow Integrations +## 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. @@ -68,7 +79,7 @@ Exit Criteria: - 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.6 — Fixes, Reports, and Workspace Scale +## Milestone 0.7 — Fixes, Reports, and Workspace Scale Goal: Move from reporting issues to helping teams reduce them across larger Flutter codebases. @@ -97,7 +108,7 @@ Exit Criteria: - Large Flutter workspaces can run FlutterGuard package-by-package. - Architecture violations can be reviewed visually during refactors. -## Milestone 0.7 — Deeper Static Analysis +## Milestone 0.8 — Deeper Static Analysis Goal: Reduce false positives and expand IoT-specific detection using stronger AST and project context. @@ -127,7 +138,7 @@ Exit Criteria: - Monorepo package imports are handled consistently. - New IoT checks remain static-only and CI-safe. -## Milestone 0.8+ — Extensibility and Team Governance +## Milestone 0.9+ — Extensibility and Team Governance Goal: Support mature teams that need reusable governance policies without turning FlutterGuard into a hosted platform.