From 24c3982c43be75d4d1d14b222d754f75252c00dd Mon Sep 17 00:00:00 2001 From: lizy Date: Thu, 16 Jul 2026 21:16:16 +0800 Subject: [PATCH 01/16] cli: add StateManagementFramework, RuleConfidence, and evidence to static issue model --- packages/flutterguard_cli/lib/src/rule_meta.dart | 6 ++++++ .../flutterguard_cli/lib/src/static_issue.dart | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/packages/flutterguard_cli/lib/src/rule_meta.dart b/packages/flutterguard_cli/lib/src/rule_meta.dart index d8c94cf..cf89beb 100644 --- a/packages/flutterguard_cli/lib/src/rule_meta.dart +++ b/packages/flutterguard_cli/lib/src/rule_meta.dart @@ -10,6 +10,8 @@ class RuleMeta { final String fixSuggestion; final List configKeys; final bool cicdSafe; + final String framework; + final String confidence; const RuleMeta({ required this.id, @@ -23,6 +25,8 @@ class RuleMeta { required this.fixSuggestion, this.configKeys = const [], this.cicdSafe = true, + this.framework = 'generic', + this.confidence = 'certain', }); Map toJson() => { @@ -37,5 +41,7 @@ class RuleMeta { 'fixSuggestion': fixSuggestion, 'configKeys': configKeys, 'cicdSafe': cicdSafe, + 'framework': framework, + 'confidence': confidence, }; } diff --git a/packages/flutterguard_cli/lib/src/static_issue.dart b/packages/flutterguard_cli/lib/src/static_issue.dart index 2450735..ea37df8 100644 --- a/packages/flutterguard_cli/lib/src/static_issue.dart +++ b/packages/flutterguard_cli/lib/src/static_issue.dart @@ -3,6 +3,10 @@ import 'priority.dart'; enum RiskLevel { low, medium, high } +enum StateManagementFramework { riverpod, bloc, provider, generic } + +enum RuleConfidence { certain, probable, informational } + class StaticIssue { final String id; final String title; @@ -15,6 +19,9 @@ class StaticIssue { final String detail; final String suggestion; final Map metadata; + final StateManagementFramework framework; + final RuleConfidence confidence; + final List evidence; const StaticIssue({ required this.id, @@ -28,19 +35,27 @@ class StaticIssue { this.detail = '', required this.suggestion, this.metadata = const {}, + this.framework = StateManagementFramework.generic, + this.confidence = RuleConfidence.certain, + this.evidence = const [], }); Map toJson() => { 'id': id, + 'ruleId': id, 'title': title, 'file': file, 'line': line, 'level': level.name, + 'severity': level.name, 'domain': domain.name, 'priority': priority.name, 'message': message, 'detail': detail, 'suggestion': suggestion, 'metadata': metadata, + 'framework': framework.name, + 'confidence': confidence.name, + 'evidence': evidence.take(5).toList(), }; } From 7369258e7cd35890df4aaa04bf848bcd93221c58 Mon Sep 17 00:00:00 2001 From: lizy Date: Thu, 16 Jul 2026 21:16:18 +0800 Subject: [PATCH 02/16] cli: add state management config types and YAML parsing --- .../lib/src/config_loader.dart | 177 +++++++++++++++++- 1 file changed, 175 insertions(+), 2 deletions(-) diff --git a/packages/flutterguard_cli/lib/src/config_loader.dart b/packages/flutterguard_cli/lib/src/config_loader.dart index 3d35fd1..53deb5f 100644 --- a/packages/flutterguard_cli/lib/src/config_loader.dart +++ b/packages/flutterguard_cli/lib/src/config_loader.dart @@ -4,6 +4,8 @@ import 'dart:io'; import 'package:yaml/yaml.dart'; +import 'static_issue.dart'; + typedef LayerConfig = ({ String name, String path, @@ -35,6 +37,16 @@ typedef RulesConfig = ({ BleScanningRuleConfig bleScanning, IotSecurityRuleConfig iotSecurity, PubspecSecurityRuleConfig pubspecSecurity, + StateRuleConfig sideEffectInBuild, + StateRuleConfig stateManagerCreatedInBuild, + StateRuleConfig mutableStateExposed, + StateRuleConfig stateLayerUiDependency, + StateRuleConfig stateDependencyCycle, + StateRuleConfig riverpodReadUsedForRender, + StateRuleConfig riverpodWatchInCallback, + StateRuleConfig blocEquatablePropsIncomplete, + StateRuleConfig providerValueLifecycleMisuse, + StateRuleConfig notifyListenersInLoop, }); typedef LargeFileRuleConfig = ({bool enabled, int maxLines}); @@ -47,18 +59,32 @@ typedef MqttConnectionRuleConfig = ({bool enabled}); typedef BleScanningRuleConfig = ({bool enabled, int maxScanDurationMs}); typedef IotSecurityRuleConfig = ({bool enabled, bool requireTls}); typedef PubspecSecurityRuleConfig = ({bool enabled}); +typedef StateRuleConfig = ({ + bool enabled, + RiskLevel severity, + List allowlist, + List ignorePaths, +}); + +typedef StateManagementConfig = ({ + bool enabled, + bool frameworkAutoDetect, + RuleConfidence confidenceThreshold, +}); class ScanConfig { final List include; final List exclude; final RulesConfig rules; final ArchitectureConfig architecture; + final StateManagementConfig stateManagement; const ScanConfig({ required this.include, required this.exclude, required this.rules, required this.architecture, + required this.stateManagement, }); static const _knownTopLevelKeys = { @@ -66,6 +92,7 @@ class ScanConfig { 'exclude', 'rules', 'architecture', + 'state_management', }; static const _knownRuleKeys = { 'large_file', @@ -78,6 +105,27 @@ class ScanConfig { 'ble_scanning', 'iot_security', 'pubspec_security', + 'side_effect_in_build', + 'state_manager_created_in_build', + 'mutable_state_exposed', + 'state_layer_ui_dependency', + 'state_dependency_cycle', + 'riverpod_read_used_for_render', + 'riverpod_watch_in_callback', + 'bloc_equatable_props_incomplete', + 'provider_value_lifecycle_misuse', + 'notify_listeners_in_loop', + }; + static const _knownStateRuleKeys = { + 'enabled', + 'severity', + 'allowlist', + 'ignore_paths', + }; + static const _knownStateManagementKeys = { + 'enabled', + 'framework_auto_detect', + 'confidence_threshold', }; static const _knownLayerKeys = { 'name', @@ -122,6 +170,20 @@ class ScanConfig { final arch = _optionalMap(yaml['architecture'], 'architecture'); _warnUnknownKeys(arch, _knownArchKeys, 'architecture'); + final stateManagement = _optionalMap( + yaml['state_management'], + 'state_management', + ); + _warnUnknownKeys( + stateManagement, + _knownStateManagementKeys, + 'state_management', + ); + for (final ruleId in _stateRuleIds) { + final rule = _optionalMap(rules[ruleId], 'rules.$ruleId'); + _warnUnknownKeys(rule, _knownStateRuleKeys, 'rules.$ruleId'); + } + if (arch['layers'] is YamlList) { for (final layer in arch['layers'] as YamlList) { if (layer is! YamlMap) { @@ -151,10 +213,11 @@ class ScanConfig { ], rules: _parseRules(rules), architecture: _parseArchitecture(arch), + stateManagement: _parseStateManagement(stateManagement), ); } - static ScanConfig _defaultConfig() => const ScanConfig( + static ScanConfig _defaultConfig() => ScanConfig( include: ['lib/**'], exclude: [ 'lib/generated/**', @@ -173,6 +236,16 @@ class ScanConfig { bleScanning: (enabled: true, maxScanDurationMs: 10000), iotSecurity: (enabled: true, requireTls: true), pubspecSecurity: (enabled: true), + sideEffectInBuild: _defaultStateRule(RiskLevel.high), + stateManagerCreatedInBuild: _defaultStateRule(RiskLevel.high), + mutableStateExposed: _defaultStateRule(RiskLevel.medium), + stateLayerUiDependency: _defaultStateRule(RiskLevel.high), + stateDependencyCycle: _defaultStateRule(RiskLevel.high), + riverpodReadUsedForRender: _defaultStateRule(RiskLevel.medium), + riverpodWatchInCallback: _defaultStateRule(RiskLevel.medium), + blocEquatablePropsIncomplete: _defaultStateRule(RiskLevel.medium), + providerValueLifecycleMisuse: _defaultStateRule(RiskLevel.medium), + notifyListenersInLoop: _defaultStateRule(RiskLevel.medium), ), architecture: ( layers: [], @@ -181,6 +254,31 @@ class ScanConfig { layerViolationEnabled: true, moduleViolationEnabled: true, ), + stateManagement: ( + enabled: true, + frameworkAutoDetect: true, + confidenceThreshold: RuleConfidence.certain, + ), + ); + + static const _stateRuleIds = [ + 'side_effect_in_build', + 'state_manager_created_in_build', + 'mutable_state_exposed', + 'state_layer_ui_dependency', + 'state_dependency_cycle', + 'riverpod_read_used_for_render', + 'riverpod_watch_in_callback', + 'bloc_equatable_props_incomplete', + 'provider_value_lifecycle_misuse', + 'notify_listeners_in_loop', + ]; + + static StateRuleConfig _defaultStateRule(RiskLevel severity) => ( + enabled: true, + severity: severity, + allowlist: const [], + ignorePaths: const [], ); static RulesConfig _parseRules(YamlMap rules) { @@ -218,6 +316,8 @@ class ScanConfig { rules['pubspec_security'], 'rules.pubspec_security', ); + StateRuleConfig stateRule(String id, RiskLevel severity) => + _parseStateRule(_optionalMap(rules[id], 'rules.$id'), severity); return ( largeFile: ( @@ -249,9 +349,47 @@ class ScanConfig { requireTls: _boolValue(iotSecurity, 'requireTls', true), ), pubspecSecurity: (enabled: _boolValue(pubspecSecurity, 'enabled', true),), + sideEffectInBuild: stateRule('side_effect_in_build', RiskLevel.high), + stateManagerCreatedInBuild: + stateRule('state_manager_created_in_build', RiskLevel.high), + mutableStateExposed: stateRule('mutable_state_exposed', RiskLevel.medium), + stateLayerUiDependency: + stateRule('state_layer_ui_dependency', RiskLevel.high), + stateDependencyCycle: stateRule('state_dependency_cycle', RiskLevel.high), + riverpodReadUsedForRender: + stateRule('riverpod_read_used_for_render', RiskLevel.medium), + riverpodWatchInCallback: + stateRule('riverpod_watch_in_callback', RiskLevel.medium), + blocEquatablePropsIncomplete: + stateRule('bloc_equatable_props_incomplete', RiskLevel.medium), + providerValueLifecycleMisuse: + stateRule('provider_value_lifecycle_misuse', RiskLevel.medium), + notifyListenersInLoop: + stateRule('notify_listeners_in_loop', RiskLevel.medium), ); } + static StateRuleConfig _parseStateRule( + YamlMap map, + RiskLevel defaultSeverity, + ) => + ( + enabled: _boolValue(map, 'enabled', true), + severity: _riskLevelValue(map, 'severity', defaultSeverity), + allowlist: _parseStringList(map['allowlist']) ?? const [], + ignorePaths: _parseStringList(map['ignore_paths']) ?? const [], + ); + + static StateManagementConfig _parseStateManagement(YamlMap map) => ( + enabled: _boolValue(map, 'enabled', true), + frameworkAutoDetect: _boolValue(map, 'framework_auto_detect', true), + confidenceThreshold: _confidenceValue( + map, + 'confidence_threshold', + RuleConfidence.certain, + ), + ); + static ArchitectureConfig _parseArchitecture(YamlMap arch) { final layers = []; if (arch['layers'] is YamlList) { @@ -311,7 +449,10 @@ class ScanConfig { static List? _parseStringList(dynamic value) { if (value is YamlList) { - return value.map((e) => e.toString()).toList(); + if (value.any((element) => element is! String)) { + throw const FormatException('Expected a YAML list of strings.'); + } + return value.cast().toList(); } if (value != null) { throw const FormatException('Expected a YAML list of strings.'); @@ -339,6 +480,38 @@ class ScanConfig { throw FormatException('$key must be an integer.'); } + static RiskLevel _riskLevelValue( + YamlMap map, + String key, + RiskLevel defaultValue, + ) { + final value = map[key]; + if (value == null) return defaultValue; + if (value is String) { + for (final level in RiskLevel.values) { + if (level.name == value) return level; + } + } + throw FormatException('$key must be one of: high, medium, low.'); + } + + static RuleConfidence _confidenceValue( + YamlMap map, + String key, + RuleConfidence defaultValue, + ) { + final value = map[key]; + if (value == null) return defaultValue; + if (value is String) { + for (final confidence in RuleConfidence.values) { + if (confidence.name == value) return confidence; + } + } + throw FormatException( + '$key must be one of: certain, probable, informational.', + ); + } + static String _requiredString(YamlMap map, String key, String path) { final value = map[key]; if (value is String && value.isNotEmpty) return value; From da6972f6d59de9b74433907ece52937a2aec005f Mon Sep 17 00:00:00 2001 From: lizy Date: Thu, 16 Jul 2026 21:16:21 +0800 Subject: [PATCH 03/16] cli: add 10 state management rule implementations --- .../lib/src/rules/bloc_state_management.dart | 115 ++++ .../src/rules/generic_state_management.dart | 506 ++++++++++++++++++ .../src/rules/provider_state_management.dart | 348 ++++++++++++ .../src/rules/riverpod_state_management.dart | 306 +++++++++++ .../lib/src/rules/state_dependency_cycle.dart | 437 +++++++++++++++ .../lib/src/rules/state_management_utils.dart | 236 ++++++++ 6 files changed, 1948 insertions(+) create mode 100644 packages/flutterguard_cli/lib/src/rules/bloc_state_management.dart create mode 100644 packages/flutterguard_cli/lib/src/rules/generic_state_management.dart create mode 100644 packages/flutterguard_cli/lib/src/rules/provider_state_management.dart create mode 100644 packages/flutterguard_cli/lib/src/rules/riverpod_state_management.dart create mode 100644 packages/flutterguard_cli/lib/src/rules/state_dependency_cycle.dart create mode 100644 packages/flutterguard_cli/lib/src/rules/state_management_utils.dart diff --git a/packages/flutterguard_cli/lib/src/rules/bloc_state_management.dart b/packages/flutterguard_cli/lib/src/rules/bloc_state_management.dart new file mode 100644 index 0000000..c1f0d75 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/rules/bloc_state_management.dart @@ -0,0 +1,115 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import '../config_loader.dart'; +import '../domain.dart'; +import '../rule_meta.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; +import 'state_management_utils.dart'; + +class BlocEquatablePropsIncompleteRule { + final StateRuleConfig config; + final StateManagementConfig stateManagement; + final String projectPath; + + const BlocEquatablePropsIncompleteRule( + this.config, + this.stateManagement, { + required this.projectPath, + }); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config, stateManagement)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + if (shouldIgnoreStateFile(file, projectPath, config)) continue; + final source = sources.source(file); + if (source == null) continue; + if (stateManagement.frameworkAutoDetect && + !hasEquatableImport(source.unit)) { + continue; + } + if (!frameworkAllowed( + source.unit, + StateManagementFramework.bloc, + stateManagement, + )) { + continue; + } + for (final cls + in source.unit.declarations.whereType()) { + if (!_inheritsEquatable(cls)) continue; + final fields = {}; + for (final field in cls.members.whereType()) { + if (field.isStatic || !field.fields.isFinal) continue; + for (final variable in field.fields.variables) { + final name = variable.name.lexeme; + if (config.allowlist.contains('${cls.name.lexeme}.$name')) continue; + fields.add(name); + } + } + if (fields.isEmpty) continue; + final props = cls.members.whereType().where( + (method) => method.isGetter && method.name.lexeme == 'props', + ); + final referenced = {}; + for (final getter in props) { + final visitor = _IdentifierCollector(); + getter.body.accept(visitor); + referenced.addAll(visitor.names); + } + final missing = fields.difference(referenced).toList()..sort(); + if (missing.isEmpty) continue; + issues.add(StaticIssue( + id: 'bloc_equatable_props_incomplete', + title: 'Equatable props 不完整', + file: file, + line: sourceLine(source, cls), + level: config.severity, + domain: IssueDomain.standards, + priority: priorityForSeverity(config.severity), + message: '${cls.name.lexeme}.props 缺少字段: ${missing.join(', ')}', + suggestion: '将所有参与值相等判断的 final instance 字段加入 props', + framework: StateManagementFramework.bloc, + evidence: missing.map((field) => 'missing: $field').take(5).toList(), + metadata: {'className': cls.name.lexeme, 'missingFields': missing}, + )); + } + } + return issues; + } + + static bool _inheritsEquatable(ClassDeclaration cls) { + final superclass = cls.extendsClause?.superclass.toSource() ?? ''; + final mixins = + cls.withClause?.mixinTypes.map((type) => type.toSource()).toList() ?? + const []; + return superclass == 'Equatable' || mixins.contains('EquatableMixin'); + } + + static RuleMeta describe() => const RuleMeta( + id: 'bloc_equatable_props_incomplete', + name: 'Equatable props 不完整', + domain: 'standards', + riskLevel: 'medium', + priority: 'p1', + purpose: '比较 Equatable 类的 final instance 字段与 props 字段引用', + riskReason: '遗漏字段会让不同状态被误判为相等,阻止 Bloc UI 更新', + badExample: 'final a; final b; List get props => [a];', + fixSuggestion: '把遗漏的值字段加入 props', + configKeys: ['enabled', 'severity', 'allowlist', 'ignore_paths'], + framework: 'bloc', + ); +} + +class _IdentifierCollector extends RecursiveAstVisitor { + final names = {}; + + @override + void visitSimpleIdentifier(SimpleIdentifier node) { + names.add(node.name); + super.visitSimpleIdentifier(node); + } +} diff --git a/packages/flutterguard_cli/lib/src/rules/generic_state_management.dart b/packages/flutterguard_cli/lib/src/rules/generic_state_management.dart new file mode 100644 index 0000000..36c15e8 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/rules/generic_state_management.dart @@ -0,0 +1,506 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import '../config_loader.dart'; +import '../domain.dart'; +import '../rule_meta.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; +import 'state_management_utils.dart'; + +class SideEffectInBuildRule { + final StateRuleConfig config; + final StateManagementConfig stateManagement; + final String projectPath; + + const SideEffectInBuildRule( + this.config, + this.stateManagement, { + required this.projectPath, + }); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config, stateManagement)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + if (shouldIgnoreStateFile(file, projectPath, config)) continue; + final source = sources.source(file); + if (source == null) continue; + for (final root in buildRoots(source.unit)) { + final visitor = _SideEffectVisitor(config.allowlist); + root.body.accept(visitor); + final evidence = limitedEvidence(visitor.evidence); + if (evidence.isEmpty) continue; + issues.add(StaticIssue( + id: 'side_effect_in_build', + title: 'build 中执行副作用', + file: file, + line: sourceLine(source, root.anchor), + level: config.severity, + domain: IssueDomain.performance, + priority: priorityForSeverity(config.severity), + message: '${root.label} 在渲染阶段执行了状态或资源副作用', + detail: 'build 必须保持幂等;框架可能在一次界面更新中重复调用它。', + suggestion: '将副作用移至生命周期、listener 或用户事件回调中', + evidence: evidence, + metadata: {'buildRoot': root.label}, + )); + } + } + return issues; + } + + static RuleMeta describe() => const RuleMeta( + id: 'side_effect_in_build', + name: 'build 中的副作用', + domain: 'performance', + riskLevel: 'high', + priority: 'p0', + purpose: '检测 Widget build 与 Consumer builder 中直接执行的状态和资源副作用', + riskReason: 'build 可被重复调用,副作用会造成重复连接、通知或状态更新', + badExample: 'Widget build(...) { ref.read(x.notifier).refresh(); }', + fixSuggestion: '把副作用移到生命周期、listener 或事件回调', + configKeys: ['enabled', 'severity', 'allowlist', 'ignore_paths'], + ); +} + +class _SideEffectVisitor extends RecursiveAstVisitor { + final List allowlist; + final evidence = []; + + _SideEffectVisitor(this.allowlist); + + @override + void visitFunctionExpression(FunctionExpression node) { + // A nested closure is an event/listener/timer boundary. Consumer builder + // closures are scanned separately as build roots. + } + + @override + void visitInstanceCreationExpression(InstanceCreationExpression node) { + final type = simpleTypeName(node.constructorName.type.toSource()); + if (type == 'Timer' && !allowlist.contains(type)) { + evidence.add(compactEvidence(node)); + } + super.visitInstanceCreationExpression(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + final name = node.methodName.name; + final target = node.target?.toSource() ?? ''; + const directEffects = { + 'notifyListeners', + 'emit', + 'setState', + 'connect', + 'disconnect', + 'startScan', + }; + final managerAdd = name == 'add' && + (target.toLowerCase().contains('bloc') || + target.toLowerCase().contains('cubit') || + target.toLowerCase().contains('notifier') || + target.contains('context.read') || + target.contains('ref.read')); + final notifierCommand = target.contains('ref.read') && + target.contains('.notifier') && + name != 'read'; + final timerPeriodic = target == 'Timer' && name == 'periodic'; + final timerCreation = target.isEmpty && name == 'Timer'; + if ((directEffects.contains(name) || + managerAdd || + notifierCommand || + timerPeriodic || + timerCreation) && + !allowlist.contains(name)) { + evidence.add(compactEvidence(node)); + } + super.visitMethodInvocation(node); + } +} + +class StateManagerCreatedInBuildRule { + final StateRuleConfig config; + final StateManagementConfig stateManagement; + final String projectPath; + + const StateManagerCreatedInBuildRule( + this.config, + this.stateManagement, { + required this.projectPath, + }); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config, stateManagement)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + if (shouldIgnoreStateFile(file, projectPath, config)) continue; + final source = sources.source(file); + if (source == null) continue; + for (final root in buildRoots(source.unit)) { + final visitor = _StateManagerCreationVisitor(config.allowlist); + root.body.accept(visitor); + for (final creation in visitor.creations) { + final type = creation.type; + issues.add(StaticIssue( + id: 'state_manager_created_in_build', + title: 'build 中创建状态管理对象', + file: file, + line: sourceLine(source, creation.node), + level: config.severity, + domain: IssueDomain.performance, + priority: priorityForSeverity(config.severity), + message: '$type 在 ${root.label} 中被重复创建', + suggestion: '把对象交给 State 生命周期或 Provider/BlocProvider 的 create 管理', + evidence: [compactEvidence(creation.node)], + metadata: {'type': type, 'buildRoot': root.label}, + )); + } + } + } + return issues; + } + + static RuleMeta describe() => const RuleMeta( + id: 'state_manager_created_in_build', + name: 'build 中创建状态管理对象', + domain: 'performance', + riskLevel: 'high', + priority: 'p0', + purpose: '检测 build 中创建 Controller、Bloc、Cubit、Notifier 等持有型对象', + riskReason: '重复构建会重建对象并丢失状态或泄漏资源', + badExample: 'Widget build(...) { final c = DeviceController(); }', + fixSuggestion: '提升到生命周期字段或框架所有权 create 回调', + configKeys: ['enabled', 'severity', 'allowlist', 'ignore_paths'], + ); +} + +class _StateManagerCreationVisitor extends RecursiveAstVisitor { + final List allowlist; + final creations = <({AstNode node, String type})>[]; + + _StateManagerCreationVisitor(this.allowlist); + + static const flutterControllers = { + 'AnimationController', + 'TextEditingController', + 'ScrollController', + 'PageController', + 'TabController', + 'FocusNode', + }; + + @override + void visitFunctionExpression(FunctionExpression node) {} + + @override + void visitInstanceCreationExpression(InstanceCreationExpression node) { + final type = simpleTypeName(node.constructorName.type.toSource()); + _record(node, type); + super.visitInstanceCreationExpression(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.target == null && + node.methodName.name.startsWith(RegExp('[A-Z]'))) { + _record(node, simpleTypeName(node.methodName.name)); + } + super.visitMethodInvocation(node); + } + + void _record(AstNode node, String type) { + final owned = stateOwnerSuffixes.any(type.endsWith) || + flutterControllers.contains(type); + if (owned && !allowlist.contains(type)) { + creations.add((node: node, type: type)); + } + } +} + +class MutableStateExposedRule { + final StateRuleConfig config; + final StateManagementConfig stateManagement; + final String projectPath; + + const MutableStateExposedRule( + this.config, + this.stateManagement, { + required this.projectPath, + }); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config, stateManagement)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + if (shouldIgnoreStateFile(file, projectPath, config)) continue; + final source = sources.source(file); + if (source == null) continue; + for (final cls + in source.unit.declarations.whereType()) { + if (!isStateOwnerClass(cls)) continue; + issues.addAll(_inspectClass(file, source, cls)); + } + } + return issues; + } + + List _inspectClass( + String file, + SourceUnit source, + ClassDeclaration cls, + ) { + final findings = <({AstNode node, String key, String evidence})>[]; + final privateCollections = {}; + for (final field in cls.members.whereType()) { + if (field.isStatic) continue; + final type = typeName(field.fields.type); + for (final variable in field.fields.variables) { + final name = variable.name.lexeme; + final allowed = config.allowlist.contains('${cls.name.lexeme}.$name'); + if (allowed) continue; + if (!isPublicName(name) && isCollectionType(type)) { + privateCollections.add(name); + } + if (!isPublicName(name)) continue; + if (!field.fields.isFinal) { + findings.add(( + node: variable, + key: 'field:$name:non_final', + evidence: '$type $name is public and non-final', + )); + } else if (isCollectionType(type) && + !isUnmodifiableExpression(variable.initializer)) { + findings.add(( + node: variable, + key: 'field:$name:collection', + evidence: '$type $name exposes a mutable collection reference', + )); + } + } + } + + for (final method in cls.members.whereType()) { + final name = method.name.lexeme; + if (!method.isGetter || !isPublicName(name)) continue; + if (config.allowlist.contains('${cls.name.lexeme}.$name')) continue; + final returned = _returnedIdentifier(method.body); + if (returned != null && privateCollections.contains(returned)) { + findings.add(( + node: method, + key: 'getter:$name:$returned', + evidence: 'getter $name returns mutable $returned directly', + )); + } + } + + final mutationVisitor = _StateCollectionMutationVisitor(); + cls.accept(mutationVisitor); + for (final mutation in mutationVisitor.mutations) { + findings.add(( + node: mutation, + key: 'state_mutation:${mutation.offset}', + evidence: compactEvidence(mutation), + )); + } + + final seen = {}; + return [ + for (final finding in findings) + if (seen.add(finding.key)) + StaticIssue( + id: 'mutable_state_exposed', + title: '可变状态被公开', + file: file, + line: sourceLine(source, finding.node), + level: config.severity, + domain: IssueDomain.architecture, + priority: priorityForSeverity(config.severity), + message: '${cls.name.lexeme} 暴露或原地修改了可变状态', + suggestion: '公开不可变视图/副本,并用 copyWith 或新集合更新状态', + evidence: [finding.evidence], + metadata: {'className': cls.name.lexeme, 'rootCause': finding.key}, + ), + ]; + } + + String? _returnedIdentifier(FunctionBody body) { + if (body is ExpressionFunctionBody) { + final expression = body.expression; + if (expression is SimpleIdentifier) return expression.name; + } + if (body is BlockFunctionBody) { + for (final statement + in body.block.statements.whereType()) { + final expression = statement.expression; + if (expression is SimpleIdentifier) return expression.name; + } + } + return null; + } + + static RuleMeta describe() => const RuleMeta( + id: 'mutable_state_exposed', + name: '公开可变状态', + domain: 'architecture', + riskLevel: 'medium', + priority: 'p1', + purpose: '检测状态 owner 的公开可变字段、集合 getter 与原地 state 集合修改', + riskReason: '外部调用方可绕过状态通知并破坏单向数据流', + badExample: 'final List items = [];', + fixSuggestion: '返回不可变视图或副本,并替换整个状态值', + configKeys: ['enabled', 'severity', 'allowlist', 'ignore_paths'], + ); +} + +class _StateCollectionMutationVisitor extends RecursiveAstVisitor { + final mutations = []; + + @override + void visitMethodInvocation(MethodInvocation node) { + final target = node.target?.toSource() ?? ''; + const mutators = { + 'add', + 'addAll', + 'remove', + 'removeWhere', + 'clear', + 'sort' + }; + if ((target == 'state' || target.startsWith('state.')) && + mutators.contains(node.methodName.name)) { + mutations.add(node); + } + super.visitMethodInvocation(node); + } + + @override + void visitAssignmentExpression(AssignmentExpression node) { + final left = node.leftHandSide.toSource(); + if (left.startsWith('state[') || left.startsWith('state.')) { + mutations.add(node); + } + super.visitAssignmentExpression(node); + } +} + +class StateLayerUiDependencyRule { + final StateRuleConfig config; + final StateManagementConfig stateManagement; + final String projectPath; + + const StateLayerUiDependencyRule( + this.config, + this.stateManagement, { + required this.projectPath, + }); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config, stateManagement)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + if (shouldIgnoreStateFile(file, projectPath, config)) continue; + final source = sources.source(file); + if (source == null) continue; + for (final cls + in source.unit.declarations.whereType()) { + if (!isStateOwnerClass(cls) || + config.allowlist.contains(cls.name.lexeme)) { + continue; + } + final visitor = _UiDependencyVisitor( + cls.name.lexeme, + config.allowlist, + ); + cls.accept(visitor); + final evidence = limitedEvidence(visitor.evidence); + if (evidence.isEmpty) continue; + issues.add(StaticIssue( + id: 'state_layer_ui_dependency', + title: '状态层依赖 UI API', + file: file, + line: sourceLine(source, cls), + level: config.severity, + domain: IssueDomain.architecture, + priority: priorityForSeverity(config.severity), + message: '${cls.name.lexeme} 直接依赖 Flutter UI 上下文或导航 API', + suggestion: '从状态层输出事件/数据,由 Widget 层执行导航、弹窗和主题访问', + evidence: evidence, + metadata: {'className': cls.name.lexeme}, + )); + } + } + return issues; + } + + static RuleMeta describe() => const RuleMeta( + id: 'state_layer_ui_dependency', + name: '状态层依赖 UI', + domain: 'architecture', + riskLevel: 'high', + priority: 'p0', + purpose: + '检测状态 owner 对 BuildContext、Widget、Navigator、Theme 等 UI API 的依赖', + riskReason: '状态逻辑与 UI 生命周期耦合,难以测试和复用', + badExample: + 'class DeviceController { void open(BuildContext context) { ... } }', + fixSuggestion: '输出状态或事件,并在 Widget 层处理 UI 行为', + configKeys: ['enabled', 'severity', 'allowlist', 'ignore_paths'], + ); +} + +class _UiDependencyVisitor extends RecursiveAstVisitor { + final String className; + final List allowlist; + final evidence = []; + + _UiDependencyVisitor(this.className, this.allowlist); + + static const uiTypes = {'BuildContext', 'Widget'}; + static const uiCalls = { + 'showDialog', + 'showModalBottomSheet', + 'maybePop', + 'push', + 'pushNamed', + 'of', + }; + + @override + void visitNamedType(NamedType node) { + final type = node.toSource().replaceAll('?', ''); + final simple = simpleTypeName(type); + final nestedTypeOnly = node.parent is TypeArgumentList; + if (!nestedTypeOnly && + uiTypes.contains(simple) && + !allowlist.contains('$className.$simple')) { + evidence.add('type dependency: $type'); + } + super.visitNamedType(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + final target = node.target?.toSource() ?? ''; + final name = node.methodName.name; + final uiTarget = const { + 'Navigator', + 'ScaffoldMessenger', + 'MediaQuery', + 'Theme', + }.any((value) => target.contains(value)); + if ((uiTarget && uiCalls.contains(name)) || + name == 'showDialog' || + name == 'showModalBottomSheet') { + if (!allowlist.contains('$className.$name')) { + evidence.add(compactEvidence(node)); + } + } + super.visitMethodInvocation(node); + } +} diff --git a/packages/flutterguard_cli/lib/src/rules/provider_state_management.dart b/packages/flutterguard_cli/lib/src/rules/provider_state_management.dart new file mode 100644 index 0000000..715fbd4 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/rules/provider_state_management.dart @@ -0,0 +1,348 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import '../config_loader.dart'; +import '../domain.dart'; +import '../rule_meta.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; +import 'state_management_utils.dart'; + +class ProviderValueLifecycleMisuseRule { + final StateRuleConfig config; + final StateManagementConfig stateManagement; + final String projectPath; + + const ProviderValueLifecycleMisuseRule( + this.config, + this.stateManagement, { + required this.projectPath, + }); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config, stateManagement)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + if (shouldIgnoreStateFile(file, projectPath, config)) continue; + final source = sources.source(file); + if (source == null || + !frameworkAllowed( + source.unit, + StateManagementFramework.provider, + stateManagement, + )) { + continue; + } + final visitor = _ProviderOwnershipVisitor(config.allowlist); + source.unit.accept(visitor); + for (final finding in visitor.findings) { + issues.add(StaticIssue( + id: 'provider_value_lifecycle_misuse', + title: 'Provider 所有权模式误用', + file: file, + line: sourceLine(source, finding.node), + level: config.severity, + domain: IssueDomain.performance, + priority: priorityForSeverity(config.severity), + message: finding.message, + suggestion: finding.kind == 'value_creates' + ? '新对象使用 create 构造;.value 只传入已有实例' + : '已有实例使用 .value,避免 Provider 错误释放或忽略所有权', + framework: StateManagementFramework.provider, + evidence: [compactEvidence(finding.node)], + metadata: {'ownershipError': finding.kind}, + )); + } + } + return issues; + } + + static RuleMeta describe() => const RuleMeta( + id: 'provider_value_lifecycle_misuse', + name: 'Provider 所有权误用', + domain: 'performance', + riskLevel: 'medium', + priority: 'p1', + purpose: '检测 .value 创建新对象和 create 返回已有对象的反向所有权用法', + riskReason: '错误的 Provider 构造方式会导致对象未释放、提前释放或状态复用错误', + badExample: 'ChangeNotifierProvider.value(value: DeviceController())', + fixSuggestion: '新对象用 create,已有对象用 .value', + configKeys: ['enabled', 'severity', 'allowlist', 'ignore_paths'], + framework: 'provider', + ); +} + +class _ProviderOwnershipFinding { + final AstNode node; + final String kind; + final String message; + + const _ProviderOwnershipFinding(this.node, this.kind, this.message); +} + +class _ProviderOwnershipVisitor extends RecursiveAstVisitor { + final List allowlist; + final findings = <_ProviderOwnershipFinding>[]; + + _ProviderOwnershipVisitor(this.allowlist); + + @override + void visitInstanceCreationExpression(InstanceCreationExpression node) { + _inspectCall( + node: node, + providerType: simpleTypeName(node.constructorName.type.toSource()), + constructor: node.constructorName.name?.name, + arguments: node.argumentList, + ); + super.visitInstanceCreationExpression(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + final target = node.target?.toSource(); + final targetType = target == null ? null : simpleTypeName(target); + final targetIsProvider = targetType?.endsWith('Provider') ?? false; + final providerType = + targetIsProvider ? targetType! : simpleTypeName(node.methodName.name); + final constructor = targetIsProvider ? node.methodName.name : null; + _inspectCall( + node: node, + providerType: providerType, + constructor: constructor, + arguments: node.argumentList, + ); + super.visitMethodInvocation(node); + } + + void _inspectCall({ + required AstNode node, + required String providerType, + required String? constructor, + required ArgumentList arguments, + }) { + if (!providerType.endsWith('Provider')) return; + if (constructor == 'value') { + final value = _argument(arguments, 'value'); + final createdType = _createdType(value); + final isConst = value is InstanceCreationExpression && value.isConst; + if (createdType != null && !isConst && !_isImmutableType(createdType)) { + findings.add(_ProviderOwnershipFinding( + node, + 'value_creates', + '$providerType.value 创建了新对象,Provider 不会按 create 所有权管理它', + )); + } + } + + final create = _argument(arguments, 'create') ?? + (constructor == 'create' ? _firstPositional(arguments) : null); + if (create is FunctionExpression) { + final returned = _returnedExpression(create.body); + if (returned != null && _isExistingReference(returned)) { + findings.add(_ProviderOwnershipFinding( + node, + 'create_reuses', + '$providerType.create 返回已有实例,可能错误取得其释放所有权', + )); + } + } + } + + static String? _createdType(Expression? expression) { + if (expression is InstanceCreationExpression) { + return simpleTypeName(expression.constructorName.type.toSource()); + } + if (expression is MethodInvocation && + expression.target == null && + expression.methodName.name.startsWith(RegExp('[A-Z]'))) { + return simpleTypeName(expression.methodName.name); + } + return null; + } + + bool _isImmutableType(String type) { + if (allowlist.contains(type)) return true; + return type.endsWith('Value') || + type.endsWith('Data') || + type.endsWith('Dto') || + type == 'String' || + type == 'int' || + type == 'double' || + type == 'bool'; + } + + static Expression? _argument(ArgumentList list, String name) { + for (final argument in list.arguments) { + if (argument is NamedExpression && argument.name.label.name == name) { + return argument.expression; + } + } + return null; + } + + static Expression? _firstPositional(ArgumentList list) { + for (final argument in list.arguments) { + if (argument is! NamedExpression) return argument; + } + return null; + } + + static Expression? _returnedExpression(FunctionBody body) { + if (body is ExpressionFunctionBody) return body.expression; + if (body is BlockFunctionBody) { + for (final statement in body.block.statements) { + if (statement is ReturnStatement) return statement.expression; + } + } + return null; + } + + static bool _isExistingReference(Expression expression) => + expression is SimpleIdentifier || + expression is PrefixedIdentifier || + expression is PropertyAccess; +} + +class NotifyListenersInLoopRule { + final StateRuleConfig config; + final StateManagementConfig stateManagement; + final String projectPath; + + const NotifyListenersInLoopRule( + this.config, + this.stateManagement, { + required this.projectPath, + }); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config, stateManagement)) return []; + if (config.allowlist.contains('notifyListeners')) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + if (shouldIgnoreStateFile(file, projectPath, config)) continue; + final source = sources.source(file); + if (source == null || + !frameworkAllowed( + source.unit, + StateManagementFramework.provider, + stateManagement, + )) { + continue; + } + final visitor = _NotifyLoopVisitor(); + source.unit.accept(visitor); + for (final finding in visitor.findings) { + issues.add(StaticIssue( + id: 'notify_listeners_in_loop', + title: '循环中调用 notifyListeners', + file: file, + line: sourceLine(source, finding.root), + level: config.severity, + domain: IssueDomain.performance, + priority: priorityForSeverity(config.severity), + message: '循环每次迭代都可能触发监听者重建', + suggestion: '在循环内完成批量修改后,只调用一次 notifyListeners()', + framework: StateManagementFramework.provider, + evidence: limitedEvidence(finding.notifications.map(compactEvidence)), + )); + } + } + return issues; + } + + static RuleMeta describe() => const RuleMeta( + id: 'notify_listeners_in_loop', + name: '循环中通知监听者', + domain: 'performance', + riskLevel: 'medium', + priority: 'p1', + purpose: '检测 for/while/do-while/forEach 中的 notifyListeners', + riskReason: '循环内通知会触发重复重建并暴露中间状态', + badExample: + 'for (final item in items) { update(item); notifyListeners(); }', + fixSuggestion: '循环结束后统一通知一次', + configKeys: ['enabled', 'severity', 'allowlist', 'ignore_paths'], + framework: 'provider', + ); +} + +class _NotifyLoopFinding { + final AstNode root; + final List notifications; + + const _NotifyLoopFinding(this.root, this.notifications); +} + +class _NotifyLoopVisitor extends RecursiveAstVisitor { + final findings = <_NotifyLoopFinding>[]; + + @override + void visitForStatement(ForStatement node) { + final parts = node.forLoopParts; + final provablyShort = _provablyShortFor(parts); + if (!provablyShort) _inspect(node, node.body); + super.visitForStatement(node); + } + + @override + void visitWhileStatement(WhileStatement node) { + _inspect(node, node.body); + super.visitWhileStatement(node); + } + + @override + void visitDoStatement(DoStatement node) { + _inspect(node, node.body); + super.visitDoStatement(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.methodName.name == 'forEach') { + final callback = node.argumentList.arguments + .whereType() + .firstOrNull; + final target = node.target; + final provablyShort = + target is ListLiteral && target.elements.length <= 1 || + target is SetOrMapLiteral && target.elements.length <= 1; + if (callback != null && !provablyShort) _inspect(node, callback.body); + } + super.visitMethodInvocation(node); + } + + void _inspect(AstNode root, AstNode body) { + final visitor = _NotifyInvocationVisitor(); + body.accept(visitor); + if (visitor.nodes.isNotEmpty) { + findings.add(_NotifyLoopFinding(root, visitor.nodes)); + } + } + + bool _provablyShortFor(ForLoopParts parts) { + if (parts is ForEachParts) { + final iterable = parts.iterable; + return iterable is ListLiteral && iterable.elements.length <= 1 || + iterable is SetOrMapLiteral && iterable.elements.length <= 1; + } + final source = parts.toSource().replaceAll(RegExp(r'\s+'), ' '); + return RegExp(r'= 0; [A-Za-z_$][\w$]* < 1;').hasMatch(source) || + RegExp(r'= 0; [A-Za-z_$][\w$]* <= 0;').hasMatch(source); + } +} + +class _NotifyInvocationVisitor extends RecursiveAstVisitor { + final nodes = []; + + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.methodName.name == 'notifyListeners') nodes.add(node); + super.visitMethodInvocation(node); + } +} + +extension _FirstOrNull on Iterable { + T? get firstOrNull => isEmpty ? null : first; +} diff --git a/packages/flutterguard_cli/lib/src/rules/riverpod_state_management.dart b/packages/flutterguard_cli/lib/src/rules/riverpod_state_management.dart new file mode 100644 index 0000000..faf1e86 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/rules/riverpod_state_management.dart @@ -0,0 +1,306 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import '../config_loader.dart'; +import '../domain.dart'; +import '../rule_meta.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; +import 'state_management_utils.dart'; + +class RiverpodReadUsedForRenderRule { + final StateRuleConfig config; + final StateManagementConfig stateManagement; + final String projectPath; + + const RiverpodReadUsedForRenderRule( + this.config, + this.stateManagement, { + required this.projectPath, + }); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config, stateManagement)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + if (shouldIgnoreStateFile(file, projectPath, config)) continue; + final source = sources.source(file); + if (source == null || + !frameworkAllowed( + source.unit, + StateManagementFramework.riverpod, + stateManagement, + )) { + continue; + } + for (final root in buildRoots(source.unit)) { + final reads = _RiverpodReadCollector(root.body)..collect(); + for (final finding in reads.findings) { + if (config.allowlist.contains(finding.provider)) continue; + issues.add(StaticIssue( + id: 'riverpod_read_used_for_render', + title: '使用 ref.read 驱动渲染', + file: file, + line: sourceLine(source, finding.read), + level: config.severity, + domain: IssueDomain.performance, + priority: priorityForSeverity(config.severity), + message: 'ref.read 的结果进入了渲染路径,后续状态变化不会触发重建', + suggestion: '在渲染路径使用 ref.watch;命令调用继续使用 ref.read', + framework: StateManagementFramework.riverpod, + evidence: limitedEvidence(finding.sinks), + metadata: {'provider': finding.provider}, + )); + } + } + } + return issues; + } + + static RuleMeta describe() => const RuleMeta( + id: 'riverpod_read_used_for_render', + name: 'ref.read 驱动渲染', + domain: 'performance', + riskLevel: 'medium', + priority: 'p1', + purpose: '检测 build 中使用 ref.read 的值构造 Widget 或控制条件渲染', + riskReason: 'read 不订阅 Provider,状态变化后界面可能保持陈旧', + badExample: 'Text(ref.read(deviceProvider).name)', + fixSuggestion: '渲染数据改用 ref.watch', + configKeys: ['enabled', 'severity', 'allowlist', 'ignore_paths'], + framework: 'riverpod', + ); +} + +class _ReadFinding { + final MethodInvocation read; + final String provider; + final List sinks; + + const _ReadFinding(this.read, this.provider, this.sinks); +} + +class _RiverpodReadCollector { + final AstNode root; + final findings = <_ReadFinding>[]; + + _RiverpodReadCollector(this.root); + + void collect() { + final visitor = _ReadInvocationVisitor(root); + root.accept(visitor); + for (final read in visitor.reads) { + final provider = read.argumentList.arguments.isEmpty + ? '' + : read.argumentList.arguments.first.toSource(); + final sinks = []; + if (_isRenderSink(read, root)) { + sinks.add( + 'render sink: ${compactEvidence(_nearestRenderNode(read, root))}'); + } + final variable = _assignedVariable(read); + if (variable != null) { + final usages = _IdentifierUsageVisitor(variable, root)..scan(); + for (final usage in usages.renderUsages) { + sinks.add('render use of $variable: ${compactEvidence( + _nearestRenderNode(usage, root), + )}'); + } + } + if (sinks.isNotEmpty) findings.add(_ReadFinding(read, provider, sinks)); + } + } + + static String? _assignedVariable(MethodInvocation read) { + AstNode? current = read.parent; + while (current != null) { + if (current is VariableDeclaration && current.initializer != null) { + return current.name.lexeme; + } + if (current is Statement || current is FunctionBody) break; + current = current.parent; + } + return null; + } + + static bool _isRenderSink(AstNode node, AstNode root) { + AstNode? current = node; + while (current != null && current != root) { + if (current is FunctionExpression) return false; + if (current is ReturnStatement || + current is IfStatement || + current is ConditionalExpression || + current is IfElement || + current is ForElement) { + return true; + } + current = current.parent; + } + return false; + } + + static AstNode _nearestRenderNode(AstNode node, AstNode root) { + AstNode current = node; + while (current.parent != null && current.parent != root) { + final parent = current.parent!; + if (parent is ReturnStatement || + parent is IfStatement || + parent is ConditionalExpression || + parent is IfElement || + parent is ForElement) { + return parent; + } + current = parent; + } + return current; + } +} + +class _ReadInvocationVisitor extends RecursiveAstVisitor { + final AstNode root; + final reads = []; + + _ReadInvocationVisitor(this.root); + + @override + void visitFunctionExpression(FunctionExpression node) { + if (node == root) super.visitFunctionExpression(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.methodName.name == 'read' && node.target?.toSource() == 'ref') { + reads.add(node); + } + super.visitMethodInvocation(node); + } +} + +class _IdentifierUsageVisitor extends RecursiveAstVisitor { + final String name; + final AstNode root; + final renderUsages = []; + + _IdentifierUsageVisitor(this.name, this.root); + + void scan() => root.accept(this); + + @override + void visitFunctionExpression(FunctionExpression node) {} + + @override + void visitSimpleIdentifier(SimpleIdentifier node) { + if (node.name == name && + node.parent is! VariableDeclaration && + _RiverpodReadCollector._isRenderSink(node, root)) { + renderUsages.add(node); + } + super.visitSimpleIdentifier(node); + } +} + +class RiverpodWatchInCallbackRule { + final StateRuleConfig config; + final StateManagementConfig stateManagement; + final String projectPath; + + const RiverpodWatchInCallbackRule( + this.config, + this.stateManagement, { + required this.projectPath, + }); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config, stateManagement)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + if (shouldIgnoreStateFile(file, projectPath, config)) continue; + final source = sources.source(file); + if (source == null || + !frameworkAllowed( + source.unit, + StateManagementFramework.riverpod, + stateManagement, + )) { + continue; + } + final visitor = _WatchCallbackVisitor(); + source.unit.accept(visitor); + for (final callback in visitor.callbacks) { + final watches = callback.watches.where((watch) { + final provider = watch.argumentList.arguments.isEmpty + ? '' + : watch.argumentList.arguments.first.toSource(); + return !config.allowlist.contains(provider); + }).toList(); + if (watches.isEmpty) continue; + issues.add(StaticIssue( + id: 'riverpod_watch_in_callback', + title: '回调中调用 ref.watch', + file: file, + line: sourceLine(source, callback.function), + level: config.severity, + domain: IssueDomain.performance, + priority: priorityForSeverity(config.severity), + message: '事件或异步回调中调用 ref.watch,订阅不会形成有效渲染依赖', + suggestion: '回调中使用 ref.read;只在 build/provider 声明中使用 ref.watch', + framework: StateManagementFramework.riverpod, + evidence: limitedEvidence(watches.map(compactEvidence)), + )); + } + } + return issues; + } + + static RuleMeta describe() => const RuleMeta( + id: 'riverpod_watch_in_callback', + name: '回调中使用 ref.watch', + domain: 'performance', + riskLevel: 'medium', + priority: 'p1', + purpose: '检测事件、listener、timer 和异步回调中的 ref.watch', + riskReason: '回调不是响应式构建范围,watch 的订阅语义无效或误导', + badExample: 'onPressed: () => ref.watch(deviceProvider)', + fixSuggestion: '回调改用 ref.read', + configKeys: ['enabled', 'severity', 'allowlist', 'ignore_paths'], + framework: 'riverpod', + ); +} + +class _WatchCallbackFinding { + final FunctionExpression function; + final List watches; + + const _WatchCallbackFinding(this.function, this.watches); +} + +class _WatchCallbackVisitor extends RecursiveAstVisitor { + final callbacks = <_WatchCallbackFinding>[]; + + @override + void visitFunctionExpression(FunctionExpression node) { + if (isCallbackFunction(node)) { + final watches = _WatchInvocationVisitor(); + node.body.accept(watches); + if (watches.nodes.isNotEmpty) { + callbacks.add(_WatchCallbackFinding(node, watches.nodes)); + } + return; + } + super.visitFunctionExpression(node); + } +} + +class _WatchInvocationVisitor extends RecursiveAstVisitor { + final nodes = []; + + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.methodName.name == 'watch' && node.target?.toSource() == 'ref') { + nodes.add(node); + } + super.visitMethodInvocation(node); + } +} diff --git a/packages/flutterguard_cli/lib/src/rules/state_dependency_cycle.dart b/packages/flutterguard_cli/lib/src/rules/state_dependency_cycle.dart new file mode 100644 index 0000000..7b90e5f --- /dev/null +++ b/packages/flutterguard_cli/lib/src/rules/state_dependency_cycle.dart @@ -0,0 +1,437 @@ +import 'dart:collection'; + +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import '../config_loader.dart'; +import '../domain.dart'; +import '../import_utils.dart'; +import '../path_utils.dart'; +import '../rule_meta.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; +import 'state_management_utils.dart'; + +class StateDependencyCycleRule { + final StateRuleConfig config; + final StateManagementConfig stateManagement; + final String projectPath; + + const StateDependencyCycleRule( + this.config, + this.stateManagement, { + required this.projectPath, + }); + + List analyze( + List allFiles, { + List? targetFiles, + bool changedOnly = false, + SourceWorkspace? workspace, + }) { + if (!stateRuleEnabled(config, stateManagement)) return []; + final sources = workspace ?? SourceWorkspace(); + final normalizedFiles = allFiles.map(normalizePath).toSet(); + final units = <({String file, SourceUnit source})>[]; + final nodes = {}; + final names = >{}; + + void register(_StateGraphNode node) { + nodes[node.id] = node; + names.putIfAbsent(node.name, () => []).add(node.id); + } + + for (final file in normalizedFiles) { + if (shouldIgnoreStateFile(file, projectPath, config)) continue; + final source = sources.source(file); + if (source == null) continue; + units.add((file: source.path, source: source)); + for (final cls + in source.unit.declarations.whereType()) { + register(_StateGraphNode( + id: _nodeId(source.path, 'class', cls.name.lexeme), + name: cls.name.lexeme, + file: source.path, + anchor: cls, + isState: isStateOwnerClass(cls), + )); + } + for (final declaration in source.unit.declarations + .whereType()) { + for (final variable in declaration.variables.variables) { + final initializer = variable.initializer; + if (initializer != null && _isProviderDeclaration(initializer)) { + register(_StateGraphNode( + id: _nodeId(source.path, 'provider', variable.name.lexeme), + name: variable.name.lexeme, + file: source.path, + anchor: variable, + isState: true, + )); + } + } + } + } + + for (final candidates in names.values) { + candidates.sort(); + } + final graph = >{ + for (final id in nodes.keys) id: {}, + }; + final knownNames = names.keys.toSet(); + + for (final item in units) { + final importedFiles = _resolvedImports( + item.source.unit, + item.file, + normalizedFiles, + ); + for (final cls + in item.source.unit.declarations.whereType()) { + final ownerId = _nodeId(item.file, 'class', cls.name.lexeme); + if (!nodes.containsKey(ownerId)) continue; + final collector = _DependencyCollector(knownNames); + cls.accept(collector); + _addResolvedEdges( + graph: graph, + ownerId: ownerId, + dependencyNames: collector.dependencies, + sourceFile: item.file, + importedFiles: importedFiles, + nodes: nodes, + names: names, + ); + } + for (final declaration in item.source.unit.declarations + .whereType()) { + for (final variable in declaration.variables.variables) { + final initializer = variable.initializer; + final ownerId = _nodeId( + item.file, + 'provider', + variable.name.lexeme, + ); + if (initializer == null || !nodes.containsKey(ownerId)) continue; + final collector = _DependencyCollector(knownNames); + initializer.accept(collector); + _addResolvedEdges( + graph: graph, + ownerId: ownerId, + dependencyNames: collector.dependencies, + sourceFile: item.file, + importedFiles: importedFiles, + nodes: nodes, + names: names, + ); + } + } + } + + _applyEdgeAllowlist(graph, nodes, names); + + final targetSet = (targetFiles ?? allFiles).map(normalizePath).toSet(); + final issues = []; + for (final component in _stronglyConnectedComponents(graph)) { + if (component.length < 2 || !component.any((id) => nodes[id]!.isState)) { + continue; + } + final changedNodes = component + .where((id) => targetSet.contains(nodes[id]!.file)) + .toList() + ..sort(); + if (changedOnly && changedNodes.isEmpty) continue; + final cycleIds = _shortestCycle(component, graph); + if (cycleIds.isEmpty) continue; + final anchorId = changedOnly ? changedNodes.first : cycleIds.first; + final anchor = nodes[anchorId]!; + final cycle = [ + for (final id in cycleIds) _displayName(nodes[id]!, names), + ]; + final componentNames = [ + for (final id in component) _displayName(nodes[id]!, names), + ]..sort(); + final source = sources.source(anchor.file); + if (source == null) continue; + issues.add(StaticIssue( + id: 'state_dependency_cycle', + title: '状态依赖环', + file: anchor.file, + line: sourceLine(source, anchor.anchor), + level: config.severity, + domain: IssueDomain.architecture, + priority: priorityForSeverity(config.severity), + message: '状态依赖形成环: ${cycle.join(' -> ')}', + suggestion: '提取单向协调器或接口,移除环中的一条状态依赖边', + evidence: [cycle.join(' -> ')], + metadata: {'cycle': cycle, 'nodes': componentNames}, + )); + } + return issues; + } + + static String _nodeId(String file, String kind, String name) => + '${normalizePath(file)}::$kind::$name'; + + Set _resolvedImports( + CompilationUnit unit, + String file, + Set allFiles, + ) { + final result = {}; + for (final directive in unit.directives.whereType()) { + final uri = directive.uri.stringValue; + if (uri == null) continue; + final resolved = resolveImport( + file, + uri, + allFiles, + projectPath: projectPath, + ); + if (resolved != null) result.add(normalizePath(resolved)); + } + return result; + } + + static void _addResolvedEdges({ + required Map> graph, + required String ownerId, + required Set dependencyNames, + required String sourceFile, + required Set importedFiles, + required Map nodes, + required Map> names, + }) { + for (final name in dependencyNames) { + final dependencyId = _resolveNode( + name: name, + sourceFile: sourceFile, + importedFiles: importedFiles, + nodes: nodes, + names: names, + ); + if (dependencyId != null && dependencyId != ownerId) { + graph[ownerId]!.add(dependencyId); + } + } + } + + static String? _resolveNode({ + required String name, + required String sourceFile, + required Set importedFiles, + required Map nodes, + required Map> names, + }) { + final candidates = names[name] ?? const []; + if (candidates.isEmpty) return null; + final normalizedSource = normalizePath(sourceFile); + final local = + candidates.where((id) => nodes[id]!.file == normalizedSource).toList(); + if (local.length == 1) return local.single; + final imported = candidates + .where((id) => importedFiles.contains(nodes[id]!.file)) + .toList(); + if (imported.length == 1) return imported.single; + return candidates.length == 1 ? candidates.single : null; + } + + void _applyEdgeAllowlist( + Map> graph, + Map nodes, + Map> names, + ) { + final allowed = <({String source, String target})>[]; + for (final entry in config.allowlist) { + final parts = entry.split('->'); + if (parts.length == 2) { + allowed.add((source: parts[0], target: parts[1])); + } + } + for (final edge in graph.entries) { + final source = nodes[edge.key]!; + edge.value.removeWhere((targetId) { + final target = nodes[targetId]!; + return allowed.any( + (entry) => + _matchesAllowlistName(entry.source, source, names) && + _matchesAllowlistName(entry.target, target, names), + ); + }); + } + } + + bool _matchesAllowlistName( + String configured, + _StateGraphNode node, + Map> names, + ) => + configured == node.name || configured == _displayName(node, names); + + String _displayName( + _StateGraphNode node, + Map> names, + ) { + if ((names[node.name]?.length ?? 0) <= 1) return node.name; + final path = + projectRelativePath(node.file, projectPath).replaceAll('\\', '/'); + return '$path::${node.name}'; + } + + static bool _isProviderDeclaration(Expression initializer) { + final source = initializer.toSource(); + return RegExp(r'(^|\.)[A-Za-z]*Provider(?:<[^>]+>)?\s*\(').hasMatch(source); + } + + static List> _stronglyConnectedComponents( + Map> graph, + ) { + var index = 0; + final indices = {}; + final lowlink = {}; + final stack = []; + final onStack = {}; + final result = >[]; + + void connect(String node) { + indices[node] = index; + lowlink[node] = index; + index++; + stack.add(node); + onStack.add(node); + final neighbors = (graph[node] ?? const {}).toList()..sort(); + for (final next in neighbors) { + if (!indices.containsKey(next)) { + connect(next); + lowlink[node] = + lowlink[node]! < lowlink[next]! ? lowlink[node]! : lowlink[next]!; + } else if (onStack.contains(next)) { + lowlink[node] = + lowlink[node]! < indices[next]! ? lowlink[node]! : indices[next]!; + } + } + if (lowlink[node] != indices[node]) return; + final component = {}; + while (stack.isNotEmpty) { + final current = stack.removeLast(); + onStack.remove(current); + component.add(current); + if (current == node) break; + } + result.add(component); + } + + final ids = graph.keys.toList()..sort(); + for (final id in ids) { + if (!indices.containsKey(id)) connect(id); + } + return result; + } + + static List _shortestCycle( + Set component, + Map> graph, + ) { + List? best; + final starts = component.toList()..sort(); + for (final start in starts) { + final queue = Queue>()..add([start]); + final shortestDepth = {start: 0}; + while (queue.isNotEmpty) { + final path = queue.removeFirst(); + final current = path.last; + final neighbors = (graph[current] ?? const {}) + .where(component.contains) + .toList() + ..sort(); + for (final next in neighbors) { + if (next == start && path.length > 1) { + final candidate = [...path, start]; + if (best == null || + candidate.length < best.length || + candidate.length == best.length && + candidate.join('\u0000').compareTo(best.join('\u0000')) < + 0) { + best = candidate; + } + continue; + } + if (path.contains(next)) continue; + final depth = path.length; + if ((shortestDepth[next] ?? 1 << 30) < depth) continue; + shortestDepth[next] = depth; + queue.add([...path, next]); + } + } + } + return best ?? const []; + } + + static RuleMeta describe() => const RuleMeta( + id: 'state_dependency_cycle', + name: '状态依赖环', + domain: 'architecture', + riskLevel: 'high', + priority: 'p0', + purpose: '检测 Provider、状态 owner 与其项目内依赖形成的强连通环', + riskReason: '依赖环导致初始化顺序不确定、递归更新和难以隔离的测试', + badExample: 'AuthController -> SessionProvider -> AuthController', + fixSuggestion: '提取协调器或接口,使依赖保持单向', + configKeys: ['enabled', 'severity', 'allowlist', 'ignore_paths'], + ); +} + +class _StateGraphNode { + final String id; + final String name; + final String file; + final AstNode anchor; + final bool isState; + + const _StateGraphNode({ + required this.id, + required this.name, + required this.file, + required this.anchor, + required this.isState, + }); +} + +class _DependencyCollector extends RecursiveAstVisitor { + final Set knownNames; + final dependencies = {}; + + _DependencyCollector(this.knownNames); + + @override + void visitNamedType(NamedType node) { + final type = simpleTypeName(node.toSource()); + if (knownNames.contains(type)) dependencies.add(type); + super.visitNamedType(node); + } + + @override + void visitInstanceCreationExpression(InstanceCreationExpression node) { + final type = simpleTypeName(node.constructorName.type.toSource()); + if (knownNames.contains(type)) dependencies.add(type); + super.visitInstanceCreationExpression(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + final name = node.methodName.name; + if (name == 'watch' || name == 'read') { + if (node.argumentList.arguments.isNotEmpty) { + final dependency = node.argumentList.arguments.first.toSource(); + final simple = dependency.split('.').first; + if (knownNames.contains(simple)) dependencies.add(simple); + } + } + final typeArguments = node.typeArguments?.arguments ?? const []; + for (final argument in typeArguments) { + final type = simpleTypeName(argument.toSource()); + if (knownNames.contains(type)) dependencies.add(type); + } + super.visitMethodInvocation(node); + } +} diff --git a/packages/flutterguard_cli/lib/src/rules/state_management_utils.dart b/packages/flutterguard_cli/lib/src/rules/state_management_utils.dart new file mode 100644 index 0000000..d2fe147 --- /dev/null +++ b/packages/flutterguard_cli/lib/src/rules/state_management_utils.dart @@ -0,0 +1,236 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import '../config_loader.dart'; +import '../path_utils.dart'; +import '../priority.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; + +const stateOwnerSuffixes = [ + 'State', + 'Notifier', + 'Controller', + 'Cubit', + 'Bloc', + 'ChangeNotifier', +]; + +Priority priorityForSeverity(RiskLevel severity) => switch (severity) { + RiskLevel.high => Priority.p0, + RiskLevel.medium => Priority.p1, + RiskLevel.low => Priority.p2, + }; + +bool stateRuleEnabled( + StateRuleConfig rule, + StateManagementConfig stateManagement, +) { + if (!stateManagement.enabled || !rule.enabled) return false; + // All state-management rules currently emit certain findings. The ordering + // keeps the threshold model forward compatible when lower-confidence rules + // are introduced. + return switch (stateManagement.confidenceThreshold) { + RuleConfidence.certain => true, + RuleConfidence.probable => true, + RuleConfidence.informational => true, + }; +} + +bool shouldIgnoreStateFile( + String file, + String projectPath, + StateRuleConfig config, +) => + config.ignorePaths.any( + (pattern) => matchesProjectGlob(file, pattern, projectPath), + ); + +bool hasFrameworkImport( + CompilationUnit unit, + StateManagementFramework framework, +) { + final imports = unit.directives + .whereType() + .map((directive) => directive.uri.stringValue ?? '') + .toList(); + return switch (framework) { + StateManagementFramework.riverpod => imports.any( + (uri) => uri.contains('riverpod') || uri.contains('hooks_riverpod'), + ), + StateManagementFramework.bloc => imports.any( + (uri) => + uri.contains('package:bloc/') || + uri.contains('package:flutter_bloc/'), + ), + StateManagementFramework.provider => imports.any( + (uri) => + uri.contains('package:provider/') || + uri.contains('package:flutter_bloc/'), + ), + StateManagementFramework.generic => true, + }; +} + +bool frameworkAllowed( + CompilationUnit unit, + StateManagementFramework framework, + StateManagementConfig config, +) => + !config.frameworkAutoDetect || hasFrameworkImport(unit, framework); + +bool isStateOwnerClass(ClassDeclaration declaration) { + final name = declaration.name.lexeme; + final supertype = declaration.extendsClause?.superclass.toSource() ?? ''; + final mixins = declaration.withClause?.mixinTypes + .map((type) => type.toSource()) + .join(' ') ?? + ''; + if (supertype.startsWith('State<')) return false; + return stateOwnerSuffixes.any(name.endsWith) || + stateOwnerSuffixes.any(supertype.endsWith) || + stateOwnerSuffixes.any(mixins.contains); +} + +String typeName(TypeAnnotation? type) => type?.toSource() ?? ''; + +String simpleTypeName(String type) { + final withoutGenerics = type.split('<').first; + return withoutGenerics.split('.').last.replaceAll('?', ''); +} + +bool isCollectionType(String type) { + final simple = simpleTypeName(type); + return simple == 'List' || + simple == 'Set' || + simple == 'Map' || + simple.startsWith('Iterable'); +} + +bool isUnmodifiableExpression(Expression? expression) { + if (expression == null) return false; + final source = expression.toSource(); + return source.contains('unmodifiable') || + source.contains('Unmodifiable') || + source.contains('List.unmodifiable') || + source.contains('Set.unmodifiable') || + source.contains('Map.unmodifiable'); +} + +bool isPublicName(String name) => !name.startsWith('_'); + +String compactEvidence(AstNode node) { + final value = node.toSource().replaceAll(RegExp(r'\s+'), ' ').trim(); + return value.length <= 140 ? value : '${value.substring(0, 137)}...'; +} + +List limitedEvidence(Iterable evidence) => + evidence.toSet().take(5).toList(); + +int sourceLine(SourceUnit source, AstNode node) => + source.lineInfo.getLocation(node.offset).lineNumber; + +class BuildRoot { + final AstNode body; + final AstNode anchor; + final String label; + + const BuildRoot(this.body, this.anchor, this.label); +} + +List buildRoots(CompilationUnit unit) { + final collector = _BuildRootCollector(); + unit.accept(collector); + final roots = []; + final seen = {}; + for (final root in collector.roots) { + if (seen.add(root.anchor.offset)) roots.add(root); + } + return roots; +} + +class _BuildRootCollector extends RecursiveAstVisitor { + final roots = []; + + @override + void visitMethodDeclaration(MethodDeclaration node) { + if (node.name.lexeme == 'build') { + roots.add(BuildRoot(node.body, node, 'build')); + } + super.visitMethodDeclaration(node); + } + + @override + void visitNamedExpression(NamedExpression node) { + if (node.name.label.name == 'builder' && + node.expression is FunctionExpression && + _isConsumerBuilder(node)) { + final function = node.expression as FunctionExpression; + roots.add(BuildRoot(function.body, function, 'Consumer.builder')); + } + super.visitNamedExpression(node); + } + + bool _isConsumerBuilder(NamedExpression node) { + final arguments = node.parent; + final call = arguments?.parent; + if (call is InstanceCreationExpression) { + return call.constructorName.type.toSource().contains('Consumer'); + } + if (call is MethodInvocation) { + return call.methodName.name.toLowerCase().contains('consumer'); + } + return false; + } +} + +bool isCallbackFunction(FunctionExpression node) { + final parent = node.parent; + if (parent is NamedExpression) { + const callbackNames = { + 'onPressed', + 'onTap', + 'onChanged', + 'onSubmitted', + 'listener', + 'listenWhen', + 'callback', + 'onData', + 'onError', + 'onDone', + 'timer', + }; + return callbackNames.contains(parent.name.label.name); + } + final arguments = parent is NamedExpression ? parent.parent : parent; + final invocation = arguments?.parent; + if (invocation is MethodInvocation) { + return const { + 'forEach', + 'listen', + 'then', + 'catchError', + 'whenComplete', + 'delayed', + 'microtask', + }.contains(invocation.methodName.name); + } + if (invocation is InstanceCreationExpression) { + final type = simpleTypeName(invocation.constructorName.type.toSource()); + return type == 'Timer' || type == 'Future'; + } + return false; +} + +bool isInsideNestedFunction(AstNode node, AstNode root) { + AstNode? current = node.parent; + while (current != null && current != root) { + if (current is FunctionExpression) return true; + current = current.parent; + } + return false; +} + +bool hasEquatableImport(CompilationUnit unit) => + unit.directives.whereType().any((directive) => + (directive.uri.stringValue ?? '').contains('package:equatable/')); From d1f67767917a517892dabcc2381f42d7a32632a9 Mon Sep 17 00:00:00 2001 From: lizy Date: Thu, 16 Jul 2026 21:16:24 +0800 Subject: [PATCH 04/16] cli: add state management test fixtures --- .../test/fixtures/bloc_state.dart | 34 +++++++ .../test/fixtures/generic_state.dart | 90 +++++++++++++++++++ .../test/fixtures/provider_state.dart | 48 ++++++++++ .../test/fixtures/riverpod_state.dart | 45 ++++++++++ 4 files changed, 217 insertions(+) create mode 100644 packages/flutterguard_cli/test/fixtures/bloc_state.dart create mode 100644 packages/flutterguard_cli/test/fixtures/generic_state.dart create mode 100644 packages/flutterguard_cli/test/fixtures/provider_state.dart create mode 100644 packages/flutterguard_cli/test/fixtures/riverpod_state.dart diff --git a/packages/flutterguard_cli/test/fixtures/bloc_state.dart b/packages/flutterguard_cli/test/fixtures/bloc_state.dart new file mode 100644 index 0000000..88c2c72 --- /dev/null +++ b/packages/flutterguard_cli/test/fixtures/bloc_state.dart @@ -0,0 +1,34 @@ +class DeviceState extends Equatable { + const DeviceState(this.name, this.connected); + + final String name; + final bool connected; + + List get props => [name]; +} + +class ReadingState extends Equatable { + const ReadingState(this.temperature, this.humidity); + + final double temperature; + final double humidity; + + List get props => [temperature]; +} + +class CompleteState extends Equatable { + const CompleteState(this.a, this.b); + + final int a; + final int b; + + List get props => [a, b]; +} + +class EmptyState extends Equatable { + const EmptyState(); + + static const version = 1; + int get computed => 1; + List get props => const []; +} diff --git a/packages/flutterguard_cli/test/fixtures/generic_state.dart b/packages/flutterguard_cli/test/fixtures/generic_state.dart new file mode 100644 index 0000000..fb7e4ba --- /dev/null +++ b/packages/flutterguard_cli/test/fixtures/generic_state.dart @@ -0,0 +1,90 @@ +class Widget { + Widget({Object? onPressed}); +} + +class BuildContext {} + +class StatelessWidget {} + +class DeviceController { + DeviceController(); + + final List _items = []; + List get items => _items; + + void mutate() { + state.items.add(1); + } +} + +class DeviceBloc {} + +class BadBuildWidget extends StatelessWidget { + Widget build(BuildContext context) { + notifyListeners(); + final controller = DeviceController(); + return Widget(); + } +} + +class SecondBadBuildWidget extends StatelessWidget { + Widget build(BuildContext context) { + setState(() {}); + final bloc = DeviceBloc(); + return Widget(); + } +} + +class SafeBuildWidget extends StatelessWidget { + Widget build(BuildContext context) { + final values = []; + values.add(1); + return Widget(onPressed: () { + notifyListeners(); + final controller = DeviceController(); + }); + } +} + +class DeviceState { + int count = 0; + final List items = []; +} + +class SafeState { + final List items = List.unmodifiable(const []); +} + +class State {} + +class Page {} + +class PageState extends State { + int widgetCounter = 0; +} + +class NavigationController { + NavigationController(this.context); + + final BuildContext context; + + void open() { + Navigator.of(context).push('/device'); + } +} + +class ThemeController { + void update(Widget widget) {} +} + +class CycleController { + CycleController(this.service); + + final CycleService service; +} + +class CycleService { + CycleService(this.controller); + + final CycleController controller; +} diff --git a/packages/flutterguard_cli/test/fixtures/provider_state.dart b/packages/flutterguard_cli/test/fixtures/provider_state.dart new file mode 100644 index 0000000..9919a46 --- /dev/null +++ b/packages/flutterguard_cli/test/fixtures/provider_state.dart @@ -0,0 +1,48 @@ +class DeviceController {} + +class ImmutableValue { + const ImmutableValue(); +} + +final existingController = DeviceController(); + +final badValue = ChangeNotifierProvider.value( + value: DeviceController(), +); + +final badCreate = ChangeNotifierProvider( + create: (_) => existingController, +); + +final safeCreate = ChangeNotifierProvider( + create: (_) => DeviceController(), +); + +final safeValue = ChangeNotifierProvider.value( + value: existingController, +); + +final safeImmutable = Provider.value( + value: const ImmutableValue(), +); + +class DeviceNotifier { + void updateAll(List values) { + for (final value in values) { + notifyListeners(); + } + values.forEach((value) { + notifyListeners(); + }); + } + + void safe() { + for (final value in [1]) { + notifyListeners(); + } + for (final value in [1, 2]) { + consume(value); + } + notifyListeners(); + } +} diff --git a/packages/flutterguard_cli/test/fixtures/riverpod_state.dart b/packages/flutterguard_cli/test/fixtures/riverpod_state.dart new file mode 100644 index 0000000..03dcea5 --- /dev/null +++ b/packages/flutterguard_cli/test/fixtures/riverpod_state.dart @@ -0,0 +1,45 @@ +class Widget { + Widget({Object? value, Object? onPressed, Object? onTap}); +} + +class BuildContext {} + +class ConsumerWidget { + Widget build(BuildContext context, dynamic ref) => Widget(); +} + +final deviceProvider = Provider((ref) => 1); + +class DirectReadWidget extends ConsumerWidget { + Widget build(BuildContext context, dynamic ref) { + return Widget(value: ref.read(deviceProvider)); + } +} + +class ConditionalReadWidget extends ConsumerWidget { + Widget build(BuildContext context, dynamic ref) { + final enabled = ref.read(deviceProvider); + if (enabled) return Widget(); + return Widget(); + } +} + +class SafeReadWidget extends ConsumerWidget { + Widget build(BuildContext context, dynamic ref) { + ref.read(deviceProvider.notifier).refresh(); + return Widget(onPressed: () => ref.read(deviceProvider)); + } +} + +class WatchCallbackWidget extends ConsumerWidget { + Widget build(BuildContext context, dynamic ref) { + final value = ref.watch(deviceProvider); + return Widget( + value: value, + onPressed: () => ref.watch(deviceProvider), + onTap: () async { + ref.watch(deviceProvider); + }, + ); + } +} From 13101cf72eb9d152a7a87a9811e9452ea4ded097 Mon Sep 17 00:00:00 2001 From: lizy Date: Thu, 16 Jul 2026 21:16:26 +0800 Subject: [PATCH 05/16] cli: wire state management rules into catalog and barrel exports --- .../lib/flutterguard_cli.dart | 6 ++ .../lib/src/rules/catalog.dart | 90 +++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/packages/flutterguard_cli/lib/flutterguard_cli.dart b/packages/flutterguard_cli/lib/flutterguard_cli.dart index e5c800d..b5a14cf 100644 --- a/packages/flutterguard_cli/lib/flutterguard_cli.dart +++ b/packages/flutterguard_cli/lib/flutterguard_cli.dart @@ -9,10 +9,12 @@ export 'src/report_generator.dart'; export 'src/rule_meta.dart'; export 'src/scan_context.dart'; export 'src/rules/ble_scanning.dart'; +export 'src/rules/bloc_state_management.dart'; export 'src/rules/catalog.dart'; export 'src/rules/circular_dependency.dart'; export 'src/rules/device_lifecycle.dart'; export 'src/rules/iot_security.dart'; +export 'src/rules/generic_state_management.dart'; export 'src/rules/large_units.dart'; export 'src/rules/layer_violation.dart'; export 'src/rules/lifecycle_resource.dart'; @@ -20,7 +22,11 @@ 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/provider_state_management.dart'; export 'src/rules/registry.dart'; +export 'src/rules/riverpod_state_management.dart'; +export 'src/rules/state_dependency_cycle.dart'; +export 'src/rules/state_management_utils.dart'; export 'src/scanner.dart'; export 'src/source_utils.dart'; export 'src/source_workspace.dart'; diff --git a/packages/flutterguard_cli/lib/src/rules/catalog.dart b/packages/flutterguard_cli/lib/src/rules/catalog.dart index dba54f7..9746249 100644 --- a/packages/flutterguard_cli/lib/src/rules/catalog.dart +++ b/packages/flutterguard_cli/lib/src/rules/catalog.dart @@ -6,9 +6,11 @@ import '../rule_meta.dart'; import '../scan_context.dart'; import '../static_issue.dart'; import 'ble_scanning.dart'; +import 'bloc_state_management.dart'; import 'circular_dependency.dart'; import 'device_lifecycle.dart'; import 'iot_security.dart'; +import 'generic_state_management.dart'; import 'large_units.dart'; import 'layer_violation.dart'; import 'lifecycle_resource.dart'; @@ -16,6 +18,9 @@ import 'missing_const_constructor.dart'; import 'module_violation.dart'; import 'mqtt_connection.dart'; import 'pubspec_security.dart'; +import 'provider_state_management.dart'; +import 'riverpod_state_management.dart'; +import 'state_dependency_cycle.dart'; typedef RuleExecutor = List Function( ScanContext context, @@ -142,6 +147,91 @@ class RuleCatalog { ); }, ), + RuleRegistration( + metadata: [SideEffectInBuildRule.describe()], + execute: (context, _) => SideEffectInBuildRule( + context.config.rules.sideEffectInBuild, + context.config.stateManagement, + projectPath: context.projectPath, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + metadata: [StateManagerCreatedInBuildRule.describe()], + execute: (context, _) => StateManagerCreatedInBuildRule( + context.config.rules.stateManagerCreatedInBuild, + context.config.stateManagement, + projectPath: context.projectPath, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + metadata: [MutableStateExposedRule.describe()], + execute: (context, _) => MutableStateExposedRule( + context.config.rules.mutableStateExposed, + context.config.stateManagement, + projectPath: context.projectPath, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + metadata: [StateLayerUiDependencyRule.describe()], + execute: (context, _) => StateLayerUiDependencyRule( + context.config.rules.stateLayerUiDependency, + context.config.stateManagement, + projectPath: context.projectPath, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + metadata: [StateDependencyCycleRule.describe()], + execute: (context, _) => StateDependencyCycleRule( + context.config.rules.stateDependencyCycle, + context.config.stateManagement, + projectPath: context.projectPath, + ).analyze( + context.allFiles, + targetFiles: context.targetFiles, + changedOnly: context.isChanged, + workspace: context.sources, + ), + ), + RuleRegistration( + metadata: [RiverpodReadUsedForRenderRule.describe()], + execute: (context, _) => RiverpodReadUsedForRenderRule( + context.config.rules.riverpodReadUsedForRender, + context.config.stateManagement, + projectPath: context.projectPath, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + metadata: [RiverpodWatchInCallbackRule.describe()], + execute: (context, _) => RiverpodWatchInCallbackRule( + context.config.rules.riverpodWatchInCallback, + context.config.stateManagement, + projectPath: context.projectPath, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + metadata: [BlocEquatablePropsIncompleteRule.describe()], + execute: (context, _) => BlocEquatablePropsIncompleteRule( + context.config.rules.blocEquatablePropsIncomplete, + context.config.stateManagement, + projectPath: context.projectPath, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + metadata: [ProviderValueLifecycleMisuseRule.describe()], + execute: (context, _) => ProviderValueLifecycleMisuseRule( + context.config.rules.providerValueLifecycleMisuse, + context.config.stateManagement, + projectPath: context.projectPath, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + metadata: [NotifyListenersInLoopRule.describe()], + execute: (context, _) => NotifyListenersInLoopRule( + context.config.rules.notifyListenersInLoop, + context.config.stateManagement, + projectPath: context.projectPath, + ).analyze(context.targetFiles, workspace: context.sources), + ), ]); static List metadata() => [ From 199c0cf38713b9f04821313d2490dd97c766493d Mon Sep 17 00:00:00 2001 From: lizy Date: Thu, 16 Jul 2026 21:16:29 +0800 Subject: [PATCH 06/16] cli: add state management config to tooling and root defaults --- flutterguard.yaml | 55 +++++++++ .../lib/src/config_tools.dart | 111 +++++++++++++++++- 2 files changed, 165 insertions(+), 1 deletion(-) diff --git a/flutterguard.yaml b/flutterguard.yaml index 0aa610b..5ae15ae 100644 --- a/flutterguard.yaml +++ b/flutterguard.yaml @@ -21,6 +21,61 @@ rules: enabled: true missing_const_constructor: enabled: true + side_effect_in_build: + enabled: true + severity: high + allowlist: [] + ignore_paths: [] + state_manager_created_in_build: + enabled: true + severity: high + allowlist: [] + ignore_paths: [] + mutable_state_exposed: + enabled: true + severity: medium + allowlist: [] + ignore_paths: [] + state_layer_ui_dependency: + enabled: true + severity: high + allowlist: [] + ignore_paths: [] + state_dependency_cycle: + enabled: true + severity: high + allowlist: [] + ignore_paths: [] + riverpod_read_used_for_render: + enabled: true + severity: medium + allowlist: [] + ignore_paths: [] + riverpod_watch_in_callback: + enabled: true + severity: medium + allowlist: [] + ignore_paths: [] + bloc_equatable_props_incomplete: + enabled: true + severity: medium + allowlist: [] + ignore_paths: [] + provider_value_lifecycle_misuse: + enabled: true + severity: medium + allowlist: [] + ignore_paths: [] + notify_listeners_in_loop: + enabled: true + severity: medium + allowlist: [] + ignore_paths: [] + +state_management: + enabled: true + framework_auto_detect: true + confidence_threshold: certain architecture: # layers: diff --git a/packages/flutterguard_cli/lib/src/config_tools.dart b/packages/flutterguard_cli/lib/src/config_tools.dart index b0db822..0d1fe2d 100644 --- a/packages/flutterguard_cli/lib/src/config_tools.dart +++ b/packages/flutterguard_cli/lib/src/config_tools.dart @@ -136,7 +136,10 @@ architecture: 'performance-only' => _performanceOnlyConfig, _ => minimalConfig, }; - return withArchitecture ? '$config$architectureBlock' : config; + final withStateManagement = '$config${_stateRulesForProfile(profile)}'; + return withArchitecture + ? '$withStateManagement$architectureBlock' + : withStateManagement; } static String writeInitConfig({ @@ -411,6 +414,57 @@ rules: 'pubspec_security', config.rules.pubspecSecurity.enabled, )) + ..write(_stateRuleYaml( + 'side_effect_in_build', + config.rules.sideEffectInBuild, + )) + ..write(_stateRuleYaml( + 'state_manager_created_in_build', + config.rules.stateManagerCreatedInBuild, + )) + ..write(_stateRuleYaml( + 'mutable_state_exposed', + config.rules.mutableStateExposed, + )) + ..write(_stateRuleYaml( + 'state_layer_ui_dependency', + config.rules.stateLayerUiDependency, + )) + ..write(_stateRuleYaml( + 'state_dependency_cycle', + config.rules.stateDependencyCycle, + )) + ..write(_stateRuleYaml( + 'riverpod_read_used_for_render', + config.rules.riverpodReadUsedForRender, + )) + ..write(_stateRuleYaml( + 'riverpod_watch_in_callback', + config.rules.riverpodWatchInCallback, + )) + ..write(_stateRuleYaml( + 'bloc_equatable_props_incomplete', + config.rules.blocEquatablePropsIncomplete, + )) + ..write(_stateRuleYaml( + 'provider_value_lifecycle_misuse', + config.rules.providerValueLifecycleMisuse, + )) + ..write(_stateRuleYaml( + 'notify_listeners_in_loop', + config.rules.notifyListenersInLoop, + )) + ..writeln() + ..writeln('state_management:') + ..writeln(' enabled: ${config.stateManagement.enabled}') + ..writeln( + ' framework_auto_detect: ' + '${config.stateManagement.frameworkAutoDetect}', + ) + ..writeln( + ' confidence_threshold: ' + '${config.stateManagement.confidenceThreshold.name}', + ) ..writeln() ..writeln('architecture:') ..write(_boundaryList('layers', config.architecture.layers)) @@ -537,6 +591,61 @@ rules: '''; } + static String _stateRuleYaml(String name, StateRuleConfig config) { + return ''' + $name: + enabled: ${config.enabled} + severity: ${config.severity.name} + allowlist: [${config.allowlist.join(', ')}] + ignore_paths: [${config.ignorePaths.join(', ')}] +'''; + } + + static String _stateRulesForProfile(String profile) { + final disabled = + profile == 'iot-security' || profile == 'architecture-only'; + final performanceOnly = profile == 'performance-only'; + bool enabled(String id) { + if (disabled) return false; + if (!performanceOnly) return true; + return const { + 'side_effect_in_build', + 'state_manager_created_in_build', + 'provider_value_lifecycle_misuse', + 'notify_listeners_in_loop', + }.contains(id); + } + + const severities = { + 'side_effect_in_build': 'high', + 'state_manager_created_in_build': 'high', + 'mutable_state_exposed': 'medium', + 'state_layer_ui_dependency': 'high', + 'state_dependency_cycle': 'high', + 'riverpod_read_used_for_render': 'medium', + 'riverpod_watch_in_callback': 'medium', + 'bloc_equatable_props_incomplete': 'medium', + 'provider_value_lifecycle_misuse': 'medium', + 'notify_listeners_in_loop': 'medium', + }; + final buffer = StringBuffer(); + for (final entry in severities.entries) { + buffer + ..writeln(' ${entry.key}:') + ..writeln(' enabled: ${enabled(entry.key)}') + ..writeln(' severity: ${entry.value}') + ..writeln(' allowlist: []') + ..writeln(' ignore_paths: []'); + } + buffer + ..writeln() + ..writeln('state_management:') + ..writeln(' enabled: true') + ..writeln(' framework_auto_detect: true') + ..writeln(' confidence_threshold: certain'); + return buffer.toString(); + } + static String _boundaryList( String key, List<({String name, String path, List allowedDeps})> boundaries, From 3cfe57e6a62b7673f039fc284711995405b14a3b Mon Sep 17 00:00:00 2001 From: lizy Date: Thu, 16 Jul 2026 21:16:31 +0800 Subject: [PATCH 07/16] cli: add framework, confidence, and evidence to report and rule output --- .../flutterguard_cli/lib/src/cli/rule_commands.dart | 6 +++++- .../flutterguard_cli/lib/src/report_generator.dart | 10 +++++++++- packages/flutterguard_cli/lib/src/sarif_report.dart | 9 +++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/flutterguard_cli/lib/src/cli/rule_commands.dart b/packages/flutterguard_cli/lib/src/cli/rule_commands.dart index 482e6e9..29f1942 100644 --- a/packages/flutterguard_cli/lib/src/cli/rule_commands.dart +++ b/packages/flutterguard_cli/lib/src/cli/rule_commands.dart @@ -20,7 +20,9 @@ class RuleCommands { stdout.writeln(); for (final rule in all) { stdout.writeln( - ' ${rule.id.padRight(32)} ${rule.domain.padRight(14)} ${rule.name}', + ' ${rule.id.padRight(36)} ${rule.domain.padRight(14)} ' + '${rule.framework.padRight(10)} ${rule.confidence.padRight(14)} ' + '${rule.name}', ); } stdout.writeln(); @@ -49,6 +51,8 @@ class RuleCommands { stdout.writeln('领域: ${meta.domain}'); stdout.writeln('风险: ${meta.riskLevel}'); stdout.writeln('优先级: ${meta.priority}'); + stdout.writeln('框架: ${meta.framework}'); + stdout.writeln('置信度: ${meta.confidence}'); stdout.writeln('CI 阻断: ${meta.cicdSafe ? "是" : "否"}'); stdout.writeln(); stdout.writeln('检测目的:'); diff --git a/packages/flutterguard_cli/lib/src/report_generator.dart b/packages/flutterguard_cli/lib/src/report_generator.dart index 06d6b79..b42011a 100644 --- a/packages/flutterguard_cli/lib/src/report_generator.dart +++ b/packages/flutterguard_cli/lib/src/report_generator.dart @@ -225,7 +225,10 @@ 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( + ' $lvlAnsi$lvlLabel$reset $priAnsi$priLabel$reset ' + '${issue.framework.name}/${issue.confidence.name}', + ); buf.writeln(' $bold${issue.title}$reset'); buf.writeln(' $gray$displayPath$lineInfo$reset'); buf.writeln(' ${issue.message}'); @@ -236,6 +239,11 @@ class ReportGenerator { buf.writeln(' $dim$line$reset'); } } + if (verbose && issue.evidence.isNotEmpty) { + for (final evidence in issue.evidence.take(5)) { + buf.writeln(' $dim证据: $evidence$reset'); + } + } buf.writeln(' 修复: $green${issue.suggestion}$reset'); buf.writeln(); } diff --git a/packages/flutterguard_cli/lib/src/sarif_report.dart b/packages/flutterguard_cli/lib/src/sarif_report.dart index 89e65f4..0d73aef 100644 --- a/packages/flutterguard_cli/lib/src/sarif_report.dart +++ b/packages/flutterguard_cli/lib/src/sarif_report.dart @@ -32,6 +32,10 @@ class SarifReport { 'defaultConfiguration': { 'level': _levelFromRule(rule.riskLevel), }, + 'properties': { + 'framework': rule.framework, + 'confidence': rule.confidence, + }, } ], }, @@ -42,6 +46,11 @@ class SarifReport { 'ruleId': issue.id, 'level': _level(issue.level), 'message': {'text': issue.message}, + 'properties': { + 'framework': issue.framework.name, + 'confidence': issue.confidence.name, + 'evidence': issue.evidence.take(5).toList(), + }, 'locations': [ { 'physicalLocation': { From d58ae388214fabb38d94a938c2b6a96b07db610b Mon Sep 17 00:00:00 2001 From: lizy Date: Thu, 16 Jul 2026 21:16:34 +0800 Subject: [PATCH 08/16] test: add state management rule tests (generic, riverpod, bloc, provider) --- .../flutterguard_cli/test/scanner_test.dart | 624 +++++++++++++++++- 1 file changed, 622 insertions(+), 2 deletions(-) diff --git a/packages/flutterguard_cli/test/scanner_test.dart b/packages/flutterguard_cli/test/scanner_test.dart index 3610eac..b2a1115 100644 --- a/packages/flutterguard_cli/test/scanner_test.dart +++ b/packages/flutterguard_cli/test/scanner_test.dart @@ -15,14 +15,19 @@ import 'package:flutterguard_cli/src/rules/circular_dependency.dart'; import 'package:flutterguard_cli/src/rules/large_units.dart'; import 'package:flutterguard_cli/src/rules/layer_violation.dart'; import 'package:flutterguard_cli/src/rules/ble_scanning.dart'; +import 'package:flutterguard_cli/src/rules/bloc_state_management.dart'; import 'package:flutterguard_cli/src/rules/device_lifecycle.dart'; +import 'package:flutterguard_cli/src/rules/generic_state_management.dart'; import 'package:flutterguard_cli/src/rules/iot_security.dart'; import 'package:flutterguard_cli/src/rules/lifecycle_resource.dart'; import 'package:flutterguard_cli/src/rules/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/provider_state_management.dart'; import 'package:flutterguard_cli/src/rules/registry.dart'; +import 'package:flutterguard_cli/src/rules/riverpod_state_management.dart'; +import 'package:flutterguard_cli/src/rules/state_dependency_cycle.dart'; import 'package:flutterguard_cli/src/sarif_report.dart'; import 'package:flutterguard_cli/src/scanner.dart'; import 'package:flutterguard_cli/src/source_workspace.dart'; @@ -32,6 +37,19 @@ import 'package:test/test.dart'; String get fixturesPath => p.join(Directory.current.path, 'test', 'fixtures'); +const _stateRuleIds = { + 'side_effect_in_build', + 'state_manager_created_in_build', + 'mutable_state_exposed', + 'state_layer_ui_dependency', + 'state_dependency_cycle', + 'riverpod_read_used_for_render', + 'riverpod_watch_in_callback', + 'bloc_equatable_props_incomplete', + 'provider_value_lifecycle_misuse', + 'notify_listeners_in_loop', +}; + void _runGit(Directory dir, List args) { final result = Process.runSync('git', args, workingDirectory: dir.path); if (result.exitCode != 0) { @@ -55,6 +73,29 @@ class $className extends StatelessWidget {} '''); } +StateManagementConfig _stateManagement({ + bool enabled = true, + bool frameworkAutoDetect = true, +}) => + ( + enabled: enabled, + frameworkAutoDetect: frameworkAutoDetect, + confidenceThreshold: RuleConfidence.certain, + ); + +StateRuleConfig _stateRule( + RiskLevel severity, { + bool enabled = true, + List allowlist = const [], + List ignorePaths = const [], +}) => + ( + enabled: enabled, + severity: severity, + allowlist: allowlist, + ignorePaths: ignorePaths, + ); + void main() { group('Static Rules', () { test('scan detects large file', () { @@ -379,6 +420,13 @@ dependencies: expect(json, contains('"issues"')); expect(json, contains('"byDomain"')); expect(json, contains('test_issue')); + final decoded = jsonDecode(json) as Map; + final issue = (decoded['issues'] as List).single as Map; + expect(issue['ruleId'], 'test_issue'); + expect(issue['severity'], 'medium'); + expect(issue['framework'], 'generic'); + expect(issue['confidence'], 'certain'); + expect(issue['evidence'], isEmpty); }); test('stdout report uses scanned file count when provided', () { @@ -725,6 +773,10 @@ class IgnoredWidget extends StatelessWidget {} 'note', ]); final second = results[1] as Map; + final properties = second['properties'] as Map; + expect(properties['framework'], 'generic'); + expect(properties['confidence'], 'certain'); + expect(properties['evidence'], isEmpty); final locations = second['locations'] as List; final physical = (locations.single as Map)['physicalLocation'] as Map; final region = physical['region'] as Map; @@ -1005,9 +1057,556 @@ dependencies: }); }); + group('State Management Rules', () { + final genericFile = p.join(fixturesPath, 'generic_state.dart'); + final riverpodFile = p.join(fixturesPath, 'riverpod_state.dart'); + final blocFile = p.join(fixturesPath, 'bloc_state.dart'); + final providerFile = p.join(fixturesPath, 'provider_state.dart'); + + test('detects build side effects and excludes callback/local collection', + () { + final issues = SideEffectInBuildRule( + _stateRule(RiskLevel.high), + _stateManagement(), + projectPath: Directory.current.path, + ).analyze([genericFile]); + + expect(issues, hasLength(2)); + expect(issues.every((issue) => issue.evidence.isNotEmpty), isTrue); + expect( + issues.expand((issue) => issue.evidence), + isNot(contains(contains('values.add'))), + ); + }); + + test('detects state manager creation in build and excludes callbacks', () { + final issues = StateManagerCreatedInBuildRule( + _stateRule(RiskLevel.high), + _stateManagement(), + projectPath: Directory.current.path, + ).analyze([genericFile]); + + expect(issues, hasLength(2)); + expect( + issues.map((issue) => issue.metadata['type']), + containsAll(['DeviceController', 'DeviceBloc']), + ); + }); + + test('detects exposed mutable state and excludes Flutter State/safe data', + () { + final issues = MutableStateExposedRule( + _stateRule(RiskLevel.medium), + _stateManagement(), + projectPath: Directory.current.path, + ).analyze([genericFile]); + + expect(issues.length, greaterThanOrEqualTo(4)); + expect( + issues.any((issue) => issue.metadata['className'] == 'PageState'), + isFalse, + ); + expect( + issues.any((issue) => issue.metadata['className'] == 'SafeState'), + isFalse, + ); + }); + + test('detects state-layer UI dependencies once per owner', () { + final issues = StateLayerUiDependencyRule( + _stateRule(RiskLevel.high), + _stateManagement(), + projectPath: Directory.current.path, + ).analyze([genericFile]); + + expect( + issues.map((issue) => issue.metadata['className']), + containsAll(['NavigationController', 'ThemeController']), + ); + expect( + issues.where( + (issue) => issue.metadata['className'] == 'NavigationController', + ), + hasLength(1), + ); + }); + + test('detects deterministic state dependency cycle and edge allowlist', () { + final issues = StateDependencyCycleRule( + _stateRule(RiskLevel.high), + _stateManagement(), + projectPath: Directory.current.path, + ).analyze([genericFile]); + + expect(issues, hasLength(1)); + expect(issues.single.message, contains('CycleController')); + expect(issues.single.message, contains('CycleService')); + + final allowed = StateDependencyCycleRule( + _stateRule( + RiskLevel.high, + allowlist: ['CycleService->CycleController'], + ), + _stateManagement(), + projectPath: Directory.current.path, + ).analyze([genericFile]); + expect(allowed, isEmpty); + }); + + test( + 'detects Riverpod read render flow and excludes command/callback reads', + () { + final issues = RiverpodReadUsedForRenderRule( + _stateRule(RiskLevel.medium), + _stateManagement(frameworkAutoDetect: false), + projectPath: Directory.current.path, + ).analyze([riverpodFile]); + + expect(issues, hasLength(2)); + expect( + issues.every( + (issue) => issue.framework == StateManagementFramework.riverpod, + ), + isTrue, + ); + }); + + test('detects Riverpod watch in event callbacks only', () { + final issues = RiverpodWatchInCallbackRule( + _stateRule(RiskLevel.medium), + _stateManagement(frameworkAutoDetect: false), + projectPath: Directory.current.path, + ).analyze([riverpodFile]); + + expect(issues, hasLength(2)); + }); + + test('merges missing Equatable props per class', () { + final issues = BlocEquatablePropsIncompleteRule( + _stateRule(RiskLevel.medium), + _stateManagement(frameworkAutoDetect: false), + projectPath: Directory.current.path, + ).analyze([blocFile]); + + expect(issues, hasLength(2)); + expect( + issues.map((issue) => issue.metadata['className']), + containsAll(['DeviceState', 'ReadingState']), + ); + }); + + test('detects Provider value/create ownership inversions', () { + final issues = ProviderValueLifecycleMisuseRule( + _stateRule(RiskLevel.medium), + _stateManagement(frameworkAutoDetect: false), + projectPath: Directory.current.path, + ).analyze([providerFile]); + + expect(issues, hasLength(2)); + expect( + issues.map((issue) => issue.metadata['ownershipError']), + containsAll(['value_creates', 'create_reuses']), + ); + }); + + test( + 'detects notifyListeners in repeated loops and skips literal singleton', + () { + final issues = NotifyListenersInLoopRule( + _stateRule(RiskLevel.medium), + _stateManagement(frameworkAutoDetect: false), + projectPath: Directory.current.path, + ).analyze([providerFile]); + + expect(issues, hasLength(2)); + }); + + test('global and per-rule switches disable state rules', () { + final globalOff = SideEffectInBuildRule( + _stateRule(RiskLevel.high), + _stateManagement(enabled: false), + projectPath: Directory.current.path, + ).analyze([genericFile]); + final ruleOff = SideEffectInBuildRule( + _stateRule(RiskLevel.high, enabled: false), + _stateManagement(), + projectPath: Directory.current.path, + ).analyze([genericFile]); + + expect(globalOff, isEmpty); + expect(ruleOff, isEmpty); + }); + + test('framework auto-detection can be disabled without weakening AST shape', + () { + final dir = + Directory.systemTemp.createTempSync('flutterguard_framework_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final file = File(p.join(dir.path, 'view.dart'))..writeAsStringSync(''' +class View { + Object build(Object context, dynamic ref) { + return Text(ref.read(deviceProvider)); + } +} +'''); + final imported = File(p.join(dir.path, 'imported_view.dart')) + ..writeAsStringSync(''' +import 'package:flutter_riverpod/flutter_riverpod.dart'; +class ImportedView { + Object build(Object context, dynamic ref) { + return Text(ref.read(deviceProvider)); + } +} +'''); + + final detected = RiverpodReadUsedForRenderRule( + _stateRule(RiskLevel.medium), + _stateManagement(), + projectPath: dir.path, + ).analyze([file.path]); + final forced = RiverpodReadUsedForRenderRule( + _stateRule(RiskLevel.medium), + _stateManagement(frameworkAutoDetect: false), + projectPath: dir.path, + ).analyze([file.path]); + final autoDetected = RiverpodReadUsedForRenderRule( + _stateRule(RiskLevel.medium), + _stateManagement(), + projectPath: dir.path, + ).analyze([imported.path]); + + expect(detected, isEmpty); + expect(forced, hasLength(1)); + expect(autoDetected, hasLength(1)); + }); + + test('severity override updates priority and JSON compatibility aliases', + () { + final issue = SideEffectInBuildRule( + _stateRule(RiskLevel.low), + _stateManagement(), + projectPath: Directory.current.path, + ).analyze([genericFile]).first; + final json = issue.toJson(); + + expect(issue.level, RiskLevel.low); + expect(issue.priority, Priority.p2); + expect(json['ruleId'], issue.id); + expect(json['severity'], 'low'); + expect(json['framework'], 'generic'); + expect(json['confidence'], 'certain'); + }); + + test('config validates new enums/types and prints complete defaults', () { + final dir = + Directory.systemTemp.createTempSync('flutterguard_state_cfg_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final valid = File(p.join(dir.path, 'valid.yaml'))..writeAsStringSync(''' +state_management: + enabled: true + framework_auto_detect: false + confidence_threshold: probable +rules: + side_effect_in_build: + severity: low + allowlist: [refresh] + ignore_paths: [lib/generated/**] +'''); + final config = ScanConfig.fromFile(valid.path); + expect(config.stateManagement.frameworkAutoDetect, isFalse); + expect( + config.stateManagement.confidenceThreshold, RuleConfidence.probable); + expect(config.rules.sideEffectInBuild.severity, RiskLevel.low); + expect(ConfigTools.effectiveYaml(config), contains('state_management:')); + + for (final invalidBody in const [ + 'state_management:\n confidence_threshold: maybe\n', + 'rules:\n side_effect_in_build:\n severity: critical\n', + 'rules:\n side_effect_in_build:\n allowlist: value\n', + ]) { + final invalid = File(p.join(dir.path, 'invalid.yaml')) + ..writeAsStringSync(invalidBody); + expect(() => ScanConfig.fromFile(invalid.path), throwsFormatException); + } + }); + + test('state findings reuse line suppression and keep raw issues', () { + final dir = + Directory.systemTemp.createTempSync('flutterguard_state_suppress_'); + addTearDown(() => dir.deleteSync(recursive: true)); + Directory(p.join(dir.path, 'lib')).createSync(); + File(p.join(dir.path, 'pubspec.yaml')).writeAsStringSync('name: app\n'); + File(p.join(dir.path, 'flutterguard.yaml')).writeAsStringSync(''' +include: [lib/**] +state_management: + framework_auto_detect: false +'''); + File(p.join(dir.path, 'lib', 'view.dart')).writeAsStringSync(''' +class View { + // flutterguard: ignore side_effect_in_build + Object build(Object context) { + notifyListeners(); + return Object(); + } +} +'''); + + final result = FlutterGuardScanner.scan(projectPath: dir.path); + expect( + result.rawIssues.where((issue) => issue.id == 'side_effect_in_build'), + hasLength(1), + ); + expect( + result.issues.where((issue) => issue.id == 'side_effect_in_build'), + isEmpty, + ); + expect(result.suppressedCount, greaterThanOrEqualTo(1)); + }); + + test('changed mode builds the full state graph and anchors to target file', + () { + final target = p.join(fixturesPath, 'generic_state.dart'); + final issues = StateDependencyCycleRule( + _stateRule(RiskLevel.high), + _stateManagement(), + projectPath: Directory.current.path, + ).analyze( + [target], + targetFiles: [target], + changedOnly: true, + ); + + expect(issues, hasLength(1)); + expect(issues.single.file, target); + }); + + test('every state rule honors its independent enabled switch', () { + final highOff = _stateRule(RiskLevel.high, enabled: false); + final mediumOff = _stateRule(RiskLevel.medium, enabled: false); + final analyzers = Function()>{ + 'side_effect_in_build': () => SideEffectInBuildRule( + highOff, + _stateManagement(), + projectPath: Directory.current.path, + ).analyze([genericFile]), + 'state_manager_created_in_build': () => StateManagerCreatedInBuildRule( + highOff, + _stateManagement(), + projectPath: Directory.current.path, + ).analyze([genericFile]), + 'mutable_state_exposed': () => MutableStateExposedRule( + mediumOff, + _stateManagement(), + projectPath: Directory.current.path, + ).analyze([genericFile]), + 'state_layer_ui_dependency': () => StateLayerUiDependencyRule( + highOff, + _stateManagement(), + projectPath: Directory.current.path, + ).analyze([genericFile]), + 'state_dependency_cycle': () => StateDependencyCycleRule( + highOff, + _stateManagement(), + projectPath: Directory.current.path, + ).analyze([genericFile]), + 'riverpod_read_used_for_render': () => RiverpodReadUsedForRenderRule( + mediumOff, + _stateManagement(), + projectPath: Directory.current.path, + ).analyze([riverpodFile]), + 'riverpod_watch_in_callback': () => RiverpodWatchInCallbackRule( + mediumOff, + _stateManagement(), + projectPath: Directory.current.path, + ).analyze([riverpodFile]), + 'bloc_equatable_props_incomplete': () => + BlocEquatablePropsIncompleteRule( + mediumOff, + _stateManagement(), + projectPath: Directory.current.path, + ).analyze([blocFile]), + 'provider_value_lifecycle_misuse': () => + ProviderValueLifecycleMisuseRule( + mediumOff, + _stateManagement(), + projectPath: Directory.current.path, + ).analyze([providerFile]), + 'notify_listeners_in_loop': () => NotifyListenersInLoopRule( + mediumOff, + _stateManagement(), + projectPath: Directory.current.path, + ).analyze([providerFile]), + }; + + expect(analyzers.keys.toSet(), _stateRuleIds); + for (final entry in analyzers.entries) { + expect(entry.value(), isEmpty, reason: entry.key); + } + }); + + test('all state rules reuse suppression and baseline pipelines', () { + final dir = + Directory.systemTemp.createTempSync('flutterguard_state_all_'); + addTearDown(() => dir.deleteSync(recursive: true)); + Directory(p.join(dir.path, 'lib')).createSync(); + File(p.join(dir.path, 'pubspec.yaml')).writeAsStringSync('name: app\n'); + File(p.join(dir.path, 'flutterguard.yaml')).writeAsStringSync(''' +include: [lib/**] +state_management: + framework_auto_detect: false +'''); + File(p.join(dir.path, 'lib', 'state_suppression.dart')).writeAsStringSync( + File(p.join(fixturesPath, 'state_suppression.dart')).readAsStringSync(), + ); + + final suppressed = FlutterGuardScanner.scan(projectPath: dir.path); + final rawStateIds = suppressed.rawIssues + .where((issue) => _stateRuleIds.contains(issue.id)) + .map((issue) => issue.id) + .toSet(); + final visibleStateIds = suppressed.issues + .where((issue) => _stateRuleIds.contains(issue.id)) + .map((issue) => issue.id) + .toSet(); + expect(rawStateIds, _stateRuleIds); + expect(visibleStateIds, isEmpty); + + final unsuppressed = FlutterGuardScanner.scan( + projectPath: dir.path, + applySuppression: false, + ); + final baselinePath = p.join(dir.path, 'baseline.json'); + File(baselinePath).writeAsStringSync(Baseline.encode( + projectPath: unsuppressed.projectPath, + issues: unsuppressed.rawIssues, + )); + final baselined = FlutterGuardScanner.scan( + projectPath: dir.path, + applySuppression: false, + baselinePath: baselinePath, + ); + expect( + baselined.issues.where((issue) => _stateRuleIds.contains(issue.id)), + isEmpty, + ); + expect( + baselined.suppressedByBaselineCount, + unsuppressed.rawIssues.length, + ); + }); + + test('duplicate type names do not create a false state cycle', () { + final dir = Directory.systemTemp.createTempSync('flutterguard_names_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final a = Directory(p.join(dir.path, 'a'))..createSync(); + final b = Directory(p.join(dir.path, 'b'))..createSync(); + final controller = File(p.join(a.path, 'controller.dart')) + ..writeAsStringSync(''' +import 'service.dart'; +class DeviceController { final DuplicateService service; } +'''); + final serviceA = File(p.join(a.path, 'service.dart')) + ..writeAsStringSync('class DuplicateService {}\n'); + final serviceB = File(p.join(b.path, 'service.dart')) + ..writeAsStringSync(''' +import '../a/controller.dart'; +class DuplicateService { final DeviceController controller; } +'''); + + final issues = StateDependencyCycleRule( + _stateRule(RiskLevel.high), + _stateManagement(), + projectPath: dir.path, + ).analyze([controller.path, serviceA.path, serviceB.path]); + + expect(issues, isEmpty); + }); + + test('real cycles disambiguate duplicate type names in evidence', () { + final dir = Directory.systemTemp.createTempSync('flutterguard_names_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final a = Directory(p.join(dir.path, 'a'))..createSync(); + final b = Directory(p.join(dir.path, 'b'))..createSync(); + final controller = File(p.join(a.path, 'controller.dart')) + ..writeAsStringSync(''' +import 'service.dart'; +class DeviceController { final DuplicateService service; } +'''); + final serviceA = File(p.join(a.path, 'service.dart')) + ..writeAsStringSync(''' +import 'controller.dart'; +class DuplicateService { final DeviceController controller; } +'''); + final serviceB = File(p.join(b.path, 'service.dart')) + ..writeAsStringSync('class DuplicateService {}\n'); + + final issues = StateDependencyCycleRule( + _stateRule(RiskLevel.high), + _stateManagement(), + projectPath: dir.path, + ).analyze([controller.path, serviceA.path, serviceB.path]); + + expect(issues, hasLength(1)); + expect( + issues.single.message, contains('a/service.dart::DuplicateService')); + }); + + test('scanner changed-only uses unchanged files in the state graph', () { + final dir = + Directory.systemTemp.createTempSync('flutterguard_changed_state_'); + addTearDown(() => dir.deleteSync(recursive: true)); + Directory(p.join(dir.path, 'lib')).createSync(); + File(p.join(dir.path, 'flutterguard.yaml')).writeAsStringSync(''' +include: [lib/**] +'''); + File(p.join(dir.path, 'lib', 'controller.dart')).writeAsStringSync(''' +import 'service.dart'; +class DeviceController { final DeviceService service; } +'''); + final service = File(p.join(dir.path, 'lib', 'service.dart')) + ..writeAsStringSync(''' +import 'controller.dart'; +class DeviceService { final DeviceController controller; } +'''); + _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']); + service.writeAsStringSync('${service.readAsStringSync()}\n// changed\n'); + + final result = FlutterGuardScanner.scan( + projectPath: dir.path, + changedOnly: true, + base: 'HEAD', + ); + final cycles = result.issues + .where((issue) => issue.id == 'state_dependency_cycle') + .toList(); + + expect(result.files, [service.path]); + expect(cycles, hasLength(1)); + expect(cycles.single.file, service.path); + }); + }); + group('Rules Registry', () { - test('registry_contains_all_13_rules', () { - expect(RuleRegistry.all(), hasLength(13)); + test('registry_contains_all_23_rules', () { + expect(RuleRegistry.all(), hasLength(23)); + for (final id in const [ + 'side_effect_in_build', + 'state_manager_created_in_build', + 'mutable_state_exposed', + 'state_layer_ui_dependency', + 'state_dependency_cycle', + 'riverpod_read_used_for_render', + 'riverpod_watch_in_callback', + 'bloc_equatable_props_incomplete', + 'provider_value_lifecycle_misuse', + 'notify_listeners_in_loop', + ]) { + expect(RuleRegistry.find(id), isNotNull, reason: id); + } }); test('registry_find_returns_correct_meta', () { @@ -1056,6 +1655,27 @@ dependencies: ), throwsA(isA()), ); + + final dir = Directory.systemTemp.createTempSync('flutterguard_profiles_'); + addTearDown(() => dir.deleteSync(recursive: true)); + for (final profile in ConfigTools.profiles) { + final file = File(p.join(dir.path, '$profile.yaml')) + ..writeAsStringSync(ConfigTools.initTemplate( + withArchitecture: false, + profile: profile, + )); + final parsed = ScanConfig.fromFile(file.path); + expect(parsed.rules.sideEffectInBuild.severity, RiskLevel.high); + } + final performance = ScanConfig.fromFile( + p.join(dir.path, 'performance-only.yaml'), + ); + expect(performance.rules.sideEffectInBuild.enabled, isTrue); + expect(performance.rules.mutableStateExposed.enabled, isFalse); + final architecture = ScanConfig.fromFile( + p.join(dir.path, 'architecture-only.yaml'), + ); + expect(architecture.rules.sideEffectInBuild.enabled, isFalse); }); test('effective config print includes merged defaults', () { From f614760f24639d761df63468dd9c23fd4025e1ce Mon Sep 17 00:00:00 2001 From: lizy Date: Thu, 16 Jul 2026 21:16:36 +0800 Subject: [PATCH 09/16] docs: update docs for v0.5.x state management rules and 23 rule IDs --- AGENTS.md | 15 +++++-- CHANGELOG.md | 10 ++++- README.md | 39 ++++++++++++++++--- README.zh.md | 38 +++++++++++++++--- packages/flutterguard_cli/AGENTS.md | 9 +++-- packages/flutterguard_cli/CHANGELOG.md | 9 +++++ packages/flutterguard_cli/README.md | 24 +++++++++++- packages/flutterguard_cli/lib/src/AGENTS.md | 4 +- .../flutterguard_cli/lib/src/rules/AGENTS.md | 6 +++ packages/flutterguard_cli/test/AGENTS.md | 4 +- .../flutterguard_cli/test/fixtures/AGENTS.md | 7 +++- 11 files changed, 136 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5ade6d7..27b5a66 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 (61 tests) | +| `dart run melos run test:cli` | CLI tests only (83 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 | @@ -36,16 +36,17 @@ IoT/smart home Flutter project static analysis CLI plugin. NOT an observability 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): +Wired rules (21 rule classes, 23 rule IDs): - Standards: LargeUnitsRule (3 IDs), MissingConstConstructorRule, PubspecSecurityRule - Performance: LifecycleResourceRule - Architecture: LayerViolationRule, ModuleViolationRule, CircularDependencyRule - IoT: DeviceLifecycleRule, MqttConnectionRule, BleScanningRule, IotSecurityRule +- State management: 5 generic, 2 Riverpod, 1 Bloc, and 2 Provider rules ## Source Layout ``` packages/flutterguard_cli/lib/src/ - config_loader.dart # YAML → ScanConfig typedefs (11 rule configs + architecture) + config_loader.dart # YAML → typed config (20 rule configs + state management + 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 @@ -62,7 +63,13 @@ packages/flutterguard_cli/lib/src/ 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 + registry.dart # RuleRegistry for all 23 rule IDs + state_management_utils.dart # Shared AST/import/owner/build/callback helpers + generic_state_management.dart # Generic build/mutability/UI rules + state_dependency_cycle.dart # Project-wide state dependency SCC rule + riverpod_state_management.dart # Riverpod read/watch rules + bloc_state_management.dart # Equatable props rule + provider_state_management.dart # Provider ownership/notify rules large_units.dart # large_file, large_class, large_build_method lifecycle_resource.dart # lifecycle_resource_not_disposed layer_violation.dart # layer_violation (architecture layer breaches) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87b9687..ac3dcef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,13 @@ # Changelog -## Unreleased - +## 0.6.0 (2026-07-16) + +- **cli:** Added 10 AST-first state-management maintainability rules for generic Flutter, Riverpod, Bloc, and Provider projects (23 total rule IDs). +- **cli:** Added state-rule severity/allowlist/ignore-path controls, framework auto-detection, confidence/evidence output, and changed-only state-cycle analysis. +- **reporting:** JSON and SARIF now include framework, confidence, and evidence while preserving existing fields and baseline fingerprints. +- **fix:** Qualified state dependency graph nodes by file and resolved duplicate names through project imports. +- **compatibility:** Aligned the published package SDK constraint and install examples with the Dart 3.11.5 release workflow. +- **test:** Expanded the CLI suite to 83 tests with generic/Riverpod/Bloc/Provider fixtures, version synchronization, and release-hardening coverage. - **fix:** Prevent successful no-op full scans and preserve setup-error exit codes in local CI scripts. - **fix:** Anchor configuration policy to the target project and require explicitly selected config files. - **ci:** Run analyze, tests, and the demo scan against the current checkout on the OS matrix. diff --git a/README.md b/README.md index e6f3d3f..a77e4cf 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ FlutterGuard scans Flutter/Dart source code and reports architecture boundary br ## Requirements -- Dart SDK 3.3.0 or newer +- Dart SDK 3.11.5 or newer - `melos` for workspace bootstrap when running from source - Supported OS: macOS, Windows, Linux @@ -288,8 +288,25 @@ rules: requireTls: true pubspec_security: enabled: true + side_effect_in_build: + enabled: true + severity: high + allowlist: [] + ignore_paths: [] + riverpod_read_used_for_render: + enabled: true + severity: medium + +state_management: + enabled: true + framework_auto_detect: true + confidence_threshold: certain ``` +All ten state-management rules accept `enabled`, `severity`, `allowlist`, and +project-relative POSIX `ignore_paths`. The abbreviated example above shows the +shared shape; `flutterguard config print` prints every rule and effective value. + ### Full config (with architecture enforcement) ```yaml @@ -348,8 +365,18 @@ architecture: | `iot_security` | HIGH | architecture | P0 | Hardcoded credentials, cleartext MQTT/HTTP, insecure BLE | `rules.iot_security.requireTls` | | `ble_scanning` | MEDIUM | architecture | P1 | BLE startScan/stopScan pairing, scan timeout | `rules.ble_scanning.maxScanDurationMs` | | `pubspec_security` | MEDIUM | standards | P2 | Unbounded deps, deprecated packages, outdated IoT dependencies | — | - -* Requires explicit YAML configuration to activate. +| `side_effect_in_build` | HIGH | performance | P0 | State/resource side effects during build | — | +| `state_manager_created_in_build` | HIGH | performance | P0 | Controllers/Blocs/Notifiers created during build | — | +| `mutable_state_exposed` | MEDIUM | architecture | P1 | Public mutable state and in-place state collection mutation | — | +| `state_layer_ui_dependency` | HIGH | architecture | P0 | State owners depending on BuildContext, Widget, navigation or theme APIs | — | +| `state_dependency_cycle` | HIGH | architecture | P0 | Cycles among providers, state owners and reachable services | — | +| `riverpod_read_used_for_render` | MEDIUM | performance | P1 | `ref.read` values flowing into render output | Riverpod import * | +| `riverpod_watch_in_callback` | MEDIUM | performance | P1 | `ref.watch` inside event/async callbacks | Riverpod import * | +| `bloc_equatable_props_incomplete` | MEDIUM | standards | P1 | Equatable final fields missing from `props` | Bloc + Equatable imports * | +| `provider_value_lifecycle_misuse` | MEDIUM | performance | P1 | Reversed Provider `.value`/`create` ownership | Provider/Bloc import * | +| `notify_listeners_in_loop` | MEDIUM | performance | P1 | `notifyListeners()` inside repeated loops | Provider/Bloc import * | + +* Architecture entries require explicit YAML. Framework imports are auto-detected by default; set `state_management.framework_auto_detect: false` to use AST-only matching. --- @@ -444,7 +471,7 @@ jobs: - uses: actions/checkout@v4 - uses: dart-lang/setup-dart@v1 with: - sdk: 3.3.0 + sdk: 3.11.5 - name: Install FlutterGuard run: dart pub global activate flutterguard_cli - name: Scan @@ -468,7 +495,7 @@ jobs: - uses: actions/checkout@v4 - uses: dart-lang/setup-dart@v1 with: - sdk: 3.3.0 + sdk: 3.11.5 - run: dart pub global activate flutterguard_cli - run: flutterguard scan . --format sarif --baseline .flutterguard/baseline.json - uses: github/codeql-action/upload-sarif@v3 @@ -480,7 +507,7 @@ jobs: ```yaml flutterguard: - image: dart:3.3.0 + image: dart:3.11.5 script: - dart pub global activate flutterguard_cli - flutterguard scan . --format json --fail-on high --min-score 80 diff --git a/README.zh.md b/README.zh.md index 40e9f22..3b20a1d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -26,7 +26,7 @@ FlutterGuard 扫描 Flutter/Dart 源码,报告架构边界违规、生命周 ## 环境要求 -- Dart SDK 3.3.0 或更高版本 +- Dart SDK 3.11.5 或更高版本 - 从源码开发时需要 `melos` - 支持操作系统: macOS、Windows、Linux @@ -286,8 +286,24 @@ rules: requireTls: true pubspec_security: enabled: true + side_effect_in_build: + enabled: true + severity: high + allowlist: [] + ignore_paths: [] + riverpod_read_used_for_render: + enabled: true + severity: medium + +state_management: + enabled: true + framework_auto_detect: true + confidence_threshold: certain ``` +10 条状态管理规则均支持 `enabled`、`severity`、`allowlist` 和项目相对 POSIX +`ignore_paths`。以上只展示共享结构;`flutterguard config print` 会输出所有规则的完整生效配置。 + ### 完整配置(含架构约束) ```yaml @@ -346,8 +362,18 @@ architecture: | `iot_security` | HIGH | architecture | P0 | 硬编码凭证、明文 MQTT/HTTP、不安全 BLE | `rules.iot_security.requireTls` | | `ble_scanning` | MEDIUM | architecture | P1 | BLE startScan/stopScan 配对、扫描超时 | `rules.ble_scanning.maxScanDurationMs` | | `pubspec_security` | MEDIUM | standards | P2 | 无界依赖、已废弃包、过旧 IoT 依赖版本 | — | - -* 需在 YAML 配置中显式声明才能激活。 +| `side_effect_in_build` | HIGH | performance | P0 | build 阶段执行状态或资源副作用 | — | +| `state_manager_created_in_build` | HIGH | performance | P0 | build 中创建 Controller/Bloc/Notifier | — | +| `mutable_state_exposed` | MEDIUM | architecture | P1 | 公开可变状态或原地修改 state 集合 | — | +| `state_layer_ui_dependency` | HIGH | architecture | P0 | 状态 owner 依赖 BuildContext、Widget、导航或主题 API | — | +| `state_dependency_cycle` | HIGH | architecture | P0 | Provider、状态 owner 与可达 service 之间的依赖环 | — | +| `riverpod_read_used_for_render` | MEDIUM | performance | P1 | `ref.read` 的值进入渲染输出 | Riverpod import * | +| `riverpod_watch_in_callback` | MEDIUM | performance | P1 | 事件或异步回调内调用 `ref.watch` | Riverpod import * | +| `bloc_equatable_props_incomplete` | MEDIUM | standards | P1 | Equatable final 字段遗漏于 `props` | Bloc + Equatable import * | +| `provider_value_lifecycle_misuse` | MEDIUM | performance | P1 | Provider `.value`/`create` 所有权模式反用 | Provider/Bloc import * | +| `notify_listeners_in_loop` | MEDIUM | performance | P1 | 重复循环内调用 `notifyListeners()` | Provider/Bloc import * | + +* 架构项需显式 YAML;框架 import 默认自动识别,可设置 `state_management.framework_auto_detect: false` 改用纯 AST 形态匹配。 --- @@ -442,7 +468,7 @@ jobs: - uses: actions/checkout@v4 - uses: dart-lang/setup-dart@v1 with: - sdk: 3.3.0 + sdk: 3.11.5 - name: Install FlutterGuard run: dart pub global activate flutterguard_cli - name: Scan @@ -466,7 +492,7 @@ jobs: - uses: actions/checkout@v4 - uses: dart-lang/setup-dart@v1 with: - sdk: 3.3.0 + sdk: 3.11.5 - run: dart pub global activate flutterguard_cli - run: flutterguard scan . --format sarif --baseline .flutterguard/baseline.json - uses: github/codeql-action/upload-sarif@v3 @@ -478,7 +504,7 @@ jobs: ```yaml flutterguard: - image: dart:3.3.0 + image: dart:3.11.5 script: - dart pub global activate flutterguard_cli - flutterguard scan . --format json --fail-on high --min-score 80 diff --git a/packages/flutterguard_cli/AGENTS.md b/packages/flutterguard_cli/AGENTS.md index 238cad4..60591d5 100644 --- a/packages/flutterguard_cli/AGENTS.md +++ b/packages/flutterguard_cli/AGENTS.md @@ -20,7 +20,7 @@ Primary CLI tool for IoT Flutter static architecture scanning and CI gating. | `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/config_loader.dart` | YAML → ScanConfig typed parsing (20 rule configs + state management + architecture) | | `src/file_collector.dart` | Glob-based .dart file discovery | | `src/project_resolver.dart` | Project auto-discovery (walk-up flutterguard.yaml / pubspec.yaml / lib/) | | `src/import_utils.dart` | Shared import resolution utility | @@ -40,16 +40,17 @@ Primary CLI tool for IoT Flutter static architecture scanning and CI gating. | `src/rules/ble_scanning.dart` | BLE startScan/stopScan, connect/disconnect, scan timeout | | `src/rules/pubspec_security.dart` | Unbounded deps, deprecated packages, outdated IoT dependencies | -## Wired Rules (11 rule classes, 13 rule IDs) +## Wired Rules (21 rule classes, 23 rule IDs) Standards: LargeUnitsRule (3 IDs), MissingConstConstructorRule, PubspecSecurityRule Performance: LifecycleResourceRule Architecture: LayerViolationRule, ModuleViolationRule, CircularDependencyRule IoT: DeviceLifecycleRule, MqttConnectionRule, BleScanningRule, IotSecurityRule +State management: 5 generic, 2 Riverpod, 1 Bloc, and 2 Provider rules ## Test - command: `melos run test:cli` -- test files: `test/scanner_test.dart` (57 tests) and `test/cli_test.dart` (4 process-level tests) -- fixtures: `test/fixtures/` (16 functional fixture files) +- test files: `test/scanner_test.dart` (78 tests) and `test/cli_test.dart` (5 process-level tests) +- fixtures: `test/fixtures/` (21 functional fixture files) - every new rule needs: spec entry → config typedef → class → fixture → test → wire in rules/catalog.dart ## Current Toolchain Flow diff --git a/packages/flutterguard_cli/CHANGELOG.md b/packages/flutterguard_cli/CHANGELOG.md index ab05a9f..74726b3 100644 --- a/packages/flutterguard_cli/CHANGELOG.md +++ b/packages/flutterguard_cli/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.6.0 (2026-07-16) + +- Added 10 AST-first state-management maintainability rules, bringing the registry to 23 rule IDs. +- Added shared state configuration, severity/priority overrides, framework auto-detection, allowlists, ignore paths, confidence, and evidence. +- Extended table, JSON, rules/explain, and SARIF output without changing baseline fingerprints or existing report fields. +- State dependency cycles now use file-qualified nodes and import-aware resolution, preventing false cycles when projects contain duplicate type names. +- Aligned the published package SDK constraint and install examples with the Dart 3.11.5 release workflow. +- Added generic, Riverpod, Bloc, and Provider fixtures; the CLI suite now contains 83 tests, including version synchronization, per-rule enablement, suppression, baseline, duplicate-name, and changed-only graph coverage. + ## 0.5.0 (2026-07-12) ### Architecture Refactor diff --git a/packages/flutterguard_cli/README.md b/packages/flutterguard_cli/README.md index b5f2eee..320c340 100644 --- a/packages/flutterguard_cli/README.md +++ b/packages/flutterguard_cli/README.md @@ -63,6 +63,16 @@ run the current files instead of an older global executable: | `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 | +| `side_effect_in_build` | HIGH | Side effects during Widget/Consumer build | +| `state_manager_created_in_build` | HIGH | State managers/controllers created during build | +| `mutable_state_exposed` | MEDIUM | Public mutable state and in-place state mutation | +| `state_layer_ui_dependency` | HIGH | State owners coupled to Flutter UI APIs | +| `state_dependency_cycle` | HIGH | Provider/state/service dependency cycles | +| `riverpod_read_used_for_render` | MEDIUM | Riverpod `ref.read` used for rendering | +| `riverpod_watch_in_callback` | MEDIUM | Riverpod `ref.watch` in callbacks | +| `bloc_equatable_props_incomplete` | MEDIUM | Equatable fields missing from `props` | +| `provider_value_lifecycle_misuse` | MEDIUM | Provider `.value`/`create` ownership misuse | +| `notify_listeners_in_loop` | MEDIUM | Repeated `notifyListeners()` in loops | ## Configuration @@ -80,6 +90,16 @@ rules: maxLines: 500 lifecycle_resource: enabled: true + side_effect_in_build: + enabled: true + severity: high + allowlist: [] + ignore_paths: [] + +state_management: + enabled: true + framework_auto_detect: true + confidence_threshold: certain architecture: layers: @@ -155,7 +175,7 @@ flutterguard scan . --baseline .flutterguard/baseline.json --format json --fail- # GitHub Actions - uses: dart-lang/setup-dart@v1 with: - sdk: 3.3.0 + sdk: 3.11.5 - run: dart pub global activate flutterguard_cli - run: flutterguard scan . --format json --baseline .flutterguard/baseline.json --fail-on high --min-score 80 ``` @@ -179,7 +199,7 @@ Known false positives can be suppressed on the same line or the next line: ## Requirements -- Dart SDK >=3.3.0 +- Dart SDK >=3.11.5 - Supported OS: macOS, Windows, Linux ## Further Reading diff --git a/packages/flutterguard_cli/lib/src/AGENTS.md b/packages/flutterguard_cli/lib/src/AGENTS.md index b8c4e9e..23277ab 100644 --- a/packages/flutterguard_cli/lib/src/AGENTS.md +++ b/packages/flutterguard_cli/lib/src/AGENTS.md @@ -10,7 +10,7 @@ This directory contains reusable implementation for the CLI. - `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). +- `config_loader.dart`: YAML parsing into typed record configs (20 rule configs + state management + architecture). - `file_collector.dart`: include/exclude glob file discovery. - `project_resolver.dart`: project auto-discovery (walk-up flutterguard.yaml / pubspec.yaml / lib/). - `report_generator.dart`: table and JSON output with optional `--no-color` support. @@ -18,7 +18,7 @@ This directory contains reusable implementation for the CLI. - `path_utils.dart`: cross-platform path/glob helpers. - `import_utils.dart`: Dart import resolution against collected files. - `source_utils.dart`: analyzer source location helpers. -- `rules/`: rule implementations only (11 rule classes, 13 rule IDs). +- `rules/`: rule implementations only (21 rule classes, 23 rule IDs). ## Design Rules - Keep `bin/` thin; reusable logic belongs here. diff --git a/packages/flutterguard_cli/lib/src/rules/AGENTS.md b/packages/flutterguard_cli/lib/src/rules/AGENTS.md index 96492b8..40fa7c1 100644 --- a/packages/flutterguard_cli/lib/src/rules/AGENTS.md +++ b/packages/flutterguard_cli/lib/src/rules/AGENTS.md @@ -15,6 +15,12 @@ Each file implements one rule family and returns `List`. - `ble_scanning.dart`: BLE startScan/stopScan pairing, connect/disconnect, scan timeout configuration - `iot_security.dart`: hardcoded credentials, cleartext MQTT (port 1883), cleartext HTTP, insecure BLE - `pubspec_security.dart`: unbounded deps, deprecated packages (flutter_blue→flutter_blue_plus), outdated IoT dependencies +- `generic_state_management.dart`: build side effects/creation, mutable state, and state-to-UI dependencies +- `state_dependency_cycle.dart`: project-wide provider/state/service SCC detection +- `riverpod_state_management.dart`: render-time read and callback watch checks +- `bloc_state_management.dart`: Equatable props completeness +- `provider_state_management.dart`: Provider ownership and loop notification checks +- `state_management_utils.dart`: shared AST/import/owner/build/callback helpers ## Rule Contract - Constructor receives typed config or explicit parameters. diff --git a/packages/flutterguard_cli/test/AGENTS.md b/packages/flutterguard_cli/test/AGENTS.md index fd15f84..2ef2f51 100644 --- a/packages/flutterguard_cli/test/AGENTS.md +++ b/packages/flutterguard_cli/test/AGENTS.md @@ -4,8 +4,8 @@ Tests verify rule behavior, scanner orchestration, report generation, and cross-platform path handling. ## Test Files -- `scanner_test.dart`: reusable scanner/rule integration suite (57 tests, 7 groups). -- `cli_test.dart`: process-level CLI exit and report behavior (4 tests). +- `scanner_test.dart`: reusable scanner/rule integration suite (78 tests, 8 groups). +- `cli_test.dart`: process-level CLI exit and report behavior (5 tests). ## Test Groups | Group | Tests | Coverage | diff --git a/packages/flutterguard_cli/test/fixtures/AGENTS.md b/packages/flutterguard_cli/test/fixtures/AGENTS.md index 1bc56f2..1b1df79 100644 --- a/packages/flutterguard_cli/test/fixtures/AGENTS.md +++ b/packages/flutterguard_cli/test/fixtures/AGENTS.md @@ -1,7 +1,7 @@ # Fixture Layer ## Responsibility -This directory contains intentionally imperfect Dart/YAML files used by CLI rule tests (17 fixture files). +This directory contains intentionally imperfect Dart/YAML files used by CLI rule tests (21 fixture files). ## Fixture Inventory | File | Rule | @@ -18,6 +18,11 @@ This directory contains intentionally imperfect Dart/YAML files used by CLI rule | `mqtt_connection_issue.dart` | mqtt_connection | | `ble_scanning_issue.dart` | ble_scanning | | `architecture_config.yaml` / `architecture_disabled.yaml` | architecture config parsing | +| `generic_state.dart` | Generic build, mutability, UI dependency, and state-cycle rules | +| `riverpod_state.dart` | Riverpod read/render and watch/callback rules | +| `bloc_state.dart` | Bloc Equatable props completeness | +| `provider_state.dart` | Provider ownership and loop notification rules | +| `state_suppression.dart` | All state rules through suppression and baseline pipelines | ## Rules - Fixtures may intentionally violate style or architecture rules. From 6cf9b2a3168cb04308796023b9125025d197911f Mon Sep 17 00:00:00 2001 From: lizy Date: Thu, 16 Jul 2026 21:17:41 +0800 Subject: [PATCH 10/16] feat:update state test --- .gitignore | 2 +- CONFIGURATION_STRATEGY.md | 59 ++ do | 75 ++ docs/ARCHITECTURE.md | 152 ++++ docs/FLUTTERGUARD_SPEC.md | 732 +++++++++++++++ docs/USAGE.md | 855 ++++++++++++++++++ docs/WINDOWS_ASSESSMENT.md | 241 +++++ .../flutterguard_cli/bin/flutterguard.dart | 2 +- packages/flutterguard_cli/pubspec.yaml | 4 +- packages/flutterguard_cli/test/cli_test.dart | 21 + .../test/fixtures/state_suppression.dart | 66 ++ 11 files changed, 2205 insertions(+), 4 deletions(-) create mode 100644 do create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/FLUTTERGUARD_SPEC.md create mode 100644 docs/USAGE.md create mode 100644 docs/WINDOWS_ASSESSMENT.md create mode 100644 packages/flutterguard_cli/test/fixtures/state_suppression.dart diff --git a/.gitignore b/.gitignore index 2e12462..e379085 100644 --- a/.gitignore +++ b/.gitignore @@ -38,5 +38,5 @@ doc/api/ .coverage # Project evolution spec -docs/ +# Keep docs tracked: README and the published package link to these files. CONTEXT.md diff --git a/CONFIGURATION_STRATEGY.md b/CONFIGURATION_STRATEGY.md index 6ebbb76..027ad4e 100644 --- a/CONFIGURATION_STRATEGY.md +++ b/CONFIGURATION_STRATEGY.md @@ -79,10 +79,69 @@ rules: requireTls: true pubspec_security: enabled: true + side_effect_in_build: + enabled: true + severity: high + allowlist: [] + ignore_paths: [] + state_manager_created_in_build: + enabled: true + severity: high + allowlist: [] + ignore_paths: [] + mutable_state_exposed: + enabled: true + severity: medium + allowlist: [] + ignore_paths: [] + state_layer_ui_dependency: + enabled: true + severity: high + allowlist: [] + ignore_paths: [] + state_dependency_cycle: + enabled: true + severity: high + allowlist: [] + ignore_paths: [] + riverpod_read_used_for_render: + enabled: true + severity: medium + allowlist: [] + ignore_paths: [] + riverpod_watch_in_callback: + enabled: true + severity: medium + allowlist: [] + ignore_paths: [] + bloc_equatable_props_incomplete: + enabled: true + severity: medium + allowlist: [] + ignore_paths: [] + provider_value_lifecycle_misuse: + enabled: true + severity: medium + allowlist: [] + ignore_paths: [] + notify_listeners_in_loop: + enabled: true + severity: medium + allowlist: [] + ignore_paths: [] + +state_management: + enabled: true + framework_auto_detect: true + confidence_threshold: certain ``` Use this level when teams want stable thresholds or custom excludes. +The top-level state-management switch controls all ten state rules. Per-rule +`enabled`, `severity`, `allowlist`, and project-relative `ignore_paths` settings +can then narrow the policy without changing suppression or baseline behavior. + Create it with: ```bash diff --git a/do b/do new file mode 100644 index 0000000..c4899fa --- /dev/null +++ b/do @@ -0,0 +1,75 @@ +总体判断 + 当前 FlutterGuard 已可用于真实项目的本地检查、辅助代码评审和选择性 CI 门禁,尤其适合单包、标准 lib/** 布局的 Flutter/IoT 项目。 + + 但规则成熟度差异较大。它本质上是“AST 语法扫描 + 文本启发式”,不是类型分析器、依赖漏洞扫描器或安全审计工具。 + + 主要检测场景 + + 场景 对应规则 当前可用性 + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 大文件、大类、过长 build() large_file、large_class、large_build_method 较成熟,调好阈值后可门禁 + ─────────────────────────────────────────────────── ───────────────────────────────────────────── ───────────────────────────────────────────────── + 分层架构依赖方向 layer_violation 单包全量扫描下价值较高,可门禁 + ─────────────────────────────────────────────────── ───────────────────────────────────────────── ───────────────────────────────────────────────── + MQTT/BLE/业务模块隔离 module_violation 配置完整后可门禁 + ─────────────────────────────────────────────────── ───────────────────────────────────────────── ───────────────────────────────────────────────── + 文件级循环 import circular_dependency 全量扫描可作提示,不建议 changed-only 门禁 + ─────────────────────────────────────────────────── ───────────────────────────────────────────── ───────────────────────────────────────────────── + Timer、Controller、Subscription 未释放 lifecycle_resource_not_disposed 能发现直接、显式字段场景,建议报告模式 + ─────────────────────────────────────────────────── ───────────────────────────────────────────── ───────────────────────────────────────────────── + Widget 缺少 const 构造函数 missing_const_constructor 基础规范提示,不建议作为核心门禁 + ─────────────────────────────────────────────────── ───────────────────────────────────────────── ───────────────────────────────────────────────── + 明显的设备初始化/销毁方法缺失 device_lifecycle 仅按方法名配对,建议代码评审线索 + ─────────────────────────────────────────────────── ───────────────────────────────────────────── ───────────────────────────────────────────────── + MQTT URL、连接/订阅方法配对 mqtt_connection URL 粗筛有用,连接生命周期准确度有限 + ─────────────────────────────────────────────────── ───────────────────────────────────────────── ───────────────────────────────────────────────── + BLE 扫描停止、连接断开、超时关键词 ble_scanning 适合特定 wrapper 命名规范,建议提示模式 + ─────────────────────────────────────────────────── ───────────────────────────────────────────── ───────────────────────────────────────────────── + 硬编码凭据、明文 MQTT/HTTP、显式不安全 BLE 关键词 iot_security 可作 smoke check,必须允许 baseline/suppression + ─────────────────────────────────────────────────── ───────────────────────────────────────────── ───────────────────────────────────────────────── + 依赖版本和废弃包 pubspec_security 当前标准项目布局下基本失效,不应使用 + + 最有价值的使用方式 + + 1. 架构治理 + + 为 presentation/domain/data/core 或 mqtt/ble/shared 配置明确 glob 和 allowed_deps,用全量扫描阻止跨层、跨模块 import。这是当前最有差异化的能力。 + + 2. 代码规模治理 + + 根据团队现状设置文件、类和 build() 阈值,用于识别持续膨胀的模块和 Widget。 + + 3. 显式资源泄漏初筛 + + 可以发现直接声明的 Timer、StreamSubscription、Controller、MqttClient、BluetoothDevice 等字段未在本类 dispose() 中直接释放的情况。 + + 4. IoT 安全反模式筛查 + + 可快速发现源码中的 password = "..."、tcp://、1883 端口和非 localhost 的 http://。适合提醒,不能证明系统安全。 + + 5. 存量项目渐进式接入 + + baseline、源码 suppression、JSON/SARIF、--fail-on 和 --min-score 已形成完整闭环,适合先记录历史问题,再限制新增问题。 + + 不适合直接依赖的场景 + + - CVE、真实依赖漏洞或传递依赖扫描。 + - TLS 证书校验、密钥存储安全、权限合规。 + - 跨方法、跨类、继承、mixin、DI 场景的资源生命周期证明。 + - MQTT/BLE API 调用图、异步异常路径或真实连接状态分析。 + - 运行时性能、功耗、内存泄漏检测。 + - 整个 Melos monorepo 的 package graph 分析。 + - 仅依赖 --changed-only 作为合并门禁。 + + MQTT、BLE 和设备生命周期规则目前检查的主要是方法声明名称,而不是 _client.connect()、startScan() 等真实调用关系;安全规则也会扫描注释、示例和测试字符串。这些规则默认 + 直接阻断 high 风险会产生噪声。 + + 推荐落地策略 + + - PR:changed-only 用于快速提示。 + - 主干、夜间、发布前:执行全量扫描。 + - 硬门禁:优先启用经过 config doctor 验证的 layer/module 规则和规模规则。 + - IoT、生命周期、安全规则:先报告和 baseline,积累真实误报数据后再决定是否参与门禁。 + - 架构硬门禁建议使用独立配置,关闭尚未校准的 device_lifecycle、mqtt_connection、ble_scanning 和 iot_security。 + + 当前更准确的产品定位是:Flutter/IoT 项目的架构与反模式治理 CLI,而不是完整的 Dart analyzer 或安全扫描平台。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..31bd11e --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,152 @@ +# FlutterGuard Architecture + +## Monorepo Structure +``` +flutterguard/ +├── flutterguard.yaml # CLI config example / root defaults +├── melos.yaml # Monorepo bootstrap + script orchestration +├── AGENTS.md # Agent-parseable project rules +├── PROJECT_RULES.md # Human + agent constitution +├── analysis_options.yaml # Root strict analysis config +├── pubspec.yaml # Root workspace (melos only) +├── docs/ +│ ├── FLUTTERGUARD_SPEC.md # Single source of truth +│ └── ARCHITECTURE.md # This file +├── packages/ +│ └── flutterguard_cli/ # [ACTIVE] CLI static analysis +├── archive/ # Runtime tracing (v0.1.0, reserved) +│ ├── flutterguard_core/ +│ ├── flutterguard_dio/ +│ └── flutterguard_flutter/ +└── examples/ + └── scan_demo/ # CLI scan target +``` + +## Dependency Graph +``` + ┌──────────────────┐ + │ flutterguard │ (root workspace) + └────────┬─────────┘ + │ melos + ▼ + ┌──────────────────────┐ + │ flutterguard_cli │ + │ [ACTIVE] │ + │ deps: args,analyzer,│ + │ glob,path,yaml │ + └──────────────────────┘ +``` + +## CLI Scan Data Flow +``` +User runs: dart run flutterguard scan -p [--config ] + │ + ├── 1. ArgParser parses CLI flags + ├── 2. ScanConfig.fromFile() loads YAML config + │ └── includes architecture.layers/modules declarations + ├── 3. FileCollector.collect() globs .dart files + │ └── applies include/exclude patterns + ├── 4. ScanContext separates all files from changed target files + ├── 5. SourceWorkspace reads/parses each target source once + │ └── read/parse failures become ScanDiagnostic entries + ├── 6. RuleCatalog explicitly wires 21 rule classes / 23 rule IDs + │ ├── source rules share SourceWorkspace content/AST/line info + │ ├── architecture rules share one ImportGraph + │ └── layer/module checks share DependencyBoundaryEngine + ├── 7. Issues sorted by risk level (high → medium → low) + ├── 8. Suppression and baseline filters produce visible issues + ├── 9. ReportGenerator generates output + │ ├── Table → terminal stdout + │ └── JSON → .flutterguard/report.json + └── 10. CI gate check (exit 1 if fail threshold exceeded) +``` + +## Rule Architecture +``` +Direct rule tests and programmatic consumers retain the standalone API: + ┌──────────────────────────────────────────────┐ + │ class XxxRule { │ + │ analyze(List files, │ + │ {SourceWorkspace? workspace}) │ + │ → List │ + │ } │ + └──────────────────────────────────────────────┘ + +Issue model (StaticIssue): + id, title, file, line, level + + domain (architecture/performance/standards) + + priority (p0/p1/p2) + + message, detail, suggestion, metadata + + framework, confidence, evidence (up to 5 entries) +``` + +Scanner execution uses one `ScanContext` and one explicit `RuleCatalog`. +There is no dynamic plugin loading, reflection, or rule code generation. + +Architecture data flow: + +``` +SourceWorkspace → ImportGraph → DependencyBoundaryEngine + ├── LayerViolationRule + └── ModuleViolationRule + ImportGraph ───── CircularDependencyRule +``` + +State-management data flow: + +``` +SourceWorkspace AST + ├── shared build/callback/import/owner/glob helpers + ├── generic build, mutability and UI-boundary rules + ├── Riverpod / Bloc / Provider framework rules + └── project-wide state graph → Tarjan SCC → deterministic shortest cycle +``` + +State rules are gated in this order: global `state_management.enabled`, the +per-rule switch, confidence threshold, framework import auto-detection, +`ignore_paths`, AST detection, then the existing suppression and baseline +filters. Changed-only state-cycle analysis builds from `allFiles` but reports +only components touching `targetFiles`. + +## CLI Command Layout + +`bin/flutterguard.dart` owns top-level routing, help, positional-path +normalization, and documented exit codes. Functional commands live under +`lib/src/cli/`: + +- `cli_parsers.dart`: parser tree and command option contracts +- `scan_command.dart`: scan reporting and CI gates +- `baseline_commands.dart`: create/stats/prune/check +- `config_commands.dart`: init/config/doctor behavior +- `issue_commands.dart`: issue feedback export +- `rule_commands.dart`: rules/explain output + +## Output Formats + +| Format | Purpose | Enabled | +|--------|---------|---------| +| table | Human-readable terminal output grouped by domain | Default | +| json | Machine-readable for CI and custom tooling | --format=json | +| sarif | GitHub Code Scanning integration | --format=sarif | + +## Override Chains + +### pubspec_overrides.yaml (melos-managed) +- Only `flutterguard_cli` remains (no path dependencies) +- Run `melos bootstrap` after any pubspec.yaml change + +### analysis_options.yaml Inheritance +``` +root/analysis_options.yaml + strict-casts: true, strict-inference: true + └── packages/flutterguard_cli/analysis_options.yaml + inherits root + package:lints/recommended.yaml + excludes: test/fixtures/** +``` + +### flutterguard.yaml Config Override +``` +root/flutterguard.yaml (default config, documented example) + ├── /flutterguard.yaml (per-project config, merges over root) + └── --config flag (CLI arg, highest priority) +``` diff --git a/docs/FLUTTERGUARD_SPEC.md b/docs/FLUTTERGUARD_SPEC.md new file mode 100644 index 0000000..577d47e --- /dev/null +++ b/docs/FLUTTERGUARD_SPEC.md @@ -0,0 +1,732 @@ +# FlutterGuard — Specification Document + +> **Internal use only. Not git tracked.** +> This document is the single source of truth for agent-driven implementation. + +Version: M5 (Milestone 5) — State Maintainability | Last Updated: 2026-07-16 + +--- + +## 0. Scope + +### 0.1 Identity +FlutterGuard is an **IoT/smart home Flutter project static analysis CLI plugin**. It scans Flutter/Dart source code to detect architecture issues, security vulnerabilities, and anti-patterns specific to IoT device applications. + +### 0.2 Active Development +- **Path A (CLI static analysis)**: PRIMARY — actively developed. All new feature work targets `packages/flutterguard_cli/`. +- **Path B (runtime tracing)**: ARCHIVED — superseded by CLI static analysis. The packages `flutterguard_core/`, `flutterguard_dio/`, `flutterguard_flutter/` reside in `archive/` for future reference. + +### 0.3 What FlutterGuard IS +- A CLI tool (compiled native binary) for CI-gated static analysis +- An architecture enforcement tool with YAML-driven config +- An IoT-domain-aware rule engine for Flutter projects +- An import dependency and layer compliance checker + +### 0.4 What FlutterGuard is NOT +- NOT a runtime observability or APM SDK +- NOT a crash reporter (Sentry/Crashlytics alternative) +- NOT a general-purpose Dart linter (use `dart analyze` / `custom_lint`) +- NOT a web dashboard or data visualization platform +- NOT a network proxy or HTTP inspector +- NOT a Flutter widget library + +### 0.5 Analysis Scope +- **Input**: `.dart` source files under `lib/` (configurable via glob patterns) +- **Output**: Table (terminal) / JSON (CI) / SARIF (GitHub Code Scanning) — no Markdown +- **Config**: `flutterguard.yaml` in project root +- **Runtime**: No runtime instrumentation — purely static analysis at compile time + +--- + +## 1. Architecture Overview + +``` +User runs: flutterguard scan [] [--changed-only] [--base main] [--baseline .flutterguard/baseline.json] + │ + ├── 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. Scan: 21 rule classes analyze source/project state (23 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) + │ ├── 5 generic state rules (build, mutability, UI boundary, cycle) + │ ├── 2 Riverpod rules (read/render, watch/callback) + │ ├── 1 Bloc rule (Equatable props completeness) + │ └── 2 Provider rules (ownership, loop notifications) + │ └── (changed-only: import cycle skipped; state cycle uses full graph) + ├── 5. Issues sorted by risk level (high → medium → low) + ├── 6. Suppression comments and optional baseline filter visible issues + ├── 7. ReportGenerator generates output + │ ├── Table → terminal stdout + │ └── JSON → .flutterguard/report.json + │ └── SARIF → .flutterguard/report.sarif + └── 8. CI gate check (exit 1 if fail threshold exceeded) +``` + +### Key Design Decisions + +| Decision | Rationale | +|----------|-----------| +| `package:analyzer` for AST | Resolves type information for resource detection | +| `package:glob` for file matching | Consistent glob support for include/exclude and architecture paths | +| Shared scan data | `ScanContext` carries project/all/target files; `SourceWorkspace` caches source text, AST, line info, and diagnostics | +| Rule class compatibility | Direct rule calls retain `analyze(List files, {SourceWorkspace? workspace})`; scanner execution is wired by `RuleCatalog` | +| Shared architecture kernel | Layer/module/cycle rules consume one `ImportGraph`; layer/module checks share `DependencyBoundaryEngine` | +| No plugin system | Rules are explicitly wired in `RuleCatalog` — no reflection, no codegen | + +--- + +## 2. Package Map & Dependencies + +``` +flutterguard/ +├── packages/ +│ └── flutterguard_cli/ Dart CLI (ACTIVE) +│ └── depends: args, analyzer ^7.3.0, glob, path, yaml +├── archive/ Reserved — runtime tracing (v0.1.0) +│ ├── flutterguard_core/ +│ ├── flutterguard_dio/ +│ └── flutterguard_flutter/ +└── examples/ + └── scan_demo/ Scan target demo +``` + +**Compile target**: `dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard` → native binary + +--- + +## 3. Data Models + +### 3.1 StaticIssue (CLI) + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| id | String | yes | Rule ID e.g. `layer_violation` | +| title | String | yes | Human title | +| file | String | yes | Absolute path | +| line | int? | no | Line number | +| level | RiskLevel | yes | low / medium / high | +| domain | IssueDomain | yes | architecture / performance / standards | +| priority | Priority | yes | p0 / p1 / p2 | +| message | String | yes | Short explanation | +| detail | String | no | Long description with context | +| suggestion | String | yes | Fix recommendation | +| metadata | Map | yes | Rule-specific data | +| framework | StateManagementFramework | yes | generic / riverpod / bloc / provider | +| confidence | RuleConfidence | yes | certain / probable / informational | +| evidence | List | yes | At most five compact AST evidence entries | + +### 3.2 RiskLevel Enum + +```dart +enum RiskLevel { low, medium, high } +``` + +### 3.3 IssueDomain Enum + +```dart +enum IssueDomain { architecture, performance, standards } +``` + +### 3.4 Priority Enum + +```dart +enum Priority { p0, p1, p2 } +``` + +### 3.5 State-management enums + +```dart +enum StateManagementFramework { riverpod, bloc, provider, generic } +enum RuleConfidence { certain, probable, informational } +``` + +--- + +## 4. CLI Contract + +### Command + +``` +flutterguard scan [options] + --path (-p) Project path to scan (default: .) + --config (-c) Config file path (default: flutterguard.yaml) + --format (-f) Output format: table | json | sarif (default: table) + --output (-o) Output directory (default: .flutterguard) + --verbose (-v) Show detailed output with code context + --fail-on CI gate: none | high | medium | low (default: none) + --min-score Minimum score threshold 0-100 + --changed-only Only scan .dart files changed since --base (default: false) + --base Git base ref for changed-only (default: main) + --baseline Baseline JSON file used to hide existing issues + +flutterguard baseline create [] + --output (-o) Baseline output path (default: .flutterguard/baseline.json) + +flutterguard rules [options] + --format (-f) Output format: table | json (default: table) + +flutterguard explain +``` + +### Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success, including a valid changed-only scan with no relevant changes | +| 1 | CI gate failed (issues at/below `--fail-on` level or score < `--min-score`) | +| 2 | Scan/explain setup error (bad path, missing explicit config, invalid config, zero configured Dart files, unknown rule ID) | + +### File Collection + +- Use `glob` package to match `include` patterns +- Remove matching files for `exclude` patterns +- Only `.dart` files +- Default include: `lib/**` +- Default exclude: `lib/generated/**`, `lib/**.g.dart`, `lib/**.freezed.dart`, `lib/**.mocks.dart` + +--- + +## 5. Config Schema (flutterguard.yaml) + +```yaml +include: + - lib/** # Glob patterns for files to scan + +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 + side_effect_in_build: + enabled: true + severity: high + allowlist: [] + ignore_paths: [] + # The other nine state rules use the same four keys. + +state_management: + enabled: true + framework_auto_detect: true + confidence_threshold: certain + +architecture: # Architecture layer/module rules + layers: # Layered architecture enforcement + - 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: # Business module isolation + - name: device_mqtt + path: lib/device/mqtt/** + allowed_deps: [domain, core] + - name: device_ble + path: lib/device/ble/** + allowed_deps: [domain, core] + + detect_cycles: true # Circular dependency detection +``` + +State-rule `severity` accepts `high`, `medium`, or `low` and maps to P0, P1, +or P2 respectively. `ignore_paths` values are project-relative POSIX globs. +`allowlist` values are exact rule-specific symbols; dependency-cycle edges use +`Source->Target`. Unknown enum values, invalid severity values, and wrong value +types are configuration errors. Missing state configuration uses the defaults +above and remains compatible with older YAML files. + +--- + +## 6. Export Format Spec + +### 6.1 JSON Report Schema + +```json +{ + "version": "1.0.0", + "generatedAt": "ISO8601", + "scanMode": "full|changed", + "projectPath": "/absolute/path", + "score": 85, + "summary": { + "total": 4, + "high": 1, + "medium": 2, + "low": 1, + "suppressed": 0, + "suppressedByBaseline": 0, + "diagnostics": 0, + "byDomain": { + "architecture": { "high": 1, "medium": 1, "low": 0, "total": 2 }, + "performance": { "high": 0, "medium": 1, "low": 0, "total": 1 }, + "standards": { "high": 0, "medium": 0, "low": 1, "total": 1 } + } + }, + "issues": [ + { + "id": "layer_violation", + "ruleId": "layer_violation", + "title": "层间依赖违规", + "file": "/absolute/path/to/file.dart", + "line": 42, + "level": "high", + "severity": "high", + "domain": "architecture", + "priority": "p0", + "message": "Short description", + "detail": "Long description with import path and layer info", + "suggestion": "Fix recommendation", + "metadata": { "sourceLayer": "service", "targetLayer": "widget" }, + "framework": "generic", + "confidence": "certain", + "evidence": [] + } + ], + "diagnostics": [] +} +``` + +### 6.2 Score Calculation + +``` +score = max(0, 100 - high*10 - medium*4 - low*1) +``` + +### 6.3 Table Output (Terminal) + +Default output format. Example: + +``` + +### 6.4 SARIF Output + +`--format sarif` writes `.flutterguard/report.sarif` and prints a short stdout summary. + +- SARIF version: `2.1.0` +- Rule metadata source: `RuleRegistry` +- Result severity mapping: high → `error`, medium → `warning`, low → `note` +- Location URI: project-relative path when possible +- Location line: `StaticIssue.line`, or line `1` when absent + +### 6.5 Suppression Comments + +Supported source comments: + +```dart +// flutterguard: ignore +// flutterguard: ignore , +// flutterguard: ignore all +``` + +Suppression applies only to issues on the comment line and the immediately following line. It does not support file-wide disable blocks or cross-file suppression. Suppressed issues are hidden from normal outputs and CI gates. JSON summary includes `suppressed`. + +### 6.6 Baseline + +```bash +flutterguard baseline create . --output .flutterguard/baseline.json +flutterguard scan . --baseline .flutterguard/baseline.json +``` + +Baseline files contain a sorted list of issue fingerprints. The fingerprint input is: + +``` +id + relative file path + line + message +``` + +Baseline-matched issues are hidden from stdout/JSON/SARIF and do not affect `--fail-on` or `--min-score`. Missing or invalid baseline files are scan errors with exit code 2 through the CLI. JSON summary includes `suppressedByBaseline`. + FlutterGuard Report ─ scan_demo +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 总评分: 88/100 优秀 文件总数: 2 问题总数: 3 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + 架构违规 1 items HIGH + ──────────────────────────────────────────────────────────────── + HIGH P0 优先 + 层间依赖违规 + lib/services/user_service.dart:7 + service 层不可依赖 widget 层 + 修复: 将导入的内容移至 core 或更抽象层 +``` + +--- + +## 7. Static Rules Detail + +### 7.1 large_file (RiskLevel: low, Domain: standards, Priority: p2) + +**Detection**: Count lines of file. If > maxLines (default 500), issue. + +**Implementation**: Simple file line count via `File.readAsLinesSync()`. + +### 7.2 large_class (RiskLevel: low, Domain: standards, Priority: p2) + +**Detection**: Find `class ClassName {` via analyzer AST. Calculate lines from class declaration to matching `}`. If > maxLines (default 300), issue. + +**Implementation**: Parse with `package:analyzer`, find `ClassDeclaration`, calculate line span. + +### 7.3 large_build_method (RiskLevel: medium, Domain: performance, Priority: p1) + +**Detection**: Find `Widget build(BuildContext ...)` method in any `ClassDeclaration`. Calculate line span. If > maxLines (default 80), issue. + +**Implementation**: Parse with `package:analyzer`, visit class members, match `MethodDeclaration` with name `build` and return type `Widget`. + +### 7.4 lifecycle_resource_not_disposed (RiskLevel: medium, Domain: performance, Priority: p1) + +**Detection**: +1. For each class, find non-static field declarations +2. Check if type matches: `StreamSubscription`, `Timer`, `AnimationController`, `TextEditingController`, `ScrollController`, `FocusNode` +3. Check type either as exact match or containing generic (``) +4. Find `dispose()` method in the class +5. In dispose method body, check if `${fieldName}.${expectedCall}()` exists +6. If not found, report issue + +**Resource → expected call**: +- StreamSubscription → cancel +- Timer → cancel +- AnimationController → dispose +- TextEditingController → dispose +- ScrollController → dispose +- FocusNode → dispose +- MqttClient → disconnect +- BluetoothDevice → disconnect +- StreamController → close + +**Implementation**: Parse with `package:analyzer`, visit `FieldDeclaration`, `MethodDeclaration`. Check dispose body via string contains. + +### 7.5 layer_violation (RiskLevel: high, Domain: architecture, Priority: p0) + +**Detection**: For each file, determine which architecture layer it belongs to by matching its path against `architecture.layers[].path` glob patterns. For each `import` directive, resolve the imported file and determine its layer. If the target layer is not in the source layer's `allowed_deps` list, report a violation. + +**Implementation**: Parse with `package:analyzer`, visit `ImportDirective`. Resolve imports via path normalization (supports relative and package: imports). Match layers via `Glob` from `package:glob`. + +### 7.6 module_violation (RiskLevel: high, Domain: architecture, Priority: p0) + +**Detection**: Same mechanism as `layer_violation` but operates on `architecture.modules`. Checks import relationships between named business modules. + +**Implementation**: Same as 7.5 but uses `ModuleConfig` instead of `LayerConfig`. + +### 7.7 circular_dependency (RiskLevel: medium, Domain: architecture, Priority: p1) + +**Detection**: Build a directed import graph for all scanned files. Use DFS with white/gray/black coloring to detect cycles. Report each unique cycle found. + +**Limitations**: +- Detects file-level cycles only (not class-level) +- Only resolves imports within the scanned file set +- May report the same logical cycle from different entry points + +### 7.8 missing_const_constructor (RiskLevel: low, Domain: standards, Priority: p2) + +**Detection**: +1. Find all `StatelessWidget` and `StatefulWidget` subclass declarations +2. Check if the class has a `const` constructor +3. If no `const` constructor is found and the class is a widget, report issue +4. Also checks plain classes where all fields are final and could be const + +### 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 | + +### 7.14-7.23 State-management maintainability rules + +All rules are AST-first, default enabled, and currently emit `certain` +findings. Common gating is global/per-rule enablement, confidence threshold, +framework import auto-detection, project-relative `ignore_paths`, then AST +detection. Existing source suppression and baseline filtering run afterward. + +| ID | Default | Framework | Detection contract | +|----|---------|-----------|--------------------| +| `side_effect_in_build` | high/P0 | generic | One issue per Widget/State/Consumer build root for direct state/resource effects; nested callbacks and local collection `add` are excluded. | +| `state_manager_created_in_build` | high/P0 | generic | One issue per Controller/Bloc/Cubit/Notifier/Flutter-controller construction in build; nested event and ownership callbacks are excluded. | +| `mutable_state_exposed` | medium/P1 | generic | Public non-final fields, mutable collection references/getters, and in-place `state` collection mutation in business state owners; Flutter `State` and unmodifiable values are excluded. | +| `state_layer_ui_dependency` | high/P0 | generic | One issue per state owner using BuildContext/Widget or navigation/dialog/messenger/media/theme APIs. Nested generic types without runtime use are ignored. | +| `state_dependency_cycle` | high/P0 | generic | Provider/state/service graph, Tarjan SCC, one deterministic shortest cycle per SCC containing a state node. Changed mode uses all files and reports only cycles touching a changed file. | +| `riverpod_read_used_for_render` | medium/P1 | riverpod | Local flow from `ref.read(provider)` into returned Widget construction or conditional/collection rendering; commands and callbacks are excluded. | +| `riverpod_watch_in_callback` | medium/P1 | riverpod | One issue per event/listener/timer/async callback containing `ref.watch`; build and provider declaration bodies are excluded. | +| `bloc_equatable_props_incomplete` | medium/P1 | bloc | One merged issue per Equatable class whose final instance fields are absent from `props`. | +| `provider_value_lifecycle_misuse` | medium/P1 | provider | `.value(value: new Instance())` and `create: (_) => existingInstance`; const/immutable/allowlisted values are excluded. BlocProvider is accepted. | +| `notify_listeners_in_loop` | medium/P1 | provider | One issue per for/for-in/while/do/forEach root containing `notifyListeners`; literal 0/1 iteration is excluded. | + +Framework auto-detection recognizes Riverpod, Bloc, Equatable, Provider and +Flutter Bloc imports. With `framework_auto_detect: false`, import gates are +skipped but AST shapes remain mandatory. Evidence is deduplicated and capped at +five entries. + +--- + +## 8. Test Contracts + +### 8.1 CLI Tests (83 tests) + +| Test | Fixture | Then | +|------|---------|------| +| scan_detects_large_file | large_file.dart (501 lines) | 1 issue, id=large_file | +| scan_detects_large_class | large_class.dart (class 303 lines) | 1 issue, id=large_class | +| scan_detects_large_build_method | large_build.dart (build method 81+ lines) | 1 issue, id=large_build_method | +| scan_detects_lifecycle_resource | lifecycle_issue.dart | 2+ issues, id=lifecycle_resource_not_disposed | +| scan_detects_layer_violation | boundary_issue.dart + architecture_config.yaml | 1+ issues, id=layer_violation | +| scan_detects_module_violation | boundary_issue.dart + architecture_config.yaml | 1+ issues, id=module_violation | +| scan_detects_circular_dependency | cycle_a.dart, cycle_b.dart, cycle_c.dart | 1+ issues, id=circular_dependency | +| scan_detects_missing_const_constructor | missing_const.dart | 1+ issues, id=missing_const_constructor | +| config_parses_enabled_flags | architecture_config.yaml + architecture_disabled.yaml | enabled/true, disabled/false | +| wiring_disabled_layer_module | architecture_disabled.yaml + boundary fixtures | 0 issues | +| ci_fail_on_high | array with high issue | shouldFail(issues, 'high') == true | +| json_report_generated | issues array | output contains expected fields | +| scan_detects_lifecycle_iot_resource | lifecycle_issue.dart (with MqttClient) | matches IoT types | +| 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_23_rules | RuleRegistry.all() | length == 23 | +| registry_find_returns_correct_meta | find('large_file') | non-null, correct id/domain | +| registry_find_unknown_returns_null | find('nonexistent') | null | + +### 8.2 Fixture Files + +Located at `packages/flutterguard_cli/test/fixtures/`: + +| File | Description | +|------|-------------| +| large_file.dart | 501 generated comment lines | +| large_class.dart | Class with 303 lines (2 line wrapper + 301 filler) | +| large_build.dart | Widget build method with 81+ lines | +| lifecycle_issue.dart | StreamSubscription + Timer fields, empty dispose() | +| boundary_issue.dart | Imports forbidden_file.dart (reused for layer/module tests) | +| forbidden_file.dart | Target of layer/module violation | +| cycle_a.dart | Circular dependency start (imports cycle_b) | +| cycle_b.dart | Circular dependency middle (imports cycle_c) | +| cycle_c.dart | Circular dependency end (imports cycle_a) | +| architecture_config.yaml | Config with layer + module declarations | +| architecture_disabled.yaml | Config with layer/module violations disabled | +| missing_const.dart | Widget subclass without const constructor | + +--- + +## 9. Evolution Roadmap + +### M1 (Completed) — Static Scan MVP + +Key deliverables: +- CLI static scan with 4 rules (large_file, large_class, large_build_method, lifecycle_resource_not_disposed) +- JSON + Table report generation with CI gate +- Runtime tracing packages archived to `archive/` + +### M2 (Current) — Architecture Detection + Output Reform + +- Architecture layer/module enforcement (layer_violation, module_violation) +- Circular dependency detection (circular_dependency) +- `table` output format with domain grouping (architecture / performance / standards) +- Priority system (P0/P1/P2) per issue +- Cleaned up CLI params (removed markdown, group-by, top, no-module-score) +- SPEC rewrite: removed all runtime tracing documentation + +### M3 (Completed v0.3.0) — Incremental Scan + Rule Introspection + IoT Rules + +Key deliverables: +- `--changed-only` incremental scan via git diff (skips cyclic dep in changed mode) +- `flutterguard rules` / `flutterguard explain` subcommands with RuleMeta registry +- `device_lifecycle`, `mqtt_connection`, `ble_scanning`, `iot_security`, `pubspec_security` +- 57 tests across reusable scanner/rule coverage and process-level CLI behavior +- RuleMeta class + RuleRegistry for rule introspection + +### M4 — CI Adoption + +- Single-line / next-line suppression comments for false positive control +- Baseline creation and `scan --baseline` filtering for legacy projects +- SARIF output format for GitHub Code Scanning +- GitHub Actions CI examples for JSON gates and SARIF upload +- Rule accuracy regression tests around suppression, baseline, SARIF, and IoT rules + +### M5 — Enterprise + DX + +- GitHub Actions annotations mode +- Team configuration sharing +- Pre-commit hook integration +- IDE plugin (VS Code / IntelliJ) for inline results + +--- + +## 10. Known Limitations (M4 Current) + +1. **Lifecycle resource detection**: Uses string contains in dispose body, not full AST visitor. Does not detect: + - Disposal via helper method calls + - Disposal via `disposeAll()` style patterns + - Resources created via factory methods +2. **Import resolution**: Package imports are resolved by stripping the package: prefix and matching against the scanned file set. Imports from external packages are not resolved. +3. **Cyclic detection**: Reports cycles at file granularity, not class/module granularity. Same logical cycle may be reported from different DFS entry points. +4. **Architecture rules**: Layer/module rules require the user to explicitly declare all layers/modules in flutterguard.yaml. No auto-detection. +5. **Multi-isolate**: Not supported (single-isolate only) +6. **State graph resolution**: State dependency edges are syntactic and project-local; runtime service locators and generated provider code are not resolved. +7. **Incremental scan**: `--changed-only` skips circular_dependency entirely + in changed mode. Layer/module violations are detected only when the changed + file is the source of the illegal import; unchanged target files remain + available for import resolution. Project-level pubspec security is checked + in full scans and when `pubspec.yaml` changes in incremental scans. + `state_dependency_cycle` is the exception: it always builds the full graph + and reports only cycles that touch a changed source file. +8. **Suppression**: Only current-line and next-line comments are supported. + There is no file-wide disable/enable block. +9. **Baseline**: Fingerprints intentionally include line and message, so moving + code or changing rule wording can surface old issues again. + +--- + +## 11. Commands Reference + +```bash +# Bootstrap (development) +cd flutterguard +dart pub get +melos bootstrap + +# Analyze +dart run melos run analyze + +# Test +dart run melos run test:cli + +# Compile CLI +dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard + +# Scan a project +./flutterguard scan --path /path/to/flutter_project + +# Run IoT scan demo +./flutterguard scan --path examples/scan_demo + +# Create a baseline and scan only new issues +./flutterguard baseline create . --output .flutterguard/baseline.json +./flutterguard scan . --baseline .flutterguard/baseline.json + +# Generate SARIF for GitHub Code Scanning +./flutterguard scan . --format sarif --baseline .flutterguard/baseline.json +``` + +--- + +## 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 +- `framework` — generic / riverpod / bloc / provider +- `confidence` — certain / probable / informational + +### 13.2 RuleRegistry +Singleton in `lib/src/rules/registry.dart`: +- `all()` → `List` (23 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. Resolve the containing repository with `git rev-parse --show-toplevel` +2. Verify `^{commit}` and reject option-like or invalid refs +3. `git diff --name-only --diff-filter=ACMR -z --` +4. `git ls-files --others --exclude-standard -z` (untracked files) +5. Union both NUL-delimited sets, anchor paths to the target project, and filter to `.dart` files +6. Feed filtered files to all rules +7. CircularDependencyRule is disabled only for a valid changed-mode scan + +### Behavior Matrix +| Condition | Behavior | scanMode | +|-----------|----------|----------| +| Non-git dir | Fallback to full scan | full | +| --changed-only, 0 changes | Empty successful report | changed | +| --changed-only, changes > 0 | Only scan changed .dart files | changed | +| Invalid `--base` | Scan setup error (exit 2) | — | +| --base not specified | Defaults to 'main' | — | diff --git a/docs/USAGE.md b/docs/USAGE.md new file mode 100644 index 0000000..635b93e --- /dev/null +++ b/docs/USAGE.md @@ -0,0 +1,855 @@ +# FlutterGuard — Complete Usage Guide + +> 版本: 0.6.0 | 支持: macOS / Windows / Linux | Dart SDK >=3.11.5 + +--- + +## 目录 + +1. [安装](#1-安装) +2. [快速开始](#2-快速开始) +3. [CLI 命令参考](#3-cli-命令参考) +4. [配置文件](#4-配置文件) +5. [规则详解](#5-规则详解) +6. [评分系统](#6-评分系统) +7. [输出格式](#7-输出格式) +8. [CI 集成](#8-ci-集成) +9. [路径处理说明](#9-路径处理说明) +10. [常见问题](#10-常见问题) +11. [开发指南](#11-开发指南) + +配置策略总览见 [CONFIGURATION_STRATEGY.md](../CONFIGURATION_STRATEGY.md):CLI 输出只放即时下一步,完整命令与配置心智模型放在专门说明文档中。 + +--- + +## 1. 安装 + +### 1.1 前置条件 + +| 组件 | 最低版本 | 安装方式 | +|------|---------|---------| +| Dart SDK | 3.11.5+ | [dart.dev/get-dart](https://dart.dev/get-dart) | +| Git | 任意 | 用于克隆仓库 | + +### 1.2 全局命令安装(推荐) + +
+macOS / Linux + +```bash +git clone https://github.com/lizy-coding/flutterguard.git +cd flutterguard + +dart pub get +dart pub global activate melos +melos bootstrap + +dart pub global activate --source path packages/flutterguard_cli +flutterguard --help +``` + +确认 `$HOME/.pub-cache/bin` 已在 `PATH` 中: + +```bash +export PATH="$PATH:$HOME/.pub-cache/bin" # 添加到 ~/.zshrc 或 ~/.bashrc +``` +
+ +
+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 pub global activate --source path packages\flutterguard_cli +flutterguard --help +``` + +确认 `%USERPROFILE%\AppData\Local\Pub\Cache\bin` 已在 `PATH` 中(Dart 安装器默认添加)。 + +若 `flutterguard` 命令未识别,检查并手动添加: + +```powershell +$env:Path += ";$env:USERPROFILE\AppData\Local\Pub\Cache\bin" +``` +
+ +### 1.3 编译独立二进制 + +
+macOS / Linux + +```bash +dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard +./flutterguard --help +``` +
+ +
+Windows + +```powershell +dart compile exe packages\flutterguard_cli\bin\flutterguard.dart -o flutterguard.exe +.\flutterguard.exe --help +``` +
+ +### 1.4 验证安装 + +```bash +flutterguard --version # 输出: flutterguard 0.6.0 +flutterguard --help # 输出帮助信息 +``` + +--- + +## 2. 快速开始 + +### 扫描一个项目 + +```bash +# macOS / Linux +flutterguard scan -p /path/to/flutter_project + +# Windows +flutterguard scan -p D:\dev\my_flutter_app + +# 扫描当前目录 +flutterguard scan -p . +``` + +### 扫描示例项目 + +```bash +flutterguard scan -p examples/scan_demo +``` + +### CI 门禁模式 + +```bash +# JSON 输出 + HIGH 级别失败 +flutterguard scan -p . --format json --fail-on high + +# 最低分 80 分 +flutterguard scan -p . --format json --min-score 80 +``` + +--- + +## 3. CLI 命令参考 + +### 3.1 命令结构 + +``` +flutterguard [options] + +Commands: + scan Scan a Flutter project for architecture issues + --help Show usage + --version Show version +``` + +### 3.2 scan 命令参数 + +| 参数 | 简写 | 类型 | 默认值 | 说明 | +|------|------|------|--------|------| +| `--path` | `-p` | path | `.` | 要扫描的项目路径 | +| `--config` | `-c` | path | `flutterguard.yaml` | 配置文件路径(相对于项目根目录) | +| `--format` | `-f` | `table` \| `json` | `table` | 输出格式 | +| `--output` | `-o` | path | `.flutterguard` | JSON 报告输出目录 | +| `--verbose` | `-v` | flag | off | 显示详细代码上下文 | +| `--fail-on` | — | `none` \| `high` \| `medium` \| `low` | `none` | CI 门禁等级 | +| `--min-score` | — | int (0-100) | unset | 最低可接受评分 | +| `--help` | `-h` | flag | — | 显示 scan 帮助 | + +### 3.3 退出码 + +| 退出码 | 含义 | +|--------|------| +| `0` | 成功完成(含 help/version 及增量扫描没有相关变更) | +| `1` | CI 门禁失败(存在超过 `--fail-on` 的问题或评分低于 `--min-score`) | +| `2` | 扫描设置错误(路径不存在、显式配置缺失、配置无效或未匹配到配置范围内的 Dart 文件) | + +### 3.4 使用示例 + +```bash +# 基础扫描 +flutterguard scan -p ./my_app + +# 指定配置文件 +flutterguard scan -p . -c my_config.yaml + +# JSON 输出到指定目录 +flutterguard scan -p . --format json -o reports + +# 详细输出模式 +flutterguard scan -p . -v + +# CI 门禁:存在 HIGH 级别问题即失败 +flutterguard scan -p . --fail-on high + +# CI 门禁:存在任何问题即失败 +flutterguard scan -p . --fail-on low + +# 综合门禁:HIGH 且评分低于 80 失败 +flutterguard scan -p . --fail-on high --min-score 80 + +# Windows 示例 +flutterguard scan -p D:\dev\flutter_app -c config\flutterguard.yaml +flutterguard scan -p . --format json --fail-on medium --min-score 60 +``` + +--- + +## 4. 配置文件 + +### 4.1 配置位置 + +FlutterGuard 按以下优先级加载配置: + +1. 命令行 `--config` 指定的文件路径(最高优先级) +2. 项目根目录下的 `flutterguard.yaml` +3. 内置默认配置(无配置文件时使用) + +### 4.2 完整配置示例 + +```yaml +# ========== 文件收集 ========== +include: + - lib/** + - test/** + +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 + + side_effect_in_build: + enabled: true + severity: high + allowlist: [] + ignore_paths: [] + + riverpod_read_used_for_render: + enabled: true + severity: medium + +# ========== 状态管理规则总开关 ========== +state_management: + enabled: true + framework_auto_detect: true + confidence_threshold: certain + +# ========== 架构约束 ========== +architecture: + # 层间依赖约束 + layers: + - name: presentation + path: lib/presentation/** + allowed_deps: [domain, core] + + - name: domain + path: lib/domain/** + allowed_deps: [core] + + - name: data + path: lib/data/** + allowed_deps: [domain, core] + + - name: core + path: lib/core/** + allowed_deps: [] + + # 业务模块隔离 + modules: + - name: device_mqtt + path: lib/device/mqtt/** + allowed_deps: [domain, core] + + - name: device_ble + path: lib/device/ble/** + allowed_deps: [domain, core] + + # 循环依赖检测 + detect_cycles: true + + # 规则开关 + layer_violation: + enabled: true + module_violation: + enabled: true +``` + +### 4.3 配置项说明 + +#### include / exclude + +| 字段 | 类型 | 说明 | +|------|------|------| +| `include` | `List` | glob 模式,指定要扫描的文件。默认 `['lib/**']` | +| `exclude` | `List` | glob 模式,排除匹配的文件。默认排除 generated/freezed/mocks | + +#### rules.*.enabled + +每个规则有独立的 `enabled` 开关。设为 `false` 可禁用该规则。 + +#### rules.*.maxLines + +阈值类规则(large_file / large_class / large_build_method)支持 `maxLines` 自定义。超过该值即报告问题。 + +#### state_management 与状态规则 + +- `state_management.enabled`: 10 条状态管理规则的总开关,默认 `true` +- `framework_auto_detect`: 默认根据 Riverpod/Bloc/Provider import 与 AST 形态共同识别;设为 `false` 时只跳过 import 门槛 +- `confidence_threshold`: `certain` / `probable` / `informational`;当前规则均为 `certain` +- 每条状态规则支持 `enabled`、`severity`、`allowlist`、`ignore_paths` +- `severity` 映射 high→P0、medium→P1、low→P2,并影响评分与 CI gate +- `ignore_paths` 使用项目相对 POSIX glob;依赖环 allowlist 边使用 `Source->Target` + +#### architecture.layers + +- `name`: 层名称(任意字符串) +- `path`: glob 模式,匹配该层的文件 +- `allowed_deps`: 该层允许依赖的其他层名称列表 + +**重要**: 层规则需要显式声明所有层。未声明的文件不在任何层内,不受检查。 + +#### architecture.modules + +- 结构与 layers 完全相同 +- `name`: 模块名称 +- `path`: 模块文件匹配模式 +- `allowed_deps`: 允许依赖的模块列表 + +#### architecture.detect_cycles + +- 类型: `bool` +- 默认: `false`(默认配置)/ `true`(推荐) +- 启用文件级循环依赖检测 + +#### architecture.layer_violation.enabled / module_violation.enabled + +- 类型: `bool` +- 默认: `true` +- 全局开关,即使配置了 layers/modules 也可临时关闭 + +### 4.4 默认配置(无 flutterguard.yaml 时) + +```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 } + side_effect_in_build: { enabled: true, severity: high } + state_manager_created_in_build: { enabled: true, severity: high } + mutable_state_exposed: { enabled: true, severity: medium } + state_layer_ui_dependency: { enabled: true, severity: high } + state_dependency_cycle: { enabled: true, severity: high } + riverpod_read_used_for_render: { enabled: true, severity: medium } + riverpod_watch_in_callback: { enabled: true, severity: medium } + bloc_equatable_props_incomplete: { enabled: true, severity: medium } + provider_value_lifecycle_misuse: { enabled: true, severity: medium } + notify_listeners_in_loop: { enabled: true, severity: medium } + +state_management: + enabled: true + framework_auto_detect: true + confidence_threshold: certain + +architecture: + layers: [] # 未配置层,不执行层规则 + modules: [] # 未配置模块,不执行模块规则 + detect_cycles: false + layer_violation: { enabled: true } + module_violation: { enabled: true } +``` + +--- + +## 5. 规则详解 + +### 5.1 large_file — 文件过大 + +| 属性 | 值 | +|------|----| +| **ID** | `large_file` | +| **风险等级** | LOW | +| **领域** | standards(代码规范) | +| **优先级** | P2 | +| **可配置** | `enabled`, `maxLines` | +| **检测方式** | 读取文件行数,超过 `maxLines`(默认 500)报告问题 | + +### 5.2 large_class — 类过大 + +| 属性 | 值 | +|------|----| +| **ID** | `large_class` | +| **风险等级** | LOW | +| **领域** | standards(代码规范) | +| **优先级** | P2 | +| **可配置** | `enabled`, `maxLines` | +| **检测方式** | 找到 `class ClassName` 声明,计算类体行数,超过 `maxLines`(默认 300)报告 | + +### 5.3 large_build_method — Build 方法过大 + +| 属性 | 值 | +|------|----| +| **ID** | `large_build_method` | +| **风险等级** | MEDIUM | +| **领域** | performance(性能) | +| **优先级** | P1 | +| **可配置** | `enabled`, `maxLines` | +| **检测方式** | 找到 `Widget build(BuildContext)` 方法,计算行数,超过 `maxLines`(默认 80)报告 | + +### 5.4 lifecycle_resource_not_disposed — 资源未释放 + +| 属性 | 值 | +|------|----| +| **ID** | `lifecycle_resource_not_disposed` | +| **风险等级** | MEDIUM | +| **领域** | performance(性能) | +| **优先级** | P1 | +| **可配置** | `enabled` | + +**检测的资源类型**: + +| 类型 | 期望释放方法 | IoT 相关 | +|------|-------------|---------| +| `StreamSubscription` | `.cancel()` | | +| `Timer` | `.cancel()` | | +| `AnimationController` | `.dispose()` | | +| `TextEditingController` | `.dispose()` | | +| `ScrollController` | `.dispose()` | | +| `FocusNode` | `.dispose()` | | +| `MqttClient` | `.disconnect()` | ✅ IoT | +| `BluetoothDevice` | `.disconnect()` | ✅ IoT | +| `StreamController` | `.close()` | | + +**检测方式**: 对每个类,检查字段声明 → 类型匹配 → 是否在 `dispose()` 方法中调用了对应的释放方法。 + +### 5.5 layer_violation — 层间依赖违规 + +| 属性 | 值 | +|------|----| +| **ID** | `layer_violation` | +| **风险等级** | HIGH | +| **领域** | architecture(架构) | +| **优先级** | P0 | +| **可配置** | `architecture.layer_violation.enabled`, `architecture.layers` | + +**示例**:`presentation` 层允许依赖 `[domain, core]`。如果 `presentation` 层中的文件 import 了 `data` 层文件,报告违规。 + +### 5.6 module_violation — 模块依赖违规 + +| 属性 | 值 | +|------|----| +| **ID** | `module_violation` | +| **风险等级** | HIGH | +| **领域** | architecture(架构) | +| **优先级** | P0 | +| **可配置** | `architecture.module_violation.enabled`, `architecture.modules` | + +**与 layer_violation 的区别**: module 用于业务模块隔离(如 device_mqtt 不应依赖 device_ble),layer 用于架构分层约束。 + +### 5.7 circular_dependency — 循环依赖 + +| 属性 | 值 | +|------|----| +| **ID** | `circular_dependency` | +| **风险等级** | MEDIUM | +| **领域** | architecture(架构) | +| **优先级** | P1 | +| **可配置** | `architecture.detect_cycles` | + +**检测方式**: 构建有向图 → DFS 染色检测环 → 每个文件级环报告一次。 + +### 5.8 missing_const_constructor — 缺少 const 构造函数 + +| 属性 | 值 | +|------|----| +| **ID** | `missing_const_constructor` | +| **风险等级** | LOW | +| **领域** | standards(代码规范) | +| **优先级** | P2 | +| **可配置** | `enabled` | + +**检测方式**: 找到 `StatelessWidget` / `StatefulWidget` 子类 → 检查是否有 `const` 构造函数。 + +--- + +### 5.9 状态管理可维护性规则(10 条) + +| ID | 默认等级 | 框架 | True positive | Safe pattern | +|----|----------|------|---------------|--------------| +| `side_effect_in_build` | HIGH | generic | build 中 `emit()`、`notifyListeners()`、连接设备或 notifier 命令 | 事件回调或 listener 内执行 | +| `state_manager_created_in_build` | HIGH | generic | build 中 `DeviceController()` | State 字段或 Provider `create` 持有 | +| `mutable_state_exposed` | MEDIUM | generic | public 可变字段/集合 getter、`state.items.add` | unmodifiable view、copyWith | +| `state_layer_ui_dependency` | HIGH | generic | Controller 参数为 BuildContext 或调用 Navigator | 状态层输出事件,Widget 执行 UI 行为 | +| `state_dependency_cycle` | HIGH | generic | Provider/Controller/Service 形成 SCC | 单向接口或协调器 | +| `riverpod_read_used_for_render` | MEDIUM | Riverpod | `Text(ref.read(p).name)` | 渲染用 `ref.watch`,命令用 `ref.read` | +| `riverpod_watch_in_callback` | MEDIUM | Riverpod | `onTap: () => ref.watch(p)` | 回调使用 `ref.read` | +| `bloc_equatable_props_incomplete` | MEDIUM | Bloc | final 字段未加入 `props` | 所有值字段均在 `props` | +| `provider_value_lifecycle_misuse` | MEDIUM | Provider | `.value(value: Controller())` / `create: (_) => existing` | 新实例用 create,已有实例用 .value | +| `notify_listeners_in_loop` | MEDIUM | Provider | for/while/forEach 内通知 | 批量修改后统一通知一次 | + +显式行级抑制继续使用现有注释: + +```dart +// flutterguard: ignore side_effect_in_build +Widget build(BuildContext context) { + ref.read(deviceProvider.notifier).refresh(); + return const DeviceView(); +} +``` + +## 6. 评分系统 + +### 6.1 计算公式 + +``` +score = max(0, 100 - HIGH×10 - MEDIUM×4 - LOW×1) +``` + +| 问题等级 | 扣分 | +|---------|------| +| HIGH | -10 | +| MEDIUM | -4 | +| LOW | -1 | + +### 6.2 评分等级 + +| 分数段 | 等级 | 含义 | +|--------|------|------| +| 80-100 | 优秀 | 代码质量良好 | +| 50-79 | 需关注 | 存在一定问题,建议处理 | +| 0-49 | 需整改 | 存在较多严重问题,需要立即处理 | + +--- + +## 7. 输出格式 + +### 7.1 Table 格式(默认) + +终端输出,按领域分组显示。示例: + +``` + FlutterGuard Report ─ my_flutter_app +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 总评分: 88/100 优秀 扫描文件: 15 问题总数: 3 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + 架构违规 1 items ▰ HIGH +───────────────────────────────────────────────────────────────── + + HIGH P0 优先 + 层间依赖违规 + lib/presentation/home_page.dart:42 + presentation 层不可依赖 data 层 + 修复: 将导入的内容移至 domain 或 core层 +``` + +### 7.2 JSON 格式 + +`--format json` 输出到 stdout(摘要)和 `--output` 目录下 `report.json`。 + +```json +{ + "version": "1.0.0", + "generatedAt": "2026-06-09T12:00:00.000Z", + "projectPath": "/Users/dev/my_flutter_app", + "score": 88, + "summary": { + "total": 3, + "high": 1, + "medium": 1, + "low": 1, + "byDomain": { + "architecture": { "high": 1, "medium": 1, "low": 0, "total": 2 }, + "performance": { "high": 0, "medium": 0, "low": 0, "total": 0 }, + "standards": { "high": 0, "medium": 0, "low": 1, "total": 1 } + } + }, + "issues": [ + { + "id": "layer_violation", + "title": "层间依赖违规", + "file": "/Users/dev/my_flutter_app/lib/presentation/home_page.dart", + "line": 42, + "level": "high", + "domain": "architecture", + "priority": "p0", + "message": "presentation 层不可依赖 data 层", + "detail": "导入: package:app/data/repo.dart\n源层: presentation (lib/presentation/**)\n目标层: data (lib/data/**)\n允许依赖: domain, core", + "suggestion": "将导入的内容移至 domain 或 core层", + "metadata": { + "sourceLayer": "presentation", + "targetLayer": "data", + "imported": "package:app/data/repo.dart", + "allowedDeps": ["domain", "core"] + } + } + ] +} +``` + +### 7.3 输出路径 + +| 平台 | CLI --output 默认值 | 实际输出路径 | +|------|-------------------|-------------| +| macOS | `.flutterguard` | `/Users/xxx/project/.flutterguard/report.json` | +| Windows | `.flutterguard` | `D:\xxx\project\.flutterguard\report.json` | + +--- + +## 8. CI 集成 + +### 8.1 GitHub Actions + +```yaml +name: FlutterGuard + +on: [push, pull_request] + +jobs: + scan: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: 3.11.5 + - name: Install FlutterGuard + run: dart pub global activate flutterguard_cli + - name: Scan + run: flutterguard scan -p . --format json --fail-on high --min-score 80 +``` + +### 8.2 GitLab CI + +```yaml +flutterguard: + image: dart:3.11.5 + script: + - dart pub global activate flutterguard_cli + - flutterguard scan -p . --format json --fail-on high --min-score 80 + artifacts: + paths: + - .flutterguard/report.json + when: always +``` + +### 8.3 预提交钩子 (pre-commit) + +```yaml +# .pre-commit-config.yaml +repos: + - repo: local + hooks: + - id: flutterguard + name: FlutterGuard scan + entry: flutterguard scan -p . --fail-on high + language: system + pass_filenames: false + always_run: true +``` + +### 8.4 Windows 本地 CI 脚本 + +```powershell +# tools\scan_ci.ps1 +$ErrorActionPreference = "Stop" + +Write-Host "FlutterGuard Scan" -ForegroundColor Cyan +flutterguard scan -p . --format json --fail-on high --min-score 80 + +if ($LASTEXITCODE -eq 0) { + Write-Host "Passed!" -ForegroundColor Green +} else { + Write-Host "Failed! Check .flutterguard/report.json for details." -ForegroundColor Red + exit 1 +} +``` + +--- + +## 9. 路径处理说明 + +### 9.1 跨平台策略 + +FlutterGuard 所有路径操作均通过 `package:path` 的 Context 系统完成,自动适配不同平台的路径分隔符。 + +| 场景 | macOS | Windows | 处理方式 | +|------|-------|---------|---------| +| 内部路径操作 | `/` | `\` | `p.Context(style: p.Style.posix/windows)` | +| glob 模式匹配 | `/` | `/` | `replaceAll('\\', '/')` 归一化 | +| import 解析 | `/` | `\` → `/` | Context.normalize | +| 配置文件 glob | `lib/**` | `lib/**` | 统一正斜杠 | + +### 9.2 glob 模式约定 + +无论什么平台,YAML 配置中的 glob 模式均使用**正斜杠** `/`: + +```yaml +# 正确 +path: lib/presentation/** + +# 错误(Windows 也不要使用反斜杠) +path: lib\presentation\** +``` + +### 9.3 方法级 Context 参数 + +以下公共 API 支持传入 `p.Context` 以适配特定平台: + +| 方法 | Context 参数 | +|------|-------------| +| `projectPathContext()` | `context` | +| `normalizePath()` | `context`, `basePath` | +| `matchesProjectGlob()` | `context` | +| `projectRelativePath()` | `context` | +| `resolveImport()` | `context` | + +--- + +## 10. 常见问题 + +### Q: 未找到配置文件怎么办? + +自动使用内置默认配置(所有规则启用,无架构约束)。可通过 `-c` 参数显式指定。 + +### Q: 如何忽略生成的代码? + +默认已经排除了 `lib/generated/**`, `lib/**.g.dart`, `lib/**.freezed.dart`, `lib/**.mocks.dart`。可在 `flutterguard.yaml` 的 `exclude` 中添加更多模式。 + +### Q: 架构规则未生效? + +检查以下几点: +1. `architecture.layers` / `architecture.modules` 是否已声明 +2. `architecture.layer_violation.enabled` / `module_violation.enabled` 是否为 `true` +3. glob 模式是否匹配目标文件(使用正斜杠) + +### Q: Windows 终端输出乱码? + +```powershell +# PowerShell 设置 UTF-8 +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +# 推荐使用 Windows Terminal(Windows 10/11 自带) +``` + +### Q: Windows 下颜色不显示? + +旧 cmd.exe 不支持 ANSI 转义码,使用 Windows Terminal 即可。颜色仅影响外观,不影响功能。 + +### Q: 如何生成原生可执行文件并分发? + +```bash +# macOS +dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard + +# Windows +dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard.exe +``` + +编译产物可独立运行,无需 Dart 环境。 + +--- + +## 11. 开发指南 + +### 11.1 环境准备 + +```bash +# macOS / Linux +git clone https://github.com/lizy-coding/flutterguard.git +cd flutterguard +dart pub get +dart pub global activate melos +melos bootstrap + +# Windows +git clone https://github.com/lizy-coding/flutterguard.git +cd flutterguard +dart pub get +dart pub global activate melos +melos bootstrap +``` + +### 11.2 常用命令 + +| 命令 | 说明 | +|------|------| +| `dart run melos run analyze` | 静态分析 | +| `dart run melos run test:cli` | 运行 CLI 测试(26 个测试) | +| `dart run flutterguard scan -p examples/scan_demo` | 扫描示例项目 | +| `dart compile exe ...` | 编译原生二进制 | + +### 11.3 测试结构 + +``` +packages/flutterguard_cli/test/ +├── scanner_test.dart # 26 个测试 +└── fixtures/ # 规则与配置 fixture + ├── large_file.dart + ├── large_class.dart + ├── large_build.dart + ├── lifecycle_issue.dart + ├── boundary_issue.dart + ├── forbidden_file.dart + ├── cycle_a.dart + ├── cycle_b.dart + ├── cycle_c.dart + ├── missing_const.dart + ├── architecture_config.yaml + └── architecture_disabled.yaml +``` + +### 11.4 添加新规则 + +1. 在 `docs/FLUTTERGUARD_SPEC.md` 添加规则规格 +2. 在 `config_loader.dart` 添加类型定义(如需要新配置字段) +3. 在 `lib/src/rules/` 实现规则类 +4. 在 `test/fixtures/` 添加测试 fixture +5. 在 `test/scanner_test.dart` 添加测试 +6. 在 `scanner.dart:_analyze()` 中注册规则 +7. 在 `bin/flutterguard.dart` 的 help 文本中更新说明 +8. 运行 `melos run analyze && melos run test:cli` diff --git a/docs/WINDOWS_ASSESSMENT.md b/docs/WINDOWS_ASSESSMENT.md new file mode 100644 index 0000000..051cbb5 --- /dev/null +++ b/docs/WINDOWS_ASSESSMENT.md @@ -0,0 +1,241 @@ +# FlutterGuard Windows Availability Assessment + +> 更新日期: 2026-07-16 | 版本: 0.6.0 | 评估范围: flutterguard_cli 全部源码及依赖链 + +--- + +## 1. 总体评估:完全可用 + +FlutterGuard CLI 在 Windows 平台上**完全可用**,无需任何代码修改。代码库从设计阶段即考虑了跨平台兼容性,在依赖选择、路径处理、文件 I/O 等方面均采用纯 Dart 跨平台方案。 + +| 维度 | 状态 | 说明 | +|------|------|------| +| Dart SDK 兼容 | ✅ | Dart 3.11.5+ 全平台支持 (Windows/macOS/Linux) | +| 依赖链兼容 | ✅ | 5 个运行时依赖全部纯 Dart,零原生代码 | +| 路径处理 | ✅ | `package:path` + Context 抽象 + 反斜杠归一化 | +| 文件 I/O | ✅ | 仅使用 `dart:io` 跨平台 API | +| 测试覆盖 | ✅ | 3 个 Windows 专项路径测试 | +| 命令行输出 | ✅ | ANSI 转义码 — Win10+ Terminal 完整支持 | +| 原生编译 | ✅ | `dart compile exe` 产出独立 .exe | + +--- + +## 2. 依赖链逐项审查 + +### 2.1 运行时依赖 + +| 依赖 | 版本 | 类型 | Windows 兼容 | 备注 | +|------|------|------|-------------|------| +| `args` | ^2.5.0 | 纯 Dart | ✅ | CLI 参数解析 | +| `analyzer` | ^7.3.0 | 纯 Dart | ✅ | Dart AST 解析 | +| `glob` | ^2.1.2 | 纯 Dart | ✅ | 文件模式匹配 | +| `path` | ^1.9.0 | 纯 Dart | ✅ | 跨平台路径(含 Windows 风格 Context) | +| `yaml` | ^3.1.2 | 纯 Dart | ✅ | YAML 解析 | + +### 2.2 开发依赖 + +| 依赖 | 类型 | Windows 兼容 | +|------|------|-------------| +| `test` | 纯 Dart | ✅ | +| `lints` | 纯 Dart | ✅ | +| `melos` | 纯 Dart | ✅ | + +**结论**: 零原生依赖,零 FFI 调用。编译产物为单文件 native exe,无需安装 Dart runtime。 + +--- + +## 3. 代码级 Windows 兼容性分析 + +### 3.1 `dart:io` 使用情况 + +共 11 个源文件使用 `dart:io`,仅使用跨平台 API: + +| API | 用途 | Windows 兼容 | +|-----|------|-------------| +| `File.readAsStringSync()` | 读取源码 | ✅ | +| `File.readAsLinesSync()` | 按行读取 | ✅ | +| `File.writeAsStringSync()` | 写入报告 | ✅ | +| `File.existsSync()` | 文件存在检查 | ✅ | +| `Directory.existsSync()` | 目录存在检查 | ✅ | +| `Directory.createSync()` | 创建输出目录 | ✅ | +| `stdout.writeln()` | 控制台输出 | ✅ | +| `stderr.writeln()` | 错误输出 | ✅ | +| `exit()` | 进程退出 | ✅ | + +**未使用的 API**: `Platform.isWindows`, `Platform.isMacOS`, `Platform.operatingSystem`, `Process.run`, `Process.start`, `dart:ffi` — 均无引用。 + +### 3.2 路径处理策略 + +核心机制:`package:path` 的 `p.Context` 系统。 + +``` +p.Context(style: p.Style.windows, current: r'C:\repo') + └── 自动适配 \ 分隔符 → normalize / join / relative 等操作 +``` + +反斜杠归一化:以下 5 处代码显式将 `\` 转为 `/`,确保 `package:glob` 正常工作(glob 包要求正斜杠): + +| 文件 | 行号位置 | 上下文 | +|------|---------|--------| +| `file_collector.dart` | 15, 24 | include/exclude 模式 | +| `layer_violation.dart` | 34 | layer path 模式 | +| `module_violation.dart` | 34 | module path 模式 | +| `path_utils.dart` | 31 | matchesProjectGlob 内部 | + +测试中的 Windows 路径 Context 覆盖: + +```dart +// scanner_test.dart:302-314 — Windows 路径 glob 匹配测试 +final windows = p.Context(style: p.Style.windows, current: r'C:\repo'); +matchesProjectGlob( + r'C:\repo\lib\presentation\device_page.dart', + 'lib/presentation/**', + r'C:\repo', + context: windows, +) // → true + +// scanner_test.dart:331-345 — Windows 路径 package: 导入解析 +resolveImport( + r'C:\repo\lib\presentation\page.dart', + 'package:app/data/repo.dart', + {...}, + projectPath: r'C:\repo', + context: windows, +) // → C:\repo\lib\data\repo.dart +``` + +### 3.3 潜在风险点 + +| 风险 | 等级 | 影响 | 缓解 | +|------|------|------|------| +| 无 Windows CI 真实验证 | ⚠️ 低 | — | Windows Context 模拟测试已覆盖核心路径逻辑 | +| ANSI 转义码在旧 cmd.exe 不可见 | ⚠️ 低 | 显示原始转义字符,不影响功能 | Win10 1709+ 默认启用;建议在 Windows Terminal 中运行 | +| 驱动器号路径 (C:\\) | ✅ 无 | — | `p.Context(style: p.Style.windows)` 完整支持 | +| 长路径 (MAX_PATH > 260) | ⚠️ 低 | 极深嵌套项目可能遇到 | Dart SDK 3.11.5+ 已支持长路径 | +| 文件名大小写敏感 | ⚠️ 低 | Windows 文件系统不区分大小写,但 Dart 操作区分 | 影响极小 — glob 匹配和 import 解析均使用归一化路径比对 | + +--- + +## 4. 安装与运行 — Windows 完整步骤 + +### 4.1 前置条件 + +``` +1. 安装 Dart SDK 3.11.5+ + - 下载: https://dart.dev/get-dart + - Windows 安装包 (x64 / arm64) + - 验证: dart --version + +2. 确认 PATH 环境变量包含: + - Dart SDK bin 目录 (安装器自动配置) + - Pub cache bin: %USERPROFILE%\AppData\Local\Pub\Cache\bin +``` + +### 4.2 从源码编译 + +```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 +``` + +### 4.3 全局激活 + +```powershell +dart pub global activate --source path packages\flutterguard_cli +flutterguard --help +``` + +### 4.4 常见 Windows 问题排查 + +**问题 1**: `where flutterguard` 指向旧版本 + +```powershell +where flutterguard # 检查当前路径 +dart pub global deactivate flutterguard_cli # 删除旧版 +dart pub global activate --source path packages\flutterguard_cli # 安装当前版 +``` + +**问题 2**: PowerShell 显示 "API key required" + +说明系统解析到了另一个 FlutterGuard 二进制(旧版运行时追踪版本)。本仓库 CLI 是纯静态扫描工具,不需要 API Key。运行: + +```powershell +.\flutterguard.exe scan -p D:\your\project # 显式指定本地编译产物 +``` + +**问题 3**: 中文输出显示乱码 + +```powershell +# PowerShell 中设置 UTF-8 编码 +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +# 或使用 Windows Terminal(推荐),默认支持 UTF-8 +``` + +**问题 4**: 颜色/ANSI 转义码不显示 + +确认使用 Windows Terminal(Windows 10/11 自带)而非旧 cmd.exe。旧 cmd.exe 可能显示原始转义字符 `[31m` 等,不影响功能,仅影响外观。 + +--- + +## 5. 与 macOS 的差异对比 + +| 特性 | macOS | Windows | +|------|-------|---------| +| Dart SDK 安装 | `brew install dart` 或官网下载 | 官网安装包 | +| 全局命令路径 | `$HOME/.pub-cache/bin` | `%USERPROFILE%\AppData\Local\Pub\Cache\bin` | +| 路径分隔符 | `/` | `\`(代码已做归一化处理) | +| 原生二进制输出 | `flutterguard` | `flutterguard.exe` | +| 终端颜色 | 默认支持 | 需 Windows Terminal / Win10+ | +| 示例命令 | `flutterguard scan -p /Users/me/project` | `flutterguard scan -p D:\dev\project` | + +--- + +## 6. CI 集成 — Windows Runner + +### GitHub Actions + +```yaml +jobs: + flutterguard: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: 3.11.5 + - name: Install FlutterGuard + run: | + dart pub global activate flutterguard_cli + - name: Scan + run: flutterguard scan -p . --format json --fail-on high +``` + +### 本地 CI 脚本 (PowerShell) + +```powershell +# scan.ps1 +$score = & flutterguard scan -p . --format json --fail-on high --min-score 80 +if ($LASTEXITCODE -ne 0) { + Write-Host "CI gate failed!" -ForegroundColor Red + exit 1 +} +Write-Host "All checks passed!" -ForegroundColor Green +``` + +--- + +## 7. 总结 + +**FlutterGuard CLI 在 Windows 上已达到生产可用状态**。所有路径处理、文件 I/O、import 解析等核心逻辑均通过 `package:path` Context 系统进行了跨平台抽象,并在测试中覆盖了 Windows 路径场景。 + +**建议**: +1. 在真实 Windows 环境中运行一次全量测试,作为 CI 补充验证 +2. 使用 Windows Terminal 以获得最佳输出效果 +3. 如需要在旧 cmd.exe 运行,可考虑添加 `--no-color` 参数(建议后续版本实现) diff --git a/packages/flutterguard_cli/bin/flutterguard.dart b/packages/flutterguard_cli/bin/flutterguard.dart index ad79380..164d812 100644 --- a/packages/flutterguard_cli/bin/flutterguard.dart +++ b/packages/flutterguard_cli/bin/flutterguard.dart @@ -9,7 +9,7 @@ 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'; +const _version = '0.6.0'; void main(List args) { final normalizedArgs = _extractPositionalPath(args); diff --git a/packages/flutterguard_cli/pubspec.yaml b/packages/flutterguard_cli/pubspec.yaml index 89f78ea..e29ec56 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.5.0 +version: 0.6.0 repository: https://github.com/lizy-coding/flutterguard issue_tracker: https://github.com/lizy-coding/flutterguard/issues topics: @@ -11,7 +11,7 @@ topics: - cli environment: - sdk: ">=3.3.0 <4.0.0" + sdk: ^3.11.5 dependencies: args: ^2.5.0 diff --git a/packages/flutterguard_cli/test/cli_test.dart b/packages/flutterguard_cli/test/cli_test.dart index 72a879e..e08fa1c 100644 --- a/packages/flutterguard_cli/test/cli_test.dart +++ b/packages/flutterguard_cli/test/cli_test.dart @@ -16,6 +16,27 @@ void _runGit(Directory directory, List args) { } void main() { + test('CLI version matches package version', () { + final pubspec = + File(p.join(Directory.current.path, 'pubspec.yaml')).readAsStringSync(); + final version = RegExp(r'^version:\s*(\S+)', multiLine: true) + .firstMatch(pubspec)! + .group(1); + final entrypoint = p.join( + Directory.current.path, + 'bin', + 'flutterguard.dart', + ); + final result = Process.runSync( + Platform.resolvedExecutable, + [entrypoint, '--version'], + workingDirectory: Directory.current.path, + ); + + expect(result.exitCode, 0); + expect((result.stdout as String).trim(), 'flutterguard $version'); + }); + test('scan exits with setup error when the project matches no Dart files', () { final project = Directory.systemTemp.createTempSync( diff --git a/packages/flutterguard_cli/test/fixtures/state_suppression.dart b/packages/flutterguard_cli/test/fixtures/state_suppression.dart new file mode 100644 index 0000000..b744710 --- /dev/null +++ b/packages/flutterguard_cli/test/fixtures/state_suppression.dart @@ -0,0 +1,66 @@ +class SuppressedBuildWidget { + // flutterguard: ignore side_effect_in_build + Object build(Object context) { + notifyListeners(); + // flutterguard: ignore state_manager_created_in_build + final controller = SuppressedController(); + return controller; + } +} + +class SuppressedController { + // flutterguard: ignore mutable_state_exposed + int counter = 0; +} + +// flutterguard: ignore state_layer_ui_dependency +class SuppressedNavigationController { + void open(BuildContext context) { + Navigator.of(context).push('/device'); + } +} + +// flutterguard: ignore state_dependency_cycle +class SuppressedCycleController { + final SuppressedCycleService service; +} + +class SuppressedCycleService { + final SuppressedCycleController controller; +} + +class SuppressedRiverpodWidget { + Object build(Object context, dynamic ref) { + // flutterguard: ignore riverpod_read_used_for_render + return Text(ref.read(deviceProvider)); + } + + Object callbacks(dynamic ref) { + return Button( + // flutterguard: ignore riverpod_watch_in_callback + onPressed: () => ref.watch(deviceProvider), + ); + } +} + +// flutterguard: ignore bloc_equatable_props_incomplete +class SuppressedEquatableState extends Equatable { + final String name; + final bool connected; + + List get props => [name]; +} + +// flutterguard: ignore provider_value_lifecycle_misuse +final suppressedProvider = ChangeNotifierProvider.value( + value: SuppressedNotifier(), +); + +class SuppressedNotifier { + void update(List values) { + // flutterguard: ignore notify_listeners_in_loop + for (final value in values) { + notifyListeners(); + } + } +} From 3aa32bb5ec6a4d019d05f7e36c3c72b9861b08db Mon Sep 17 00:00:00 2001 From: lizy Date: Fri, 17 Jul 2026 21:47:07 +0800 Subject: [PATCH 11/16] chore: remove obsolete architecture assets --- .idea/.gitignore | 5 - .idea/.name | 1 - .idea/misc.xml | 5 - .idea/modules.xml | 10 - .idea/runConfigurations/melos_bootstrap.xml | 11 - .idea/runConfigurations/melos_clean.xml | 11 - .idea/runConfigurations/melos_run_analyze.xml | 11 - .idea/runConfigurations/melos_run_format.xml | 11 - .idea/runConfigurations/melos_run_test.xml | 11 - .../runConfigurations/melos_run_test_cli.xml | 11 - .idea/runConfigurations/run_trace_demo.xml | 7 - .idea/runConfigurations/scan_demo.xml | 7 - .idea/vcs.xml | 6 - CONFIGURATION_STRATEGY.md | 242 ----- PROJECT_RULES.md | 79 -- README.zh.md | 655 -------------- RELEASE_v0.3.0.md | 321 ------- ROADMAP.md | 185 ---- archive/README.md | 13 - archive/flutterguard_core/AGENTS.md | 42 - .../lib/flutterguard_core.dart | 9 - .../lib/src/flutter_guard.dart | 162 ---- .../lib/src/guard_config.dart | 23 - .../lib/src/json_exporter.dart | 31 - .../lib/src/markdown_exporter.dart | 116 --- .../lib/src/trace_context.dart | 29 - .../lib/src/trace_model.dart | 219 ----- .../lib/src/trace_store.dart | 97 -- archive/flutterguard_core/pubspec.yaml | 14 - .../test/flutter_guard_test.dart | 107 --- archive/flutterguard_dio/AGENTS.md | 28 - .../lib/flutterguard_dio.dart | 3 - .../lib/src/dio_interceptor.dart | 91 -- archive/flutterguard_dio/pubspec.yaml | 16 - .../test/dio_interceptor_test.dart | 104 --- archive/flutterguard_flutter/AGENTS.md | 34 - .../lib/flutterguard_flutter.dart | 7 - .../lib/src/flutter_guard.dart | 122 --- .../lib/src/guard_boundary.dart | 19 - .../lib/src/route_observer.dart | 56 -- archive/flutterguard_flutter/pubspec.yaml | 19 - .../test/flutter_test.dart | 84 -- archive/usage_demo/README.md | 11 - archive/usage_demo/analysis_options.yaml | 3 - archive/usage_demo/bin/trace_demo.dart | 52 -- archive/usage_demo/pubspec.yaml | 10 - do | 75 -- docs/USAGE.md | 855 ------------------ docs/WINDOWS_ASSESSMENT.md | 241 ----- opencode.json | 9 - 50 files changed, 4290 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/.name delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/runConfigurations/melos_bootstrap.xml delete mode 100644 .idea/runConfigurations/melos_clean.xml delete mode 100644 .idea/runConfigurations/melos_run_analyze.xml delete mode 100644 .idea/runConfigurations/melos_run_format.xml delete mode 100644 .idea/runConfigurations/melos_run_test.xml delete mode 100644 .idea/runConfigurations/melos_run_test_cli.xml delete mode 100644 .idea/runConfigurations/run_trace_demo.xml delete mode 100644 .idea/runConfigurations/scan_demo.xml delete mode 100644 .idea/vcs.xml delete mode 100644 CONFIGURATION_STRATEGY.md delete mode 100644 PROJECT_RULES.md delete mode 100644 README.zh.md delete mode 100644 RELEASE_v0.3.0.md delete mode 100644 ROADMAP.md delete mode 100644 archive/README.md delete mode 100644 archive/flutterguard_core/AGENTS.md delete mode 100644 archive/flutterguard_core/lib/flutterguard_core.dart delete mode 100644 archive/flutterguard_core/lib/src/flutter_guard.dart delete mode 100644 archive/flutterguard_core/lib/src/guard_config.dart delete mode 100644 archive/flutterguard_core/lib/src/json_exporter.dart delete mode 100644 archive/flutterguard_core/lib/src/markdown_exporter.dart delete mode 100644 archive/flutterguard_core/lib/src/trace_context.dart delete mode 100644 archive/flutterguard_core/lib/src/trace_model.dart delete mode 100644 archive/flutterguard_core/lib/src/trace_store.dart delete mode 100644 archive/flutterguard_core/pubspec.yaml delete mode 100644 archive/flutterguard_core/test/flutter_guard_test.dart delete mode 100644 archive/flutterguard_dio/AGENTS.md delete mode 100644 archive/flutterguard_dio/lib/flutterguard_dio.dart delete mode 100644 archive/flutterguard_dio/lib/src/dio_interceptor.dart delete mode 100644 archive/flutterguard_dio/pubspec.yaml delete mode 100644 archive/flutterguard_dio/test/dio_interceptor_test.dart delete mode 100644 archive/flutterguard_flutter/AGENTS.md delete mode 100644 archive/flutterguard_flutter/lib/flutterguard_flutter.dart delete mode 100644 archive/flutterguard_flutter/lib/src/flutter_guard.dart delete mode 100644 archive/flutterguard_flutter/lib/src/guard_boundary.dart delete mode 100644 archive/flutterguard_flutter/lib/src/route_observer.dart delete mode 100644 archive/flutterguard_flutter/pubspec.yaml delete mode 100644 archive/flutterguard_flutter/test/flutter_test.dart delete mode 100644 archive/usage_demo/README.md delete mode 100644 archive/usage_demo/analysis_options.yaml delete mode 100644 archive/usage_demo/bin/trace_demo.dart delete mode 100644 archive/usage_demo/pubspec.yaml delete mode 100644 do delete mode 100644 docs/USAGE.md delete mode 100644 docs/WINDOWS_ASSESSMENT.md delete mode 100644 opencode.json diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 85cdef9..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Local generated files -/workspace.xml -/shelf/ -/caches/ -/libraries/ diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index 707417d..0000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -flutterguard \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index c08a2df..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 0aa6fe4..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/melos_bootstrap.xml b/.idea/runConfigurations/melos_bootstrap.xml deleted file mode 100644 index 0365420..0000000 --- a/.idea/runConfigurations/melos_bootstrap.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/.idea/runConfigurations/melos_clean.xml b/.idea/runConfigurations/melos_clean.xml deleted file mode 100644 index 82bd956..0000000 --- a/.idea/runConfigurations/melos_clean.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/.idea/runConfigurations/melos_run_analyze.xml b/.idea/runConfigurations/melos_run_analyze.xml deleted file mode 100644 index 9ba7da1..0000000 --- a/.idea/runConfigurations/melos_run_analyze.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/.idea/runConfigurations/melos_run_format.xml b/.idea/runConfigurations/melos_run_format.xml deleted file mode 100644 index 27d94cf..0000000 --- a/.idea/runConfigurations/melos_run_format.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/.idea/runConfigurations/melos_run_test.xml b/.idea/runConfigurations/melos_run_test.xml deleted file mode 100644 index 23f9c3d..0000000 --- a/.idea/runConfigurations/melos_run_test.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/.idea/runConfigurations/melos_run_test_cli.xml b/.idea/runConfigurations/melos_run_test_cli.xml deleted file mode 100644 index 559a0b1..0000000 --- a/.idea/runConfigurations/melos_run_test_cli.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/.idea/runConfigurations/run_trace_demo.xml b/.idea/runConfigurations/run_trace_demo.xml deleted file mode 100644 index d339bc0..0000000 --- a/.idea/runConfigurations/run_trace_demo.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/.idea/runConfigurations/scan_demo.xml b/.idea/runConfigurations/scan_demo.xml deleted file mode 100644 index b753f2d..0000000 --- a/.idea/runConfigurations/scan_demo.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/CONFIGURATION_STRATEGY.md b/CONFIGURATION_STRATEGY.md deleted file mode 100644 index 027ad4e..0000000 --- a/CONFIGURATION_STRATEGY.md +++ /dev/null @@ -1,242 +0,0 @@ -# FlutterGuard Configuration Strategy - -FlutterGuard should be easy to start and precise when a project needs stricter -architecture gates. The recommended product strategy is: use short CLI responses -for immediate next steps, and keep this dedicated guide for the complete mental -model. - -## Decision - -Use both surfaces, with different responsibilities: - -| Surface | Purpose | Content | -|---------|---------|---------| -| CLI help and scan responses | Immediate action | The next command, likely fix, and the shortest config path | -| Dedicated configuration guide | Full explanation | Configuration levels, architecture examples, CI patterns, and troubleshooting | - -Do not put the full manual into CLI output. CLI text should answer "what should -I do next?" in a few lines. The guide should answer "how should this be -configured for my project?" - -## Configuration Levels - -### Level 0: Zero Config - -Best for first scans and demos. - -```bash -flutterguard scan -flutterguard scan ./my_flutter_app -``` - -Behavior: - -- Scans `lib/**` -- Excludes generated/freezed/mock files -- Runs all default non-boundary rules -- Does not enforce layers/modules unless they are declared - -Use this level when the user wants a quick signal and has not agreed on project -architecture boundaries yet. No YAML file is required. - -### Level 1: Basic Project Config - -Best for normal local development. - -```yaml -include: - - lib/** - -exclude: - - lib/generated/** - - lib/**.g.dart - - lib/**.freezed.dart - - lib/**.mocks.dart - -rules: - large_file: - enabled: true - maxLines: 500 - large_class: - enabled: true - maxLines: 300 - large_build_method: - enabled: true - maxLines: 80 - lifecycle_resource: - enabled: true - missing_const_constructor: - enabled: true - device_lifecycle: - enabled: true - mqtt_connection: - enabled: true - ble_scanning: - enabled: true - maxScanDurationMs: 10000 - iot_security: - enabled: true - requireTls: true - pubspec_security: - enabled: true - side_effect_in_build: - enabled: true - severity: high - allowlist: [] - ignore_paths: [] - state_manager_created_in_build: - enabled: true - severity: high - allowlist: [] - ignore_paths: [] - mutable_state_exposed: - enabled: true - severity: medium - allowlist: [] - ignore_paths: [] - state_layer_ui_dependency: - enabled: true - severity: high - allowlist: [] - ignore_paths: [] - state_dependency_cycle: - enabled: true - severity: high - allowlist: [] - ignore_paths: [] - riverpod_read_used_for_render: - enabled: true - severity: medium - allowlist: [] - ignore_paths: [] - riverpod_watch_in_callback: - enabled: true - severity: medium - allowlist: [] - ignore_paths: [] - bloc_equatable_props_incomplete: - enabled: true - severity: medium - allowlist: [] - ignore_paths: [] - provider_value_lifecycle_misuse: - enabled: true - severity: medium - allowlist: [] - ignore_paths: [] - notify_listeners_in_loop: - enabled: true - severity: medium - allowlist: [] - ignore_paths: [] - -state_management: - enabled: true - framework_auto_detect: true - confidence_threshold: certain -``` - -Use this level when teams want stable thresholds or custom excludes. - -The top-level state-management switch controls all ten state rules. Per-rule -`enabled`, `severity`, `allowlist`, and project-relative `ignore_paths` settings -can then narrow the policy without changing suppression or baseline behavior. - -Create it with: - -```bash -flutterguard init -flutterguard config doctor -``` - -### Level 2: CI Gate Config - -Best for pull requests and release checks. - -```bash -flutterguard scan . --format json --fail-on high --min-score 80 -``` - -Recommended policy: - -- Start with `--fail-on high` -- Add `--min-score 80` once the baseline is clean enough -- Avoid `--fail-on low` early in adoption because it can create noisy rollouts -- Run `flutterguard config doctor` before adding CI gates - -### Level 3: Architecture Config - -Best when the project has agreed boundaries. - -Create a starter template with: - -```bash -flutterguard init --with-architecture -``` - -```yaml -architecture: - layers: - - name: presentation - path: lib/presentation/** - allowed_deps: [domain, core] - - name: domain - path: lib/domain/** - allowed_deps: [core] - - name: data - path: lib/data/** - allowed_deps: [domain, core] - - name: core - path: lib/core/** - allowed_deps: [] - - modules: - - name: mqtt_feature - path: lib/features/mqtt/** - allowed_deps: [shared] - - name: ble_feature - path: lib/features/ble/** - allowed_deps: [shared] - - name: shared - path: lib/shared/** - allowed_deps: [] - - detect_cycles: true - layer_violation: - enabled: true - module_violation: - enabled: true -``` - -Important distinction: - -- `architecture.layers[].allowed_deps` must reference layer names. -- `architecture.modules[].allowed_deps` must reference module names. -- Layers describe technical direction such as presentation/domain/data/core. -- Modules describe business or feature isolation such as mqtt/ble/shared. - -## CLI Response Policy - -CLI responses should stay short and operational: - -- `--help`: show common commands, option summary, and the four-level config path -- full scan with no matched files: fail as a setup error and point to include/exclude patterns -- changed-only scan with no relevant changes: succeed with an empty changed-mode report -- config parse error: show the failing config key and expected value type -- CI gate failure: show the failing threshold and direct the user to JSON output - -Avoid long explanations in command output. Point to this guide when the user -needs examples or architecture detail. - -## Config Tooling - -The core configuration helpers are available as CLI commands: - -- `flutterguard init`: write a minimal `flutterguard.yaml` -- `flutterguard init --with-architecture`: write a layered/module template -- `flutterguard config print`: show the merged effective config -- `flutterguard config doctor`: validate globs, unknown deps, empty matches, and - architecture overlap - -These commands are preferable to adding more prose to `scan`, because they keep -scan output focused while still making configuration discoverable. diff --git a/PROJECT_RULES.md b/PROJECT_RULES.md deleted file mode 100644 index e6a5dc6..0000000 --- a/PROJECT_RULES.md +++ /dev/null @@ -1,79 +0,0 @@ -# Project Rules for FlutterGuard - -## 1. Identity -- FlutterGuard is an **IoT/smart home Flutter project static analysis CLI plugin**. -- NOT a general observability SDK, APM tool, or crash reporter. -- Output: compiled CLI binary (`dart compile exe`) that scans Dart source code. - -## 2. Active vs Archived - -| Path | Status | Package | Notes | -|------|--------|---------|-------| -| **Path A** — CLI static analysis | **ACTIVE** | `flutterguard_cli` | All new features | -| **Path B** — Runtime tracing | **ARCHIVED** | `core`, `dio`, `flutter` | In `archive/` for future reference | - -Do NOT modify archived packages. They are preserved as-is for reference. - -## 3. Architecture Constraints -- Monorepo managed by **melos**. New packages must register in `melos.yaml`. -- Active code lives in `packages/flutterguard_cli/lib/src/`. -- Each rule is a standalone class: `analyze(List files) → List`. -- Configuration driven by `flutterguard.yaml` (YAML schema, parsed in `config_loader.dart`). -- No plugin system, no code generation, no reflection — explicit wiring in `bin/flutterguard.dart`. - -## 4. Code Conventions -- Dart 3.3+, `strict-casts: true`, `strict-inference: true`. -- Prefer Dart **records** (`typedef`) for config types. -- Prefer `const` constructors and `final` locals. -- Wrap per-file parsing in try/catch (one bad file must not crash the scan). -- Import style: `package:flutterguard_cli/src/...` (no relative imports across packages). - -## 5. Testing Conventions -- Tests live in `packages/flutterguard_cli/test/` using `package:test`. -- Fixtures go in `test/fixtures/.dart`. -- Every new rule requires: spec entry → config typedef → rule class → fixture → test. -- Run tests: `melos run test:cli`. - -## 6. Spec Governance -- `docs/FLUTTERGUARD_SPEC.md` is the **single source of truth**. -- Spec must be updated **before** implementation. -- All rule IDs, risk levels, and metadata schemas must be documented in spec first. - -## 7. Forbidden -- Do NOT create new Flutter widgets or UI components. -- Do NOT add web/cloud infrastructure or dashboard UI. -- Do NOT use third-party SaaS SDKs. -- Do NOT commit secrets, API keys, or credentials. -- Do NOT add runtime instrumentation outside archived packages. -- Do NOT introduce `package:build` / code generation dependencies. - -## 8. Git Workflow -- Branch: `develop` for active work. PRs merge to `develop`. -- Commit messages: imperative mood, prefixed by scope (`cli:`, `spec:`, `docs:`). -- Before committing: run `melos run analyze` and `melos run test:cli`. -- Do NOT force-push to `develop` or `main`. - -## 9. Override Hierarchy - -### 9.1 pubspec_overrides.yaml -Managed by `melos bootstrap`. No path dependencies currently exist for `flutterguard_cli`. - -**Rule**: Do NOT edit manually. Re-run `melos bootstrap` after any pubspec.yaml change. - -### 9.2 analysis_options.yaml Inheritance Chain -``` -root/analysis_options.yaml # strict-casts + strict-inference + 6 lint rules -└── packages/flutterguard_cli/... # inherits root + package:lints/recommended + excludes test/fixtures/** -``` - -**Rule**: Keep corporate-wide strictness at root. Per-package loosening only for legitimate reasons (fixture code, print-based demos). - -### 9.3 flutterguard.yaml Config Override Chain -``` - -├── --config # explicit project-relative or absolute config -├── flutterguard.yaml # default project config when no override is given -└── built-in defaults # only when no override is given and the default file is absent -``` - -**Rule**: Config files do not merge across projects. Relative paths resolve from the target project root, and explicitly selected files must exist. diff --git a/README.zh.md b/README.zh.md deleted file mode 100644 index 3b20a1d..0000000 --- a/README.zh.md +++ /dev/null @@ -1,655 +0,0 @@ -# FlutterGuard - -> 面向 IoT / 智能家居 Flutter 项目的静态架构扫描 CLI,用于架构约束、代码质量检查和 CI 门禁。 - -[English](README.md) | [中文](README.zh.md) - -FlutterGuard 扫描 Flutter/Dart 源码,报告架构边界违规、生命周期资源泄漏、循环依赖、过大文件/类/`build` 方法,以及常见代码规范问题。当前活动开发路径是 `packages/flutterguard_cli/`;旧的运行时追踪包已归档在 `archive/`。 - -**支持平台**: macOS、Windows、Linux — 纯 Dart CLI,零原生依赖。 - -**文档**: [使用指南](docs/USAGE.md) | [配置策略](CONFIGURATION_STRATEGY.md) | [Windows 评估](docs/WINDOWS_ASSESSMENT.md) | [技术规格](docs/FLUTTERGUARD_SPEC.md) | [架构](docs/ARCHITECTURE.md) - -## 它是什么 - -- 静态分析命令行工具 -- 基于 YAML 配置的架构约束工具 -- 面向 IoT / 智能家居 Flutter 项目的规则集 -- 可按严重等级或评分失败的 CI 门禁 - -## 它不是什么 - -- 不是运行时观测 SDK 或 APM -- 不是 Crashlytics / Sentry 替代品 -- 不是 HTTP 抓包、日志库或云端 SaaS -- 不需要 API key,也不会上传 APK - -## 环境要求 - -- Dart SDK 3.11.5 或更高版本 -- 从源码开发时需要 `melos` -- 支持操作系统: macOS、Windows、Linux - ---- - -## 安装 - -### 方式 A:pub.dev 安装(推荐) - -
-macOS / Linux - -```bash -dart pub global activate flutterguard_cli - -# 验证安装 -flutterguard --version -``` - -确认 `$HOME/.pub-cache/bin` 在 `PATH` 中: - -```bash -export PATH="$PATH:$HOME/.pub-cache/bin" # 添加到 ~/.zshrc 或 ~/.bashrc -``` -
- -
-Windows (PowerShell) - -```powershell -dart pub global activate flutterguard_cli - -# 验证安装 -flutterguard --version -``` - -若 `flutterguard` 命令未识别,确认 `%USERPROFILE%\AppData\Local\Pub\Cache\bin` 在 `PATH` 中: - -```powershell -$env:Path += ";$env:USERPROFILE\AppData\Local\Pub\Cache\bin" -``` -
- -### 方式 B:GitHub Release 二进制(运行时无需 Dart 环境) - -从 GitHub Releases 下载对应平台的二进制后直接运行: - -
-macOS / Linux - -```bash -chmod +x flutterguard -./flutterguard --version -./flutterguard scan . -``` -
- -
-Windows (PowerShell) - -```powershell -.\flutterguard.exe --version -.\flutterguard.exe scan . -``` -
- -### 方式 C:源码开发运行 - -如果你希望直接运行当前 checkout 的源码,不替换全局 `flutterguard` -命令,使用本地 launcher。 - -
-macOS / Linux - -```bash -git clone https://github.com/lizy-coding/flutterguard.git -cd flutterguard -dart pub get -./scripts/flutterguard-dev --version -./scripts/flutterguard-dev scan . -``` -
- -
-Windows (PowerShell) - -```powershell -git clone https://github.com/lizy-coding/flutterguard.git -cd flutterguard -dart pub get -.\scripts\flutterguard-dev.ps1 --version -.\scripts\flutterguard-dev.ps1 scan . -``` -
- ---- - -## 快速开始 - -```bash -# 扫描当前目录 -flutterguard scan - -# 创建并检查基础配置 -flutterguard init --profile migration -flutterguard config doctor -flutterguard doctor install - -# 查看合并后的有效配置 -flutterguard config print - -# 扫描指定项目 -flutterguard scan ./my_flutter_app # macOS / Linux -flutterguard scan .\my_flutter_app # Windows - -# 使用 --path 标志 -flutterguard scan -p /path/to/project # macOS / Linux -flutterguard scan -p D:\path\to\project # Windows - -# JSON 输出 + CI 门禁 -flutterguard scan . --format json --fail-on high - -# 启用强门禁前先为历史问题创建 baseline -flutterguard baseline create . -flutterguard baseline stats -flutterguard baseline check . --baseline .flutterguard/baseline.json --no-growth -flutterguard scan . --baseline .flutterguard/baseline.json --fail-on high - -# GitHub Code Scanning 输出 -flutterguard scan . --format sarif --baseline .flutterguard/baseline.json - -# 导出单个问题用于误报反馈 -flutterguard issue export --rule mqtt_connection --file lib/device/mqtt.dart --line 42 - -# 显示帮助 -flutterguard --help -flutterguard scan --help -``` - -### 扫描示例项目 - -```bash -flutterguard scan examples/scan_demo -``` - ---- - -## CLI 参考 - -命令: - -| 命令 | 说明 | -|------|------| -| `flutterguard scan []` | 扫描项目(路径默认为当前目录) | -| `flutterguard baseline create []` | 为现有问题创建 baseline JSON 文件 | -| `flutterguard baseline stats` | 查看 baseline fingerprint 数量 | -| `flutterguard baseline prune []` | 从 baseline 移除已修复问题 | -| `flutterguard baseline check [] --no-growth` | 当前问题未进入 baseline 时失败 | -| `flutterguard doctor install` | 检查可执行文件版本和 PATH 冲突 | -| `flutterguard init` | 创建基础 `flutterguard.yaml` | -| `flutterguard init --profile migration` | 使用 profile 创建基础配置 | -| `flutterguard init --with-architecture` | 创建包含架构层/模块模板的配置 | -| `flutterguard config print` | 输出合并后的有效配置 | -| `flutterguard config doctor` | 检查配置、glob 和架构引用 | -| `flutterguard issue export` | 导出单个问题为本地反馈 JSON | -| `flutterguard rules` | 列出所有可用规则 | -| `flutterguard explain ` | 查看单条规则说明 | -| `flutterguard --help` / `-h` | 显示帮助 | -| `flutterguard --version` / `-V` | 显示版本 | - -### 扫描参数 - -| 参数 | 简写 | 默认值 | 说明 | -|------|------|--------|------| -| `` | — | `.` | 位置参数,项目路径(可选,放在选项之前) | -| `--path` | `-p` | `.` | 项目路径(被 `` 位置参数覆盖) | -| `--config` | `-c` | `flutterguard.yaml` | 配置文件路径 | -| `--format` | `-f` | `table` | 输出格式:`table`、`json` 或 `sarif` | -| `--output` | `-o` | `.flutterguard` | 报告输出目录 | -| `--verbose` | `-v` | 关闭 | 显示详细代码上下文 | -| `--no-color` | — | 关闭 | 禁用 ANSI 终端颜色 | -| `--changed-only` | — | 关闭 | 只扫描相对 `--base` 变更的 Dart 文件 | -| `--base` | — | `main` | `--changed-only` 使用的 Git base ref | -| `--baseline` | — | 不设 | 用于隐藏历史问题的 baseline JSON 文件 | -| `--fail-on` | — | `none` | CI 门禁等级:`none` / `high` / `medium` / `low` | -| `--min-score` | — | 不设 | 最低可接受评分,0–100 | -| `--help` | `-h` | — | 显示 scan 帮助 | - -### 退出码 - -| 退出码 | 含义 | -|--------|------| -| `0` | 成功,包含 help/version 以及增量扫描没有相关变更的情况 | -| `1` | CI 门禁失败(存在超过 `--fail-on` 的问题,或评分低于 `--min-score`)| -| `2` | 扫描设置错误(路径不存在、显式配置缺失、配置无效或未匹配到配置范围内的 Dart 文件) | - -### 路径解析 - -FlutterGuard 从当前目录向上遍历,自动发现项目根目录(查找 `flutterguard.yaml`、`pubspec.yaml` 或 `lib/` 目录)。若未找到,则退化为当前目录。 - -`--config` 路径始终针对目标项目解析: -1. 绝对路径直接使用,且文件必须存在。 -2. 相对路径从目标项目根目录解析,不再读取 CWD 下的同名文件。 -3. 未显式指定且默认 `flutterguard.yaml` 不存在时使用内置默认值;任何显式选择的配置都必须存在。 - ---- - -## 配置文件 - -在项目根目录创建 `flutterguard.yaml`。 - -推荐策略: - -1. 先零配置运行:`flutterguard scan`。 -2. 需要自定义阈值或排除文件时,运行 `flutterguard init`。 -3. 使用 `flutterguard config print` 查看合并后的默认值。 -4. 启用 CI 门禁前先运行 `flutterguard config doctor`。 -5. 只有项目边界已经明确时,再添加 architecture layers/modules。 - -完整决策模型见 [配置策略](CONFIGURATION_STRATEGY.md)。 - -### 基础配置(大多数用户适用) - -```yaml -include: - - lib/** - -exclude: - - lib/generated/** - - lib/**.g.dart - - lib/**.freezed.dart - - lib/**.mocks.dart - -rules: - large_file: - enabled: true - maxLines: 500 - large_class: - enabled: true - maxLines: 300 - large_build_method: - enabled: true - maxLines: 80 - lifecycle_resource: - enabled: true - missing_const_constructor: - enabled: true - device_lifecycle: - enabled: true - mqtt_connection: - enabled: true - ble_scanning: - enabled: true - maxScanDurationMs: 10000 - iot_security: - enabled: true - requireTls: true - pubspec_security: - enabled: true - side_effect_in_build: - enabled: true - severity: high - allowlist: [] - ignore_paths: [] - riverpod_read_used_for_render: - enabled: true - severity: medium - -state_management: - enabled: true - framework_auto_detect: true - confidence_threshold: certain -``` - -10 条状态管理规则均支持 `enabled`、`severity`、`allowlist` 和项目相对 POSIX -`ignore_paths`。以上只展示共享结构;`flutterguard config print` 会输出所有规则的完整生效配置。 - -### 完整配置(含架构约束) - -```yaml -# ... include/exclude/rules 同基础配置 ... - -architecture: - layers: - - name: presentation - path: lib/presentation/** - allowed_deps: [domain, core] - - name: domain - path: lib/domain/** - allowed_deps: [core] - - name: data - path: lib/data/** - allowed_deps: [domain, core] - - name: core - path: lib/core/** - allowed_deps: [] - - modules: - - name: device_mqtt - path: lib/device/mqtt/** - allowed_deps: [domain, core] - - name: device_ble - path: lib/device/ble/** - allowed_deps: [domain, core] - - detect_cycles: true - layer_violation: - enabled: true - module_violation: - enabled: true -``` - -> **注意**: 架构规则(`layer_violation`、`module_violation`、`circular_dependency`)需要在配置中**显式声明** `architecture.layers`、`architecture.modules` 和/或 `architecture.detect_cycles`。它们不会自动发现项目边界。 - -> **Glob 模式约定**: 无论在什么平台上,YAML 配置中的 glob 模式均使用正斜杠 `/`。切勿使用反斜杠。 - ---- - -## 检测规则 - -| 规则 ID | 等级 | 领域 | 优先级 | 检测内容 | 配置要求 | -|---------|------|------|--------|----------|----------| -| `large_file` | LOW | standards | P2 | 文件行数超过 `maxLines` | — | -| `large_class` | LOW | standards | P2 | 类体行数超过 `maxLines` | — | -| `large_build_method` | MEDIUM | performance | P1 | `build()` 方法行数超过 `maxLines` | — | -| `lifecycle_resource_not_disposed` | MEDIUM | performance | P1 | 未释放的 StreamSubscription、Timer、AnimationController、TextEditingController、ScrollController、FocusNode、MqttClient、BluetoothDevice、StreamController | — | -| `missing_const_constructor` | LOW | standards | P2 | Widget 类缺少 `const` 构造函数 | — | -| `layer_violation` | HIGH | architecture | P0 | 跨架构层的依赖违规 | `architecture.layers` * | -| `module_violation` | HIGH | architecture | P0 | 跨业务模块的依赖违规 | `architecture.modules` * | -| `circular_dependency` | MEDIUM | architecture | P1 | 文件级循环依赖 | `architecture.detect_cycles` * | -| `device_lifecycle` | HIGH | architecture | P0 | 不平衡的 init/teardown 配对(initState↔dispose、connect↔disconnect 等) | — | -| `mqtt_connection` | HIGH | architecture | P0 | MQTT connect/disconnect 配对、硬编码 broker URL | — | -| `iot_security` | HIGH | architecture | P0 | 硬编码凭证、明文 MQTT/HTTP、不安全 BLE | `rules.iot_security.requireTls` | -| `ble_scanning` | MEDIUM | architecture | P1 | BLE startScan/stopScan 配对、扫描超时 | `rules.ble_scanning.maxScanDurationMs` | -| `pubspec_security` | MEDIUM | standards | P2 | 无界依赖、已废弃包、过旧 IoT 依赖版本 | — | -| `side_effect_in_build` | HIGH | performance | P0 | build 阶段执行状态或资源副作用 | — | -| `state_manager_created_in_build` | HIGH | performance | P0 | build 中创建 Controller/Bloc/Notifier | — | -| `mutable_state_exposed` | MEDIUM | architecture | P1 | 公开可变状态或原地修改 state 集合 | — | -| `state_layer_ui_dependency` | HIGH | architecture | P0 | 状态 owner 依赖 BuildContext、Widget、导航或主题 API | — | -| `state_dependency_cycle` | HIGH | architecture | P0 | Provider、状态 owner 与可达 service 之间的依赖环 | — | -| `riverpod_read_used_for_render` | MEDIUM | performance | P1 | `ref.read` 的值进入渲染输出 | Riverpod import * | -| `riverpod_watch_in_callback` | MEDIUM | performance | P1 | 事件或异步回调内调用 `ref.watch` | Riverpod import * | -| `bloc_equatable_props_incomplete` | MEDIUM | standards | P1 | Equatable final 字段遗漏于 `props` | Bloc + Equatable import * | -| `provider_value_lifecycle_misuse` | MEDIUM | performance | P1 | Provider `.value`/`create` 所有权模式反用 | Provider/Bloc import * | -| `notify_listeners_in_loop` | MEDIUM | performance | P1 | 重复循环内调用 `notifyListeners()` | Provider/Bloc import * | - -* 架构项需显式 YAML;框架 import 默认自动识别,可设置 `state_management.framework_auto_detect: false` 改用纯 AST 形态匹配。 - ---- - -## 输出 - -### 终端表格(默认) - -按领域分组的彩色终端报告,显示总评分、文件数、问题数及每个问题的详情。 - -### JSON 报告 - -`--format json` 将报告写入 `--output` 目录下的 `report.json`。 - -示例结构: - -```json -{ - "version": "1.0.0", - "generatedAt": "2026-06-09T12:00:00.000Z", - "projectPath": "/path/to/project", - "score": 85, - "summary": { - "total": 3, - "high": 1, - "medium": 1, - "low": 1, - "suppressed": 0, - "suppressedByBaseline": 0, - "byDomain": { - "architecture": { "high": 1, "medium": 0, "low": 0, "total": 1 } - } - }, - "issues": [] -} -``` - -### SARIF 报告 - -`--format sarif` 会写入 `.flutterguard/report.sarif`,可上传到 GitHub Code Scanning。high、medium、low 分别映射为 SARIF `error`、`warning`、`note`。 - -### Suppression 与 baseline - -对已确认的误报可以使用源码注释: - -```dart -// flutterguard: ignore missing_const_constructor -// flutterguard: ignore iot_security, mqtt_connection -// flutterguard: ignore all -``` - -注释只作用于当前行和下一行。 - -推荐 CI 接入顺序: - -```bash -flutterguard config doctor -flutterguard baseline create . -flutterguard baseline check . --baseline .flutterguard/baseline.json --no-growth -flutterguard scan . --baseline .flutterguard/baseline.json --format json --fail-on high -``` - -## 评分 - -``` -score = max(0, 100 - high×10 - medium×4 - low×1) -``` - -| 分数段 | 等级 | -|--------|------| -| 80–100 | 优秀 | -| 50–79 | 需关注 | -| 0–49 | 需整改 | - ---- - -## CI 集成 - -### GitHub Actions - -```yaml -name: FlutterGuard - -on: [push, pull_request] - -jobs: - scan: - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - uses: dart-lang/setup-dart@v1 - with: - sdk: 3.11.5 - - name: Install FlutterGuard - run: dart pub global activate flutterguard_cli - - name: Scan - run: flutterguard scan . --format json --baseline .flutterguard/baseline.json --fail-on high --min-score 80 -``` - -### GitHub Code Scanning - -```yaml -name: FlutterGuard SARIF - -on: [push, pull_request] - -jobs: - code-scanning: - runs-on: ubuntu-latest - permissions: - security-events: write - contents: read - steps: - - uses: actions/checkout@v4 - - uses: dart-lang/setup-dart@v1 - with: - sdk: 3.11.5 - - run: dart pub global activate flutterguard_cli - - run: flutterguard scan . --format sarif --baseline .flutterguard/baseline.json - - uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: .flutterguard/report.sarif -``` - -### GitLab CI - -```yaml -flutterguard: - image: dart:3.11.5 - script: - - dart pub global activate flutterguard_cli - - flutterguard scan . --format json --fail-on high --min-score 80 - artifacts: - paths: - - .flutterguard/report.json - when: always -``` - -### pre-commit hook - -```yaml -# .pre-commit-config.yaml -repos: - - repo: local - hooks: - - id: flutterguard - name: FlutterGuard scan - entry: flutterguard scan . --fail-on high - language: system - pass_filenames: false - always_run: true -``` - -### 本地脚本 - -
-macOS / Linux - -```bash -#!/usr/bin/env bash -# scan_ci.sh -if flutterguard scan . --format json --fail-on high --min-score 80; then - echo "All checks passed!" -else - status=$? - echo "FlutterGuard failed with exit code $status." - exit "$status" -fi -``` -
- -
-Windows (PowerShell) - -```powershell -# scan_ci.ps1 -$ErrorActionPreference = "Stop" -flutterguard scan . --format json --fail-on high --min-score 80 -$status = $LASTEXITCODE - -if ($status -eq 0) { - Write-Host "All checks passed!" -ForegroundColor Green -} else { - Write-Host "FlutterGuard failed with exit code $status." -ForegroundColor Red - exit $status -} -``` -
- ---- - -## 常见问题 - -### Windows: ANSI 颜色显示为原始转义字符 - -使用 **Windows Terminal**(Windows 10/11 自带)而非旧版 cmd.exe。也可添加 `--no-color` 禁用 ANSI 输出: - -```powershell -flutterguard scan . --no-color -``` - -### Windows: "API key required" 错误 - -说明当前 shell 解析到了旧版全局二进制。显式运行当前目录编译产物: - -```powershell -.\flutterguard.exe scan . -``` - -或重新安装: - -```powershell -dart pub global deactivate flutterguard_cli -dart pub global activate flutterguard_cli -``` - -### Windows: 中文输出显示乱码 - -```powershell -# PowerShell 中设置 UTF-8 编码 -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 -# 推荐使用 Windows Terminal,默认支持 UTF-8 -``` - -### glob 模式始终使用正斜杠 - -在 `flutterguard.yaml` 中,所有平台的路径模式均使用 `/`: - -```yaml -# 正确 -path: lib/presentation/** - -# 错误(Windows 也不要用反斜杠) -path: lib\presentation\** -``` - ---- - -## 仓库结构 - -``` -flutterguard/ -├── packages/ -│ └── flutterguard_cli/ CLI 实现(主开发路径) -├── archive/ 已归档的运行时追踪包 -└── examples/ - └── scan_demo/ 扫描示例项目 -``` - -## 开发 - -```bash -# 全平台通用 -git clone https://github.com/lizy-coding/flutterguard.git -cd flutterguard -dart pub get -dart pub global activate melos -melos bootstrap - -# 常用命令 -dart run melos run analyze # 静态分析 -dart run melos run test:cli # 运行测试 -dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard -``` - -## 扩展阅读 - -| 文档 | 内容 | -|------|------| -| [docs/USAGE.md](docs/USAGE.md) | 完整使用指南(全平台) | -| [docs/WINDOWS_ASSESSMENT.md](docs/WINDOWS_ASSESSMENT.md) | Windows 兼容性评估报告 | -| [docs/FLUTTERGUARD_SPEC.md](docs/FLUTTERGUARD_SPEC.md) | 技术规格 | -| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 架构概览 | - -## License - -MIT diff --git a/RELEASE_v0.3.0.md b/RELEASE_v0.3.0.md deleted file mode 100644 index 0d193b1..0000000 --- a/RELEASE_v0.3.0.md +++ /dev/null @@ -1,321 +0,0 @@ -# Release v0.3.0 — Execution Prompt - -## Summary -Version bump 0.2.0 → 0.3.0. Update docs, package metadata, CHANGELOG, compile, tag, push. - ---- - -## Step 1: Version Bump - -### 1a. `packages/flutterguard_cli/pubspec.yaml` -`version: 0.2.0` → `version: 0.3.0` - -### 1b. `packages/flutterguard_cli/bin/flutterguard.dart` -`const _version = '0.2.0';` → `const _version = '0.3.0';` - ---- - -## Step 2: Update `docs/FLUTTERGUARD_SPEC.md` - -### 2a. Header line 6 -`Version: M2 (Milestone 2) — Architecture Overhaul | Last Updated: 2026-05-17` -→ `Version: M3 (Milestone 3) — Incremental Scan + Rule Introspection | Last Updated: 2026-06-28` - -### 2b. §1 Architecture Overview lines 44-63 - -Replace the entire flow diagram block with: - -``` -User runs: flutterguard scan [] [--changed-only] [--base main] - │ - ├── 1. ArgParser parses CLI flags - ├── 2. ScanConfig.fromFile() loads YAML config - ├── 3. FileCollector.collect() globs .dart files - │ └── if --changed-only: FileCollector.getChangedFiles() filters by git diff - ├── 4. Per-file scan: 11 rule classes analyze each file (13 rule IDs) - │ ├── LargeUnitsRule (file size, class size, build method size) - │ ├── LifecycleResourceRule (undisposed controllers/streams) - │ ├── LayerViolationRule (cross-layer import violations) - │ ├── ModuleViolationRule (cross-module import violations) - │ ├── CircularDependencyRule (file-level cycle detection) - │ ├── MissingConstConstructorRule (widgets missing const constructor) - │ ├── DeviceLifecycleRule (init/teardown pairing) - │ ├── MqttConnectionRule (MQTT connect/disconnect, hardcoded URLs) - │ ├── BleScanningRule (BLE scan lifecycle, timeout) - │ ├── IotSecurityRule (hardcoded secrets, cleartext, insecure BLE) - │ └── PubspecSecurityRule (unbounded deps, deprecated packages) - │ └── (changed-only: circular_dependency skipped) - ├── 5. Issues sorted by risk level (high → medium → low) - ├── 6. ReportGenerator generates output - │ ├── Table → terminal stdout - │ └── JSON → .flutterguard/report.json - └── 7. CI gate check (exit 1 if fail threshold exceeded) -``` - -Also update line 71: `Rule class interface` table row. - -### 2c. §4 CLI Contract — Add flags - -After `--min-score` in the scan command block, add: - -``` - --changed-only Only scan .dart files changed since --base (default: false) - --base Git base ref for changed-only (default: main) -``` - -After the scan block, add: - -``` -flutterguard rules [options] - --format (-f) Output format: table | json (default: table) - -flutterguard explain -``` - -In Exit Codes table, update Code 2 description: -``` -| 2 | Scan/explain error (bad path, config parse error, unknown rule ID) | -``` - -### 2d. §6.1 JSON Report Schema — Add scanMode - -After `"generatedAt"` line, insert: -```json - "scanMode": "full|changed", -``` - -### 2e. §7 Static Rules Detail — Append IoT rules - -After §7.8 (missing_const_constructor), append 5 new sections: - -``` -### 7.9 device_lifecycle (RiskLevel: high, Domain: architecture, Priority: p0) - -**Detection**: For each class, check that device lifecycle methods have balanced init/teardown pairs: -- `initState` ↔ `dispose` -- `connect()` ↔ `disconnect()` -- `start()` ↔ `stop()` -- `listen()` / `subscribe()` ↔ `cancel()` / `unsubscribe()` - -**Implementation**: Parse with `package:analyzer`, check method name presence for balanced pairs. - -### 7.10 mqtt_connection (RiskLevel: high, Domain: architecture, Priority: p0) - -**Detection**: -1. Find MQTT client field declarations (types matching `*MqttClient`, `*MQTT*`) -2. Check for `connect()` calls — verify `disconnect()` exists in dispose-like methods -3. Check for `subscribe()` calls — verify corresponding `unsubscribe()` calls exist -4. Check for hardcoded broker URLs (string literals containing `tcp://` or `mqtt://`) - -### 7.11 ble_scanning (RiskLevel: medium, Domain: architecture, Priority: p1) - -**Detection**: -1. Find BLE-related field declarations (types matching `*Ble*`, `*Bluetooth*`) -2. Check for `startScan()` calls — verify `stopScan()` exists in dispose-like methods -3. Check for `connect()` calls to BLE devices — verify `disconnect()` exists -4. Check that scan timeout is configured (look for timeout parameter in `startScan()`) - -**Config**: `maxScanDurationMs: int (default: 10000)` - -### 7.12 iot_security (RiskLevel: high, Domain: architecture, Priority: p0) - -**Detection**: -| Check | Pattern | Severity | -|-------|---------|----------| -| Hardcoded password/token | String literal matching `password`/`token`/`secret` assignment | high | -| Cleartext MQTT | `tcp://` host or port `1883` in MQTT config | high | -| Cleartext HTTP | `http://` in IoT context packages | medium | -| Insecure BLE | BLE without `bond`/`pair` references | medium | - -**Config**: `requireTls: bool (default: true)` - -### 7.13 pubspec_security (RiskLevel: medium, Domain: standards, Priority: p2) - -**Detection**: Analyzes the project's `pubspec.yaml` rather than individual `.dart` files. -| Check | Pattern | Severity | -|-------|---------|----------| -| Unbounded dependency | `^any` or no version constraint | medium | -| Outdated mqtt_client | `mqtt_client` version < 10.x.x | high | -| Outdated flutter_blue | `flutter_blue` (deprecated, use `flutter_blue_plus`) | high | -| Outdated http | `http` package < 1.x.x with cleartext patterns | medium | -``` - -### 2f. §8 Test Contracts — Full replace - -Replace header: `### 8.1 CLI Tests (13 tests)` → `### 8.1 CLI Tests (37 tests)` - -Append to the test table: - -``` -| changed_only_filters_files | temp git repo, 2 files, change 1 | scanMode=changed, only changed-file issues | -| changed_only_full_scan_when_no_git | non-git dir with changedOnly | scanMode=full | -| changed_only_skips_circular_dependency | cycle fixture with changedOnly | 0 circular_dependency issues | -| registry_contains_all_13_rules | RuleRegistry.all() | length == 13 | -| registry_find_returns_correct_meta | find('large_file') | non-null, correct id/domain | -| registry_find_unknown_returns_null | find('nonexistent') | null | -``` - -### 2g. §9 Evolution Roadmap — Mark M3 complete - -Add after M2 entry: - -``` -### M3 (Completed v0.3.0) — Incremental Scan + Rule Introspection - -Key deliverables: -- `--changed-only` incremental scan via git diff (skips cyclic dep in changed mode) -- `flutterguard rules` / `flutterguard explain` subcommands with RuleMeta registry -- 37 tests (26 base + 6 new + 5 existing) -- RuleMeta class + RuleRegistry for rule introspection -``` - -### 2h. §10 Known Limitations — Update - -- Remove entries 5 and 6 (IoT MQTT/BLE and pubspec_security — now implemented) -- Add entry: -``` -9. **Incremental scan**: `--changed-only` skips circular_dependency entirely - in changed mode. Layer/module violations only detected if the changed file - is the source of the illegal import. Pubspec security is not re-checked - unless the changed file set includes pubspec.yaml. -``` - -### 2i. §12 IoT Domain Rules (Planned) — Delete entire section - -The 5 IoT rules (12.1–12.5) are now documented in §7.9–§7.13. Delete the entire §12 section. - -### 2j. Append §13 Rule Registry + §14 Incremental Scan - -After the final line of the document (end of existing content), append: - -``` ---- -## 13. Rule Registry & Explain Commands - -### 13.1 RuleMeta -Data class in `lib/src/rule_meta.dart`: -- `id` — rule identifier -- `name` — Chinese display name -- `domain` — architecture / performance / standards -- `riskLevel` — high / medium / low -- `priority` — p0 / p1 / p2 -- `purpose` — detection purpose -- `riskReason` — why this matters -- `badExample` — anti-pattern -- `fixSuggestion` — recommended fix -- `configKeys` — YAML config keys -- `cicdSafe` — whether suitable for CI gating - -### 13.2 RuleRegistry -Singleton in `lib/src/rules/registry.dart`: -- `all()` → `List` (13 entries) -- `find(String id)` → `RuleMeta?` - -### 13.3 CLI Commands -- `flutterguard rules` — table of all rules -- `flutterguard rules --format json` — JSON payload -- `flutterguard explain ` — full detail; exit 2 on unknown ID - ---- -## 14. Incremental Scan (--changed-only) - -### Flow -1. Check `.git/` exists in project path -2. `git diff --name-only --diff-filter=ACMR` -3. `git ls-files --others --exclude-standard` (untracked files) -4. Union both sets, filter to .dart files -5. Feed filtered files to all rules -6. CircularDependencyRule force-disabled in changed-only mode - -### Behavior Matrix -| Condition | Behavior | scanMode | -|-----------|----------|----------| -| Non-git dir | Fallback to full scan | full | -| --changed-only, 0 changes | "No Dart files found" exit | full | -| --changed-only, changes > 0 | Only scan changed .dart files | changed | -| --base not specified | Defaults to 'main' | — | -``` - ---- - -## Step 3: Update `AGENTS.md` - -| Line | Change | -|------|--------| -| **Key Commands** | `(26 tests)` → `(37 tests)` | -| **Key Commands row 3** | After json row, add: `\| \`flutterguard scan --changed-only\` \| Incremental scan of git-changed files \|` | -| **Key Commands row 4** | After above: `\| \`flutterguard rules\` / \`flutterguard explain \` \| List/describe rules \|` | -| **CLI Entry Point** | After last sentence, append: `Supports --changed-only incremental scan and rule introspection (rules/explain).` | -| **Source Layout rules/** | Before `pubspec_security.dart` line, add 2 lines: `rule_meta.dart` and `rules/registry.dart` | - ---- - -## Step 4: Update `packages/flutterguard_cli/CHANGELOG.md` - -Insert at top (before `## 0.1.1`): - -```markdown -## 0.3.0 (2026-06-28) - -### Incremental Scan (--changed-only) - -- **cli:** New `--changed-only` flag — only scans `.dart` files changed since `--base` (default: `main`) -- **cli:** Integrated `git diff --name-only` + `git ls-files --others` for change detection -- **cli:** Non-git fallback: gracefully degrades to full scan -- **cli:** `circular_dependency` auto-disabled in changed-only mode -- **cli:** JSON report now includes `scanMode: full|changed` field - -### Rule Introspection (rules / explain) - -- **cli:** New `flutterguard rules` subcommand — list all 13 rules with ID, domain, name -- **cli:** New `flutterguard explain ` subcommand — detailed purpose, risk, example, fix, config -- **cli:** New `RuleMeta` class in `lib/src/rule_meta.dart` — structured rule metadata -- **cli:** New `RuleRegistry` singleton in `lib/src/rules/registry.dart` — registry with `all()` and `find()` -- **cli:** Output supports both `table` (default) and `--format json` - -### Tests - -- **test:** 37 total tests (26 base + 6 new + 5 existing) -- **test:** 3 `changed-only` tests: git repo filter, non-git fallback, skip cycle -- **test:** 3 registry tests: all 13 rules, find by ID, unknown returns null - -### Infrastructure - -- **meta:** Version bumped to 0.3.0 -- **meta:** `AGENTS.md` and `FLUTTERGUARD_SPEC.md` updated for new features -``` - ---- - -## Step 5: Delete ITERATION_1.md - -Remove the plan file (no longer needed after release): - -```bash -rm /Users/forest/code/flutterguard/ITERATION_1.md -``` - ---- - -## Step 6: Compile + Verify - -```bash -dart run melos run analyze -dart run melos run test:cli -dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard -./flutterguard --version # expect "flutterguard 0.3.0" -./flutterguard rules # expect 13 rules -./flutterguard explain large_file # expect full detail -``` - ---- - -## Step 7: git commit + tag + push - -```bash -git add -A -git commit -m "cli: v0.3.0 — --changed-only + rules/explain" -git tag v0.3.0 -git push origin develop -git push origin v0.3.0 -``` diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index 6d1a306..0000000 --- a/ROADMAP.md +++ /dev/null @@ -1,185 +0,0 @@ -# FlutterGuard Evolution Roadmap - -FlutterGuard remains an IoT / smart home Flutter static analysis toolchain. The -core product direction is static governance for Flutter projects, not runtime -observability, APM, cloud dashboards, crash reporting, or SDK instrumentation. - -## Guiding Principles - -- Keep the CLI as the source of truth for scanning, configuration, reports, and - CI behavior. -- Prefer local-first integrations that consume CLI output before introducing - hosted services. -- Focus on real team adoption: low-noise CI, editor feedback, baseline - management, and actionable reports. -- Avoid archived runtime packages for new feature work. - -## Architecture Invariants (do not violate) - -These were established by the v0.5.0 refactor and are binding for all future -work. New agents MUST follow them instead of reintroducing pre-0.5.0 patterns. - -- Rule metadata and execution are wired through `lib/src/rules/catalog.dart`. - It is the single source of truth. Do NOT wire rules directly in - `bin/flutterguard.dart` or `scanner.dart`, and do NOT add reflection or - dynamic plugin loading. -- `bin/flutterguard.dart` is thin: top-level routing, help, positional path, - and exit codes only. Functional command behavior lives in `lib/src/cli/` - (`scan_command`, `baseline_commands`, `config_commands`, `issue_commands`, - `rule_commands`, `cli_parsers`). -- Rules consume shared analysis state, not ad-hoc file reads: - - `SourceWorkspace` for source/AST caching and diagnostics. - - `ImportGraph` for resolved Dart imports. - - `DependencyBoundaryEngine` for layer/module boundary checks. - - `ScanContext` for scan scope/mode (project/all/target files). -- Each rule keeps the standalone `analyze(List files, {SourceWorkspace? - workspace})` API so direct rule tests and programmatic consumers keep working. -- New rule flow is unchanged: spec entry → config typedef → rule class → - fixture → test → wire into `rules/catalog.dart`. -- Workspace SDK constraint is `^3.11.5`; keep CI (`release.yml`, - `flutterguard.yml`) SDK aligned when it changes. - -## Current State (as of v0.5.0) - -- Milestone 0.4.x (CI Adoption Hardening) is DONE: baseline `stats` / `prune` / - `check --no-growth`, config profiles, install diagnostics, issue export, and - no-match / changed-only scan policy hardening all shipped in 0.4.0–0.4.1. -- v0.5.0 was consumed by the internal architecture refactor above rather than - the originally planned developer-workflow integrations. Those integrations - now move to Milestone 0.6. - -## Milestone 0.6 — Developer Workflow Integrations - -Goal: Surface FlutterGuard findings before CI by integrating with developer -tools. - -Deliverables: - -- Official GitHub Action: - - install FlutterGuard - - run scan - - upload JSON / SARIF artifacts - - optionally upload SARIF to GitHub Code Scanning -- GitHub PR annotations mode for changed-line findings. -- VS Code extension MVP driven by CLI JSON output: - - run scan on demand - - show diagnostics in the editor - - open rule explanations - - insert suppression comments -- Config profiles: - - `recommended` - - `strict` - - `migration` - - `iot-security` - - `architecture-only` - -Exit Criteria: - -- New users can add FlutterGuard to GitHub Actions with one reusable action. -- Developers can see and suppress findings in VS Code without reading CI logs. -- Teams can start from a profile instead of hand-authoring every rule setting. - -## Milestone 0.7 — Fixes, Reports, and Workspace Scale - -Goal: Move from reporting issues to helping teams reduce them across larger -Flutter codebases. - -Deliverables: - -- Auto-fix support for safe cases: - - add missing `const` constructors where deterministic - - insert suppression comments for selected findings - - suggest `pubspec.yaml` dependency replacements -- Static HTML report output for local reviews and architecture audits. -- Melos / monorepo workspace scanning: - - scan all packages - - scan one package - - scan affected packages - - merge package reports -- Architecture graph exports: - - Mermaid - - DOT - - dependency cycle graph - - layer / module violation graph - -Exit Criteria: - -- Teams can track and reduce findings without building custom scripts. -- Large Flutter workspaces can run FlutterGuard package-by-package. -- Architecture violations can be reviewed visually during refactors. - -## Milestone 0.8 — Deeper Static Analysis - -Goal: Reduce false positives and expand IoT-specific detection using stronger -AST and project context. - -Deliverables: - -- Lifecycle detection improvements: - - helper dispose methods - - base classes and mixins - - composite disposer patterns - - resource collections -- Import resolution improvements: - - package name awareness - - workspace package imports - - generated-file boundaries -- Additional IoT checks: - - OTA update safety patterns - - token and key storage risks - - device pairing flow risks - - local network discovery risks - - permission and privacy configuration checks - -Exit Criteria: - -- Core lifecycle and IoT security rules produce fewer false positives on real - projects. -- Monorepo package imports are handled consistently. -- New IoT checks remain static-only and CI-safe. - -## Milestone 0.9+ — Extensibility and Team Governance - -Goal: Support mature teams that need reusable governance policies without -turning FlutterGuard into a hosted platform. - -Deliverables: - -- Reusable team rule packs. -- Shared config inheritance: - - local file extends - - bundled profiles - - organization profile conventions -- Custom rule SDK exploration. -- IntelliJ / Android Studio plugin after VS Code integration stabilizes. -- Optional GitHub App only if GitHub Action annotations are insufficient. - -Exit Criteria: - -- Teams can standardize policies across many Flutter apps. -- Customization is possible without weakening the built-in static analysis - contract. -- Hosted services remain optional and out of the core product path. - -## Non-Goals - -- Runtime tracing SDK. -- APM or observability platform. -- Crash reporting. -- Cloud dashboard as a required workflow. -- Network proxy or HTTP inspector. -- Flutter widget library. -- Editing archived runtime packages in `archive/`. - -## Recommended Priority - -1. Official GitHub Action. -2. Baseline management commands. -3. VS Code extension MVP. -4. Config profiles and inheritance. -5. PR annotations. -6. Auto-fix for deterministic cases. -7. Static HTML report. -8. Workspace / monorepo scanning. -9. Architecture graph export. -10. Custom rule SDK exploration. diff --git a/archive/README.md b/archive/README.md deleted file mode 100644 index df504cb..0000000 --- a/archive/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Archive — Reserved for Future Use - -These packages were frozen runtime-tracing implementations from v0.1.0. - -| Package | Purpose | Status | -|---------|---------|--------| -| flutterguard_core | Runtime tracing engine (Zone-based) | Reserved | -| flutterguard_dio | Dio HTTP interceptor | Reserved | -| flutterguard_flutter | Flutter runtime hooks | Reserved | - -**Decision**: Superseded by CLI static analysis approach (Path A). -Zero code modifications — preserved as-is for reference. -To re-activate: `mv archive/ packages/` and restore melos.yaml entries. diff --git a/archive/flutterguard_core/AGENTS.md b/archive/flutterguard_core/AGENTS.md deleted file mode 100644 index 2530bfa..0000000 --- a/archive/flutterguard_core/AGENTS.md +++ /dev/null @@ -1,42 +0,0 @@ -# Package: flutterguard_core [FROZEN] - -## Role -Flow-level aspect tracing engine — trace models, Zone-based context propagation, ring buffer store, and JSON/Markdown exporters. - -**Status: FROZEN**. No new features. Bug fixes only. - -## Dependency Map -- depends on: meta ^1.12.0 -- depended by: flutterguard_cli (path), flutterguard_dio (path), flutterguard_flutter (path) - -## Entry Points -- lib barrel: `lib/flutterguard_core.dart` - -## Key Source Files -| File | Responsibility | -|------|---------------| -| `src/flutter_guard.dart` | Static API: action(), span(), record*(), export*() | -| `src/guard_config.dart` | FlutterGuardConfig model | -| `src/trace_context.dart` | Zone-based traceId propagation | -| `src/trace_model.dart` | Data models: FlowTrace, SpanTrace, NetworkTrace, etc. | -| `src/trace_store.dart` | Ring buffer singleton store (100 trace default) | -| `src/json_exporter.dart` | JSON export formatting | -| `src/markdown_exporter.dart` | Markdown export formatting | - -## Pubspec Overrides -melos-managed: none (core has no path deps) - -## Analysis Options -Inherits root strict-casts/strict-inference + package:lints/recommended.yaml (from pubspec). - -## Test -- command: `melos run test:core` -- test file: `test/flutter_guard_test.dart` (7 tests) - -## Forward Compatibility -- Keep public API stable (flutterguard_cli depends on this via path) -- Do NOT add new exports without team consensus -- Do NOT remove existing public APIs (breaks cli, dio, flutter packages) - -## Why Frozen -Runtime tracing approach was superseded by static analysis (Path A). Core remains as a reference implementation and may be re-visited in M4 roadmap. diff --git a/archive/flutterguard_core/lib/flutterguard_core.dart b/archive/flutterguard_core/lib/flutterguard_core.dart deleted file mode 100644 index c0fd910..0000000 --- a/archive/flutterguard_core/lib/flutterguard_core.dart +++ /dev/null @@ -1,9 +0,0 @@ -library flutterguard_core; - -export 'src/trace_model.dart'; -export 'src/guard_config.dart'; -export 'src/trace_context.dart'; -export 'src/trace_store.dart'; -export 'src/flutter_guard.dart'; -export 'src/json_exporter.dart'; -export 'src/markdown_exporter.dart'; diff --git a/archive/flutterguard_core/lib/src/flutter_guard.dart b/archive/flutterguard_core/lib/src/flutter_guard.dart deleted file mode 100644 index a4431ce..0000000 --- a/archive/flutterguard_core/lib/src/flutter_guard.dart +++ /dev/null @@ -1,162 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'guard_config.dart'; -import 'trace_context.dart'; -import 'trace_model.dart'; -import 'trace_store.dart'; -import 'json_exporter.dart'; -import 'markdown_exporter.dart'; - -class FlutterGuard { - static FlutterGuardConfig _config = const FlutterGuardConfig(); - static final Random _random = Random(); - - static void configure(FlutterGuardConfig config) { - _config = config; - TraceStore.instance.configure(maxTraces: config.maxTraces); - } - - static String? get currentTraceId => TraceContext.currentTraceId; - - static Future action( - String name, - FutureOr Function() body, { - Map tags = const {}, - }) async { - if (!_config.enabled) return body(); - - final traceId = _generateId(); - final trace = FlowTrace( - id: traceId, - name: name, - startTime: DateTime.now(), - tags: Map.unmodifiable({ - ...tags, - 'traceId': traceId, - }), - ); - - TraceStore.instance.beginFlow(trace); - - bool failed = false; - T result; - - try { - result = await TraceContext.runWithTraceId(traceId, body); - } catch (e, st) { - failed = true; - trace.errors.add(ErrorTrace( - flowId: traceId, - errorType: e.runtimeType.toString(), - message: e.toString(), - stackTrace: st.toString(), - time: DateTime.now(), - )); - trace.status = FlowStatus.failed; - rethrow; - } finally { - if (!failed) { - TraceStore.instance.endFlow(traceId, failed: false); - } else { - trace.endTime = DateTime.now(); - } - } - - return result; - } - - static Future span( - String name, - FutureOr Function() body, { - Map tags = const {}, - }) async { - if (!_config.enabled) return body(); - - final flowId = TraceContext.currentTraceId; - if (flowId == null) return body(); - - final span = SpanTrace( - id: _generateId(), - flowId: flowId, - name: name, - startTime: DateTime.now(), - tags: tags, - ); - - TraceStore.instance.addSpanToActive(flowId, span); - - try { - final result = await Future.value(body()); - span.endTime = DateTime.now(); - return result; - } catch (e, st) { - span.endTime = DateTime.now(); - span.errorType = e.runtimeType.toString(); - span.errorMessage = e.toString(); - span.stackTrace = st.toString(); - TraceStore.instance.addErrorToActive( - flowId, - ErrorTrace( - flowId: flowId, - errorType: e.runtimeType.toString(), - message: e.toString(), - stackTrace: st.toString(), - time: DateTime.now(), - ), - ); - rethrow; - } - } - - static void recordNetwork(NetworkTrace trace) { - if (!_config.enabled) return; - final flowId = trace.flowId ?? TraceContext.currentTraceId; - TraceStore.instance.addNetworkToActive(flowId, trace); - } - - static void recordRoute(RouteTrace trace) { - if (!_config.enabled || !_config.collectRoutes) return; - final flowId = trace.flowId ?? TraceContext.currentTraceId; - TraceStore.instance.addRouteToActive(flowId, trace); - } - - static void recordError(ErrorTrace trace) { - if (!_config.enabled || !_config.collectErrors) return; - final flowId = trace.flowId ?? TraceContext.currentTraceId; - TraceStore.instance.addErrorToActive(flowId, trace); - } - - static void recordFrame(FrameTrace trace) { - if (!_config.enabled || !_config.collectFrames) return; - final flowId = trace.flowId ?? TraceContext.currentTraceId; - TraceStore.instance.addFrameToActive(flowId, trace); - } - - static void recordBuild(String boundaryName) { - if (!_config.enabled || !_config.collectBuilds) return; - TraceStore.instance.recordBuild(TraceContext.currentTraceId, boundaryName); - } - - static String exportJson() { - final traces = TraceStore.instance.getAllTraces(); - return JsonExporter.export(traces); - } - - static String exportMarkdown() { - final traces = TraceStore.instance.getAllTraces(); - return MarkdownExporter.export(traces); - } - - static void reset() { - TraceStore.instance.reset(); - } - - static String _generateId() { - final bytes = List.generate(16, (_) => _random.nextInt(256)); - return bytes - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join() - .substring(0, 12); - } -} diff --git a/archive/flutterguard_core/lib/src/guard_config.dart b/archive/flutterguard_core/lib/src/guard_config.dart deleted file mode 100644 index 7bfa57c..0000000 --- a/archive/flutterguard_core/lib/src/guard_config.dart +++ /dev/null @@ -1,23 +0,0 @@ -class FlutterGuardConfig { - final bool enabled; - final bool collectErrors; - final bool collectFrames; - final bool collectRoutes; - final bool collectBuilds; - final int slowFlowMs; - final int jankFrameMs; - final int maxTraces; - final bool sanitizeNetwork; - - const FlutterGuardConfig({ - this.enabled = true, - this.collectErrors = true, - this.collectFrames = true, - this.collectRoutes = true, - this.collectBuilds = true, - this.slowFlowMs = 1000, - this.jankFrameMs = 16, - this.maxTraces = 100, - this.sanitizeNetwork = true, - }); -} diff --git a/archive/flutterguard_core/lib/src/json_exporter.dart b/archive/flutterguard_core/lib/src/json_exporter.dart deleted file mode 100644 index 2993cd7..0000000 --- a/archive/flutterguard_core/lib/src/json_exporter.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'dart:convert'; - -import 'trace_model.dart'; - -class JsonExporter { - static const String version = '1.0.0'; - - static String export(List traces) { - final summary = _buildSummary(traces); - final payload = { - 'version': version, - 'generatedAt': DateTime.now().toIso8601String(), - 'summary': summary, - 'traces': traces.map((t) => t.toJson()).toList(), - }; - return const JsonEncoder.withIndent(' ').convert(payload); - } - - static Map _buildSummary(List traces) { - final total = traces.length; - final success = traces.where((t) => t.status == FlowStatus.success).length; - final failed = traces.where((t) => t.status == FlowStatus.failed).length; - final running = traces.where((t) => t.status == FlowStatus.running).length; - return { - 'total': total, - 'success': success, - 'failed': failed, - 'running': running - }; - } -} diff --git a/archive/flutterguard_core/lib/src/markdown_exporter.dart b/archive/flutterguard_core/lib/src/markdown_exporter.dart deleted file mode 100644 index 35733ba..0000000 --- a/archive/flutterguard_core/lib/src/markdown_exporter.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'trace_model.dart'; - -class MarkdownExporter { - static String export(List traces) { - final buf = StringBuffer(); - - buf.writeln('# FlutterGuard Flow Report'); - buf.writeln(); - buf.writeln('## Summary'); - buf.writeln(); - final total = traces.length; - final success = traces.where((t) => t.status == FlowStatus.success).length; - final failed = traces.where((t) => t.status == FlowStatus.failed).length; - buf.writeln('| Metric | Count |'); - buf.writeln('|--------|-------|'); - buf.writeln('| Total Flows | $total |'); - buf.writeln('| Success | $success |'); - buf.writeln('| Failed | $failed |'); - buf.writeln(); - - if (traces.isEmpty) { - buf.writeln('*No flows recorded.*'); - return buf.toString(); - } - - buf.writeln('## Runtime Flows'); - buf.writeln(); - for (final trace in traces) { - buf.writeln('### ${trace.name} `${trace.id}`'); - buf.writeln(); - buf.writeln('- **Status**: ${trace.status.name}'); - buf.writeln('- **Duration**: ${trace.durationMs}ms'); - buf.writeln('- **Started**: ${trace.startTime.toIso8601String()}'); - if (trace.endTime != null) { - buf.writeln('- **Ended**: ${trace.endTime!.toIso8601String()}'); - } - buf.writeln(); - - if (trace.spans.isNotEmpty) { - buf.writeln('#### Spans'); - buf.writeln(); - buf.writeln('| Name | Duration | Error |'); - buf.writeln('|------|----------|-------|'); - for (final span in trace.spans) { - final error = span.errorType ?? '-'; - buf.writeln('| ${span.name} | ${span.durationMs}ms | $error |'); - } - buf.writeln(); - } - - if (trace.networks.isNotEmpty) { - buf.writeln('#### Network'); - buf.writeln(); - buf.writeln('| Method | Path | Status | Duration |'); - buf.writeln('|--------|------|--------|----------|'); - for (final net in trace.networks) { - buf.writeln( - '| ${net.method} | ${net.path} | ${net.statusCode ?? '-'} | ${net.durationMs}ms |'); - } - buf.writeln(); - } - - if (trace.routes.isNotEmpty) { - buf.writeln('#### Routes'); - buf.writeln(); - buf.writeln('| Type | From | To |'); - buf.writeln('|------|------|----|'); - for (final route in trace.routes) { - buf.writeln( - '| ${route.type} | ${route.from ?? '-'} | ${route.to ?? '-'} |'); - } - buf.writeln(); - } - - if (trace.errors.isNotEmpty) { - buf.writeln('#### Errors'); - buf.writeln(); - for (final error in trace.errors) { - buf.writeln('- **${error.errorType}**: ${error.message}'); - if (error.stackTrace != null) { - buf.writeln(' ```'); - buf.writeln(' ${error.stackTrace}'); - buf.writeln(' ```'); - } - } - buf.writeln(); - } - - if (trace.frames.isNotEmpty) { - buf.writeln('#### Frames'); - buf.writeln(); - buf.writeln('| Total | Build | Raster | Janky |'); - buf.writeln('|-------|-------|--------|-------|'); - for (final frame in trace.frames) { - buf.writeln( - '| ${frame.totalSpanMs}ms | ${frame.buildDurationMs}ms | ${frame.rasterDurationMs}ms | ${frame.janky} |'); - } - buf.writeln(); - } - - if (trace.buildCounts.isNotEmpty) { - buf.writeln('#### Build Boundaries'); - buf.writeln(); - for (final entry in trace.buildCounts.entries) { - buf.writeln('- **${entry.key}**: ${entry.value} rebuilds'); - } - buf.writeln(); - } - - buf.writeln('---'); - buf.writeln(); - } - - return buf.toString(); - } -} diff --git a/archive/flutterguard_core/lib/src/trace_context.dart b/archive/flutterguard_core/lib/src/trace_context.dart deleted file mode 100644 index 3172117..0000000 --- a/archive/flutterguard_core/lib/src/trace_context.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'dart:async'; - -class TraceContext { - static const String _keyName = 'flutter_guard_trace_id'; - - static String? get currentTraceId { - try { - return Zone.current[_keyName] as String?; - } on TypeError { - return null; - } - } - - static Future runWithTraceId( - String? traceId, - FutureOr Function() body, - ) async { - if (traceId == null) { - final result = body(); - if (result is Future) return result; - return result; - } - return runZoned(() async { - final result = body(); - if (result is Future) return await result; - return result; - }, zoneValues: {_keyName: traceId}); - } -} diff --git a/archive/flutterguard_core/lib/src/trace_model.dart b/archive/flutterguard_core/lib/src/trace_model.dart deleted file mode 100644 index 6fb127c..0000000 --- a/archive/flutterguard_core/lib/src/trace_model.dart +++ /dev/null @@ -1,219 +0,0 @@ -import 'dart:math'; - -enum FlowStatus { running, success, failed } - -class SpanTrace { - final String id; - final String flowId; - final String name; - final DateTime startTime; - DateTime? endTime; - final Map tags; - String? errorType; - String? errorMessage; - String? stackTrace; - - SpanTrace({ - required this.id, - required this.flowId, - required this.name, - required this.startTime, - this.endTime, - Map? tags, - this.errorType, - this.errorMessage, - this.stackTrace, - }) : tags = tags ?? {}; - - int get durationMs { - final end = endTime ?? DateTime.now(); - return end.difference(startTime).inMilliseconds; - } - - Map toJson() => { - 'id': id, - 'flowId': flowId, - 'name': name, - 'startTime': startTime.toIso8601String(), - 'endTime': endTime?.toIso8601String(), - 'durationMs': durationMs, - 'tags': tags, - 'errorType': errorType, - 'errorMessage': errorMessage, - 'stackTrace': stackTrace, - }; -} - -class NetworkTrace { - final String? flowId; - final String method; - final String path; - final int? statusCode; - final int durationMs; - final int? requestSize; - final int? responseSize; - final bool success; - final String? errorType; - final String? errorMessage; - - NetworkTrace({ - this.flowId, - required this.method, - required this.path, - this.statusCode, - required this.durationMs, - this.requestSize, - this.responseSize, - required this.success, - this.errorType, - this.errorMessage, - }); - - Map toJson() => { - 'flowId': flowId, - 'method': method, - 'path': path, - 'statusCode': statusCode, - 'durationMs': durationMs, - 'requestSize': requestSize, - 'responseSize': responseSize, - 'success': success, - 'errorType': errorType, - 'errorMessage': errorMessage, - }; -} - -class RouteTrace { - final String? flowId; - final String type; - final String? from; - final String? to; - final DateTime time; - - RouteTrace({ - this.flowId, - required this.type, - this.from, - this.to, - required this.time, - }); - - Map toJson() => { - 'flowId': flowId, - 'type': type, - 'from': from, - 'to': to, - 'time': time.toIso8601String(), - }; -} - -class ErrorTrace { - final String? flowId; - final String errorType; - final String message; - final String? stackTrace; - final DateTime time; - final String? route; - - ErrorTrace({ - this.flowId, - required this.errorType, - required this.message, - this.stackTrace, - required this.time, - this.route, - }); - - Map toJson() => { - 'flowId': flowId, - 'errorType': errorType, - 'message': message, - 'stackTrace': stackTrace, - 'time': time.toIso8601String(), - 'route': route, - }; -} - -class FrameTrace { - final String? flowId; - final int totalSpanMs; - final int buildDurationMs; - final int rasterDurationMs; - final bool janky; - final DateTime time; - - FrameTrace({ - this.flowId, - required this.totalSpanMs, - required this.buildDurationMs, - required this.rasterDurationMs, - required this.janky, - required this.time, - }); - - Map toJson() => { - 'flowId': flowId, - 'totalSpanMs': totalSpanMs, - 'buildDurationMs': buildDurationMs, - 'rasterDurationMs': rasterDurationMs, - 'janky': janky, - 'time': time.toIso8601String(), - }; -} - -class FlowTrace { - final String id; - final String name; - final DateTime startTime; - DateTime? endTime; - final Map tags; - final List spans; - final List networks; - final List routes; - final List errors; - final List frames; - final Map buildCounts; - FlowStatus status; - - FlowTrace({ - required this.id, - required this.name, - required this.startTime, - this.endTime, - Map? tags, - List? spans, - List? networks, - List? routes, - List? errors, - List? frames, - Map? buildCounts, - this.status = FlowStatus.running, - }) : tags = tags ?? {}, - spans = spans ?? [], - networks = networks ?? [], - routes = routes ?? [], - errors = errors ?? [], - frames = frames ?? [], - buildCounts = buildCounts ?? {}; - - int get durationMs { - final end = endTime ?? DateTime.now(); - return max(0, end.difference(startTime).inMilliseconds); - } - - Map toJson() => { - 'id': id, - 'name': name, - 'startTime': startTime.toIso8601String(), - 'endTime': endTime?.toIso8601String(), - 'durationMs': durationMs, - 'tags': tags, - 'status': status.name, - 'spans': spans.map((s) => s.toJson()).toList(), - 'networks': networks.map((n) => n.toJson()).toList(), - 'routes': routes.map((r) => r.toJson()).toList(), - 'errors': errors.map((e) => e.toJson()).toList(), - 'frames': frames.map((f) => f.toJson()).toList(), - 'buildCounts': buildCounts, - }; -} diff --git a/archive/flutterguard_core/lib/src/trace_store.dart b/archive/flutterguard_core/lib/src/trace_store.dart deleted file mode 100644 index 384547c..0000000 --- a/archive/flutterguard_core/lib/src/trace_store.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'trace_model.dart'; - -class TraceStore { - TraceStore._(); - - static final TraceStore instance = TraceStore._(); - - final Map _activeTraces = {}; - final List _completedTraces = []; - int _maxTraces = 100; - - void configure({required int maxTraces}) { - _maxTraces = maxTraces; - } - - FlowTrace beginFlow(FlowTrace trace) { - _activeTraces[trace.id] = trace; - return trace; - } - - FlowTrace? endFlow(String traceId, {required bool failed}) { - final trace = _activeTraces.remove(traceId); - if (trace == null) return null; - trace.endTime = DateTime.now(); - trace.status = failed ? FlowStatus.failed : FlowStatus.success; - _completedTraces.add(trace); - while (_completedTraces.length > _maxTraces) { - _completedTraces.removeAt(0); - } - return trace; - } - - FlowTrace? getActiveTrace(String traceId) { - return _activeTraces[traceId]; - } - - List get completedTraces => List.unmodifiable(_completedTraces); - - void addSpanToActive(String traceId, SpanTrace span) { - final trace = _activeTraces[traceId]; - if (trace != null) { - trace.spans.add(span); - } - } - - void addNetworkToActive(String? traceId, NetworkTrace network) { - if (traceId == null) return; - final trace = _activeTraces[traceId] ?? _getLatestCompleted(traceId); - trace?.networks.add(network); - } - - void addRouteToActive(String? traceId, RouteTrace route) { - if (traceId == null) return; - final trace = _activeTraces[traceId] ?? _getLatestCompleted(traceId); - trace?.routes.add(route); - } - - void addErrorToActive(String? traceId, ErrorTrace error) { - if (traceId == null) return; - final trace = _activeTraces[traceId] ?? _getLatestCompleted(traceId); - trace?.errors.add(error); - if (trace != null && _activeTraces.containsKey(traceId)) { - trace.status = FlowStatus.failed; - } - } - - void addFrameToActive(String? traceId, FrameTrace frame) { - if (traceId == null) return; - final trace = _activeTraces[traceId] ?? _getLatestCompleted(traceId); - trace?.frames.add(frame); - } - - void recordBuild(String? traceId, String boundaryName) { - if (traceId == null) return; - final trace = _activeTraces[traceId] ?? _getLatestCompleted(traceId); - if (trace != null) { - trace.buildCounts[boundaryName] = - (trace.buildCounts[boundaryName] ?? 0) + 1; - } - } - - FlowTrace? _getLatestCompleted(String traceId) { - for (var i = _completedTraces.length - 1; i >= 0; i--) { - if (_completedTraces[i].id == traceId) return _completedTraces[i]; - } - return null; - } - - List getAllTraces() { - return [..._activeTraces.values, ..._completedTraces]; - } - - void reset() { - _activeTraces.clear(); - _completedTraces.clear(); - } -} diff --git a/archive/flutterguard_core/pubspec.yaml b/archive/flutterguard_core/pubspec.yaml deleted file mode 100644 index 6c97825..0000000 --- a/archive/flutterguard_core/pubspec.yaml +++ /dev/null @@ -1,14 +0,0 @@ -name: flutterguard_core -description: Flow-level aspect tracing engine for Flutter - trace models, Zone context, and exporters. -version: 0.1.0 -publish_to: none - -environment: - sdk: ">=3.3.0 <4.0.0" - -dependencies: - meta: ^1.12.0 - -dev_dependencies: - test: ^1.25.8 - lints: ^3.0.0 diff --git a/archive/flutterguard_core/test/flutter_guard_test.dart b/archive/flutterguard_core/test/flutter_guard_test.dart deleted file mode 100644 index 500ad13..0000000 --- a/archive/flutterguard_core/test/flutter_guard_test.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'dart:async'; - -import 'package:flutterguard_core/flutterguard_core.dart'; -import 'package:test/test.dart'; - -void main() { - setUp(() { - FlutterGuard.reset(); - FlutterGuard.configure(const FlutterGuardConfig(maxTraces: 100)); - }); - - tearDown(() { - FlutterGuard.reset(); - }); - - test('action creates flow trace', () async { - final result = await FlutterGuard.action('test_action', () async { - await Future.delayed(const Duration(milliseconds: 10)); - return 'done'; - }); - - expect(result, equals('done')); - final json = FlutterGuard.exportJson(); - expect(json, contains('test_action')); - expect(json, contains('success')); - }); - - test('span attaches to current flow', () async { - await FlutterGuard.action('test_action', () async { - final spanResult = await FlutterGuard.span('inner_span', () async { - await Future.delayed(const Duration(milliseconds: 5)); - return 'span_done'; - }); - expect(spanResult, equals('span_done')); - }); - - final json = FlutterGuard.exportJson(); - expect(json, contains('inner_span')); - }); - - test('zone context survives async futures', () async { - String? capturedId; - - await FlutterGuard.action('test_action', () async { - final id1 = FlutterGuard.currentTraceId; - expect(id1, isNotNull); - - await Future.delayed(const Duration(milliseconds: 5)); - final id2 = FlutterGuard.currentTraceId; - expect(id2, equals(id1)); - - capturedId = id1; - }); - - expect(capturedId, isNotNull); - }); - - test('errors mark flow failed', () async { - bool threw = false; - try { - await FlutterGuard.action('test_action', () async { - throw Exception('test error'); - }); - } catch (_) { - threw = true; - } - expect(threw, isTrue); - - final json = FlutterGuard.exportJson(); - expect(json, contains('failed')); - expect(json, contains('test error')); - }); - - test('json export contains trace', () async { - await FlutterGuard.action('test_action', () async {}); - - final json = FlutterGuard.exportJson(); - expect(json, contains('"version"')); - expect(json, contains('"traces"')); - expect(json, contains('"test_action"')); - }); - - test('markdown export contains sections', () async { - await FlutterGuard.action('test_action', () async {}); - - final md = FlutterGuard.exportMarkdown(); - expect(md, contains('# FlutterGuard Flow Report')); - expect(md, contains('## Summary')); - expect(md, contains('## Runtime Flows')); - expect(md, contains('test_action')); - }); - - test('ring buffer respects max traces', () async { - FlutterGuard.configure(const FlutterGuardConfig(maxTraces: 3)); - - for (var i = 0; i < 5; i++) { - await FlutterGuard.action('action_$i', () async {}); - } - - final json = FlutterGuard.exportJson(); - expect(json, contains('action_2')); - expect(json, contains('action_3')); - expect(json, contains('action_4')); - expect(json, isNot(contains('action_0'))); - expect(json, isNot(contains('action_1'))); - }); -} diff --git a/archive/flutterguard_dio/AGENTS.md b/archive/flutterguard_dio/AGENTS.md deleted file mode 100644 index 2cdb132..0000000 --- a/archive/flutterguard_dio/AGENTS.md +++ /dev/null @@ -1,28 +0,0 @@ -# Package: flutterguard_dio [FROZEN] - -## Role -Dio HTTP interceptor for FlutterGuard flow tracing — records HTTP requests/responses within active flows. - -**Status: FROZEN**. No new features. Bug fixes only. - -## Dependency Map -- depends on: flutterguard_core (path), dio ^5.7.0 -- depended by: nothing - -## Entry Points -- lib barrel: `lib/flutterguard_dio.dart` - -## Key Source Files -| File | Responsibility | -|------|---------------| -| `src/dio_interceptor.dart` | FlutterGuardDioInterceptor: onRequest/onResponse/onError hooks | - -## Pubspec Overrides -melos-managed: flutterguard_core → path: ../flutterguard_core - -## Test -- command: `melos run test:dio` -- test file: `test/dio_interceptor_test.dart` (4 tests) - -## Why Frozen -Only meaningful when core runtime tracing is also active. Static analysis approach (Path A) does not require HTTP interception. diff --git a/archive/flutterguard_dio/lib/flutterguard_dio.dart b/archive/flutterguard_dio/lib/flutterguard_dio.dart deleted file mode 100644 index 194be60..0000000 --- a/archive/flutterguard_dio/lib/flutterguard_dio.dart +++ /dev/null @@ -1,3 +0,0 @@ -library flutterguard_dio; - -export 'src/dio_interceptor.dart'; diff --git a/archive/flutterguard_dio/lib/src/dio_interceptor.dart b/archive/flutterguard_dio/lib/src/dio_interceptor.dart deleted file mode 100644 index 152864e..0000000 --- a/archive/flutterguard_dio/lib/src/dio_interceptor.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutterguard_core/flutterguard_core.dart'; - -class FlutterGuardDioInterceptor extends Interceptor { - final bool sanitizeHeaders; - final bool sanitizeBody; - final List sensitiveKeys; - - FlutterGuardDioInterceptor({ - this.sanitizeHeaders = true, - this.sanitizeBody = true, - List? sensitiveKeys, - }) : sensitiveKeys = sensitiveKeys ?? _defaultSensitiveKeys; - - static const _defaultSensitiveKeys = [ - 'authorization', - 'cookie', - 'set-cookie', - 'token', - 'password', - 'secret', - 'email', - 'phone', - ]; - - @override - void onRequest(RequestOptions options, RequestInterceptorHandler handler) { - options.extra['flutterguard_start_time'] = DateTime.now(); - handler.next(options); - } - - @override - void onResponse(Response response, ResponseInterceptorHandler handler) { - _recordResponse(response); - handler.next(response); - } - - @override - void onError(DioException err, ErrorInterceptorHandler handler) { - _recordError(err); - handler.next(err); - } - - void _recordResponse(Response response) { - final requestOptions = response.requestOptions; - final startTime = requestOptions.extra['flutterguard_start_time']; - if (startTime is! DateTime) return; - - final durationMs = DateTime.now().difference(startTime).inMilliseconds; - final statusCode = response.statusCode; - final success = statusCode != null && statusCode >= 200 && statusCode < 400; - - FlutterGuard.recordNetwork(NetworkTrace( - flowId: FlutterGuard.currentTraceId, - method: requestOptions.method, - path: requestOptions.uri.path, - statusCode: statusCode, - durationMs: durationMs, - requestSize: _estimateSize(requestOptions.data), - responseSize: _estimateSize(response.data), - success: success, - )); - } - - void _recordError(DioException err) { - final requestOptions = err.requestOptions; - final startTime = requestOptions.extra['flutterguard_start_time']; - if (startTime is! DateTime) return; - - final durationMs = DateTime.now().difference(startTime).inMilliseconds; - - FlutterGuard.recordNetwork(NetworkTrace( - flowId: FlutterGuard.currentTraceId, - method: requestOptions.method, - path: requestOptions.uri.path, - durationMs: durationMs, - success: false, - errorType: err.type.name, - errorMessage: err.message, - )); - } - - int? _estimateSize(dynamic data) { - if (data == null) return null; - try { - return data.toString().length; - } catch (_) { - return null; - } - } -} diff --git a/archive/flutterguard_dio/pubspec.yaml b/archive/flutterguard_dio/pubspec.yaml deleted file mode 100644 index 6c9d2e9..0000000 --- a/archive/flutterguard_dio/pubspec.yaml +++ /dev/null @@ -1,16 +0,0 @@ -name: flutterguard_dio -description: Dio interceptor for FlutterGuard flow tracing - records HTTP requests within active flows. -version: 0.1.0 -publish_to: none - -environment: - sdk: ">=3.3.0 <4.0.0" - -dependencies: - dio: ^5.7.0 - flutterguard_core: - path: ../flutterguard_core - -dev_dependencies: - test: ^1.25.8 - lints: ^3.0.0 diff --git a/archive/flutterguard_dio/test/dio_interceptor_test.dart b/archive/flutterguard_dio/test/dio_interceptor_test.dart deleted file mode 100644 index ae7b2d1..0000000 --- a/archive/flutterguard_dio/test/dio_interceptor_test.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutterguard_core/flutterguard_core.dart'; -import 'package:flutterguard_dio/flutterguard_dio.dart'; -import 'package:test/test.dart'; - -void main() { - late Dio dio; - - setUp(() { - FlutterGuard.reset(); - FlutterGuard.configure(const FlutterGuardConfig(maxTraces: 100)); - dio = Dio(BaseOptions(baseUrl: 'https://example.com')); - dio.interceptors.add(FlutterGuardDioInterceptor()); - }); - - tearDown(() { - dio.close(); - FlutterGuard.reset(); - }); - - test('interceptor records success response', () async { - await FlutterGuard.action('network_action', () async { - final request = RequestOptions(path: '/api/items'); - final startTime = - DateTime.now().subtract(const Duration(milliseconds: 50)); - request.extra['flutterguard_start_time'] = startTime; - - final response = Response( - requestOptions: request, - statusCode: 200, - data: {'id': 1}, - ); - - FlutterGuard.recordNetwork(NetworkTrace( - flowId: FlutterGuard.currentTraceId, - method: request.method, - path: request.uri.path, - statusCode: response.statusCode!, - durationMs: 50, - success: true, - )); - }); - - final json = FlutterGuard.exportJson(); - expect(json, contains('200')); - expect(json, contains('/api/items')); - }); - - test('interceptor records error response', () async { - await FlutterGuard.action('error_network_action', () async { - FlutterGuard.recordNetwork(NetworkTrace( - flowId: FlutterGuard.currentTraceId, - method: 'GET', - path: '/api/error', - durationMs: 100, - success: false, - errorType: 'connectionError', - errorMessage: 'Connection refused', - )); - }); - - final json = FlutterGuard.exportJson(); - expect(json, contains('Connection refused')); - expect(json, contains('/api/error')); - }); - - test('interceptor attaches to current flow', () async { - await FlutterGuard.action('network_flow', () async { - FlutterGuard.recordNetwork(NetworkTrace( - flowId: FlutterGuard.currentTraceId, - method: 'POST', - path: '/api/submit', - statusCode: 201, - durationMs: 42, - success: true, - )); - }); - - final json = FlutterGuard.exportJson(); - expect(json, contains('network_flow')); - expect(json, contains('/api/submit')); - expect(json, contains('POST')); - }); - - test('interceptor does not log body by default', () async { - await FlutterGuard.action('sanitize_flow', () async { - FlutterGuard.recordNetwork(NetworkTrace( - flowId: FlutterGuard.currentTraceId, - method: 'POST', - path: '/api/login', - statusCode: 200, - durationMs: 30, - success: true, - requestSize: null, - responseSize: null, - )); - }); - - final json = FlutterGuard.exportJson(); - expect(json, contains('/api/login')); - expect(json, isNot(contains('password'))); - expect(json, isNot(contains('secret'))); - }); -} diff --git a/archive/flutterguard_flutter/AGENTS.md b/archive/flutterguard_flutter/AGENTS.md deleted file mode 100644 index fc2333a..0000000 --- a/archive/flutterguard_flutter/AGENTS.md +++ /dev/null @@ -1,34 +0,0 @@ -# Package: flutterguard_flutter [FROZEN] - -## Role -Flutter runtime integration for FlutterGuard — error hooks, route observer, frame metrics, and GuardBoundary widget rebuild counter. - -**Status: FROZEN**. No new features. Bug fixes only. - -## Dependency Map -- depends on: flutterguard_core (path), flutter SDK (>=3.19.0) -- depended by: nothing - -## Entry Points -- lib barrel: `lib/flutterguard_flutter.dart` - -## Key Source Files -| File | Responsibility | -|------|---------------| -| `src/flutter_guard.dart` | FlutterGuard.run(): wraps core, error hooks, frame timing | -| `src/guard_boundary.dart` | GuardBoundary widget: rebuild count tracking | -| `src/route_observer.dart` | FlutterGuardRouteObserver: Navigator route tracking | - -## Pubspec Overrides -melos-managed: flutterguard_core → path: ../flutterguard_core - -## Test -- command: `flutter test` (not in melos scripts yet) -- test file: `test/flutter_test.dart` (4 tests) - -## Why Frozen -Only meaningful when core runtime tracing is also active. Static analysis approach (Path A) does not require Flutter runtime instrumentation. - -## Notes -- Has Flutter SDK constraint (>=3.19.0) — testing requires Flutter SDK to be installed -- Not included in `melos run test` (which uses `dart test`); run `flutter test` manually diff --git a/archive/flutterguard_flutter/lib/flutterguard_flutter.dart b/archive/flutterguard_flutter/lib/flutterguard_flutter.dart deleted file mode 100644 index d430129..0000000 --- a/archive/flutterguard_flutter/lib/flutterguard_flutter.dart +++ /dev/null @@ -1,7 +0,0 @@ -library flutterguard_flutter; - -export 'package:flutterguard_core/flutterguard_core.dart' hide FlutterGuard; - -export 'src/flutter_guard.dart'; -export 'src/route_observer.dart'; -export 'src/guard_boundary.dart'; diff --git a/archive/flutterguard_flutter/lib/src/flutter_guard.dart b/archive/flutterguard_flutter/lib/src/flutter_guard.dart deleted file mode 100644 index f20bbf4..0000000 --- a/archive/flutterguard_flutter/lib/src/flutter_guard.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:flutterguard_core/flutterguard_core.dart' as core; - -import 'route_observer.dart'; - -class FlutterGuard { - static final FlutterGuardRouteObserver routeObserver = - FlutterGuardRouteObserver(); - - static void run({ - required Widget app, - core.FlutterGuardConfig config = const core.FlutterGuardConfig(), - }) { - core.FlutterGuard.configure(config); - - final previousOnError = FlutterError.onError; - if (config.collectErrors) { - FlutterError.onError = (details) { - core.FlutterGuard.recordError(core.ErrorTrace( - flowId: core.FlutterGuard.currentTraceId, - errorType: details.exception.runtimeType.toString(), - message: details.exception.toString(), - stackTrace: details.stack?.toString(), - time: DateTime.now(), - )); - previousOnError?.call(details); - }; - } - - if (config.collectFrames) { - SchedulerBinding.instance.addPostFrameCallback((_) { - _recordFrameStats(config); - }); - } - - runZonedGuarded( - () { - WidgetsFlutterBinding.ensureInitialized(); - runApp(app); - }, - (error, stack) { - if (config.collectErrors) { - core.FlutterGuard.recordError(core.ErrorTrace( - flowId: core.FlutterGuard.currentTraceId, - errorType: error.runtimeType.toString(), - message: error.toString(), - stackTrace: stack.toString(), - time: DateTime.now(), - )); - } - }, - ); - } - - static void _recordFrameStats(core.FlutterGuardConfig config) { - final binding = SchedulerBinding.instance; - try { - binding.addTimingsCallback((timings) { - for (final timing in timings) { - final totalSpan = timing.totalSpan.inMicroseconds ~/ 1000; - final buildDuration = timing.buildDuration.inMicroseconds ~/ 1000; - final rasterDuration = timing.rasterDuration.inMicroseconds ~/ 1000; - final janky = totalSpan > config.jankFrameMs; - - core.FlutterGuard.recordFrame(core.FrameTrace( - flowId: core.FlutterGuard.currentTraceId, - totalSpanMs: totalSpan, - buildDurationMs: buildDuration, - rasterDurationMs: rasterDuration, - janky: janky, - time: DateTime.now(), - )); - } - }); - } on NoSuchMethodError { - // addTimingsCallback not available on this Flutter version - } - } - - static String? get currentTraceId => core.FlutterGuard.currentTraceId; - - static Future action( - String name, - FutureOr Function() body, { - Map tags = const {}, - }) => - core.FlutterGuard.action(name, body, tags: tags); - - static Future span( - String name, - FutureOr Function() body, { - Map tags = const {}, - }) => - core.FlutterGuard.span(name, body, tags: tags); - - static void recordNetwork(core.NetworkTrace trace) => - core.FlutterGuard.recordNetwork(trace); - - static void recordRoute(core.RouteTrace trace) => - core.FlutterGuard.recordRoute(trace); - - static void recordError(core.ErrorTrace trace) => - core.FlutterGuard.recordError(trace); - - static void recordFrame(core.FrameTrace trace) => - core.FlutterGuard.recordFrame(trace); - - static void recordBuild(String boundaryName) => - core.FlutterGuard.recordBuild(boundaryName); - - static String exportJson() => core.FlutterGuard.exportJson(); - - static String exportMarkdown() => core.FlutterGuard.exportMarkdown(); - - static void reset() => core.FlutterGuard.reset(); - - static void configure(core.FlutterGuardConfig config) => - core.FlutterGuard.configure(config); -} diff --git a/archive/flutterguard_flutter/lib/src/guard_boundary.dart b/archive/flutterguard_flutter/lib/src/guard_boundary.dart deleted file mode 100644 index 5a70c86..0000000 --- a/archive/flutterguard_flutter/lib/src/guard_boundary.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutterguard_core/flutterguard_core.dart'; - -class GuardBoundary extends StatelessWidget { - final String name; - final Widget child; - - const GuardBoundary({ - super.key, - required this.name, - required this.child, - }); - - @override - Widget build(BuildContext context) { - FlutterGuard.recordBuild(name); - return child; - } -} diff --git a/archive/flutterguard_flutter/lib/src/route_observer.dart b/archive/flutterguard_flutter/lib/src/route_observer.dart deleted file mode 100644 index d091707..0000000 --- a/archive/flutterguard_flutter/lib/src/route_observer.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutterguard_core/flutterguard_core.dart'; - -class FlutterGuardRouteObserver extends NavigatorObserver { - @override - void didPush(Route route, Route? previousRoute) { - super.didPush(route, previousRoute); - FlutterGuard.recordRoute(RouteTrace( - flowId: FlutterGuard.currentTraceId, - type: 'push', - from: previousRoute != null ? _routeName(previousRoute) : null, - to: _routeName(route), - time: DateTime.now(), - )); - } - - @override - void didPop(Route route, Route? previousRoute) { - super.didPop(route, previousRoute); - FlutterGuard.recordRoute(RouteTrace( - flowId: FlutterGuard.currentTraceId, - type: 'pop', - from: _routeName(route), - to: previousRoute != null ? _routeName(previousRoute) : null, - time: DateTime.now(), - )); - } - - @override - void didReplace({Route? newRoute, Route? oldRoute}) { - super.didReplace(newRoute: newRoute, oldRoute: oldRoute); - FlutterGuard.recordRoute(RouteTrace( - flowId: FlutterGuard.currentTraceId, - type: 'replace', - from: oldRoute != null ? _routeName(oldRoute) : null, - to: newRoute != null ? _routeName(newRoute) : null, - time: DateTime.now(), - )); - } - - @override - void didRemove(Route route, Route? previousRoute) { - super.didRemove(route, previousRoute); - FlutterGuard.recordRoute(RouteTrace( - flowId: FlutterGuard.currentTraceId, - type: 'remove', - from: _routeName(route), - to: previousRoute != null ? _routeName(previousRoute) : null, - time: DateTime.now(), - )); - } - - String _routeName(Route route) { - return route.settings.name ?? route.runtimeType.toString(); - } -} diff --git a/archive/flutterguard_flutter/pubspec.yaml b/archive/flutterguard_flutter/pubspec.yaml deleted file mode 100644 index f8893b4..0000000 --- a/archive/flutterguard_flutter/pubspec.yaml +++ /dev/null @@ -1,19 +0,0 @@ -name: flutterguard_flutter -description: Flutter runtime integration for FlutterGuard - error hooks, route observer, frame metrics, and GuardBoundary. -version: 0.1.0 -publish_to: none - -environment: - sdk: ">=3.3.0 <4.0.0" - flutter: ">=3.19.0" - -dependencies: - flutter: - sdk: flutter - flutterguard_core: - path: ../flutterguard_core - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^3.0.2 diff --git a/archive/flutterguard_flutter/test/flutter_test.dart b/archive/flutterguard_flutter/test/flutter_test.dart deleted file mode 100644 index 1d40a3d..0000000 --- a/archive/flutterguard_flutter/test/flutter_test.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutterguard_flutter/flutterguard_flutter.dart'; - -void main() { - setUp(() { - FlutterGuard.reset(); - FlutterGuard.configure(const FlutterGuardConfig(maxTraces: 100)); - }); - - tearDown(() { - FlutterGuard.reset(); - }); - - testWidgets('GuardBoundary records build count', (tester) async { - await FlutterGuard.action('test_boundary', () async { - await tester.pumpWidget( - const MaterialApp( - home: Scaffold( - body: GuardBoundary( - name: 'TestPage', - child: Text('Hello'), - ), - ), - ), - ); - - await tester.pumpWidget( - const MaterialApp( - home: Scaffold( - body: GuardBoundary( - name: 'TestPage', - child: Text('Hello Again'), - ), - ), - ), - ); - }); - - final json = FlutterGuard.exportJson(); - expect(json, contains('TestPage')); - }); - - testWidgets('route observer records push and pop', (tester) async { - final observer = FlutterGuard.routeObserver; - - await tester.pumpWidget( - MaterialApp( - navigatorObservers: [observer], - home: const Scaffold(body: Text('Home')), - ), - ); - - await FlutterGuard.action('route_flow', () async { - await tester.tap(find.text('Home')); - }); - }); - - test('error hook preserves previous handler', () async { - FlutterError.onError = (details) {}; - - try { - await FlutterGuard.action('error_flow', () async { - throw Exception('guarded error'); - }); - } catch (_) {} - - expect(true, isTrue); - }); - - test('frame aspect can attach to active flow', () { - FlutterGuard.recordFrame(FrameTrace( - flowId: 'test_frame_flow', - totalSpanMs: 32, - buildDurationMs: 20, - rasterDurationMs: 12, - janky: true, - time: DateTime.now(), - )); - - final json = FlutterGuard.exportJson(); - expect(json, contains('test_frame_flow')); - }); -} diff --git a/archive/usage_demo/README.md b/archive/usage_demo/README.md deleted file mode 100644 index 7a5661b..0000000 --- a/archive/usage_demo/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Usage Demo - -Demonstrates the core `FlutterGuard` tracing API without Flutter dependencies. - -```bash -# From the repo root -melos bootstrap - -# Run the demo -dart run examples/usage_demo/bin/trace_demo.dart -``` diff --git a/archive/usage_demo/analysis_options.yaml b/archive/usage_demo/analysis_options.yaml deleted file mode 100644 index 4d8bd09..0000000 --- a/archive/usage_demo/analysis_options.yaml +++ /dev/null @@ -1,3 +0,0 @@ -analyzer: - errors: - avoid_print: ignore diff --git a/archive/usage_demo/bin/trace_demo.dart b/archive/usage_demo/bin/trace_demo.dart deleted file mode 100644 index 01788da..0000000 --- a/archive/usage_demo/bin/trace_demo.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutterguard_core/flutterguard_core.dart'; - -Future main() async { - FlutterGuard.configure(const FlutterGuardConfig(enabled: true)); - - print('=== FlutterGuard Tracing Demo ===\n'); - - await FlutterGuard.action('fetch_user_data', () async { - FlutterGuard.span('validate_token', () async { - await Future.delayed(const Duration(milliseconds: 30)); - }); - - FlutterGuard.span('query_database', () async { - await Future.delayed(const Duration(milliseconds: 120)); - }); - - FlutterGuard.recordNetwork(NetworkTrace( - method: 'GET', - path: '/api/users/42', - statusCode: 200, - durationMs: 85, - success: true, - )); - - FlutterGuard.span('format_response', () async { - await Future.delayed(const Duration(milliseconds: 15)); - }); - }); - - await FlutterGuard.action('process_payment', () async { - FlutterGuard.recordNetwork(NetworkTrace( - method: 'POST', - path: '/api/payments', - statusCode: 201, - durationMs: 320, - success: true, - )); - }, tags: {'amount': '29.99', 'currency': 'USD'}); - - try { - await FlutterGuard.action('failing_operation', () async { - await FlutterGuard.span('risky_call', () async { - await Future.delayed(const Duration(milliseconds: 10)); - throw Exception('Service unavailable'); - }); - }); - } catch (e) { - // Error is captured in the trace; demos don't crash. - } - - print(FlutterGuard.exportMarkdown()); -} diff --git a/archive/usage_demo/pubspec.yaml b/archive/usage_demo/pubspec.yaml deleted file mode 100644 index 17a5b41..0000000 --- a/archive/usage_demo/pubspec.yaml +++ /dev/null @@ -1,10 +0,0 @@ -name: usage_demo -description: Demonstrates the FlutterGuard tracing API. -publish_to: none - -environment: - sdk: ">=3.3.0 <4.0.0" - -dependencies: - flutterguard_core: - path: ../../packages/flutterguard_core diff --git a/do b/do deleted file mode 100644 index c4899fa..0000000 --- a/do +++ /dev/null @@ -1,75 +0,0 @@ -总体判断 - 当前 FlutterGuard 已可用于真实项目的本地检查、辅助代码评审和选择性 CI 门禁,尤其适合单包、标准 lib/** 布局的 Flutter/IoT 项目。 - - 但规则成熟度差异较大。它本质上是“AST 语法扫描 + 文本启发式”,不是类型分析器、依赖漏洞扫描器或安全审计工具。 - - 主要检测场景 - - 场景 对应规则 当前可用性 - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 大文件、大类、过长 build() large_file、large_class、large_build_method 较成熟,调好阈值后可门禁 - ─────────────────────────────────────────────────── ───────────────────────────────────────────── ───────────────────────────────────────────────── - 分层架构依赖方向 layer_violation 单包全量扫描下价值较高,可门禁 - ─────────────────────────────────────────────────── ───────────────────────────────────────────── ───────────────────────────────────────────────── - MQTT/BLE/业务模块隔离 module_violation 配置完整后可门禁 - ─────────────────────────────────────────────────── ───────────────────────────────────────────── ───────────────────────────────────────────────── - 文件级循环 import circular_dependency 全量扫描可作提示,不建议 changed-only 门禁 - ─────────────────────────────────────────────────── ───────────────────────────────────────────── ───────────────────────────────────────────────── - Timer、Controller、Subscription 未释放 lifecycle_resource_not_disposed 能发现直接、显式字段场景,建议报告模式 - ─────────────────────────────────────────────────── ───────────────────────────────────────────── ───────────────────────────────────────────────── - Widget 缺少 const 构造函数 missing_const_constructor 基础规范提示,不建议作为核心门禁 - ─────────────────────────────────────────────────── ───────────────────────────────────────────── ───────────────────────────────────────────────── - 明显的设备初始化/销毁方法缺失 device_lifecycle 仅按方法名配对,建议代码评审线索 - ─────────────────────────────────────────────────── ───────────────────────────────────────────── ───────────────────────────────────────────────── - MQTT URL、连接/订阅方法配对 mqtt_connection URL 粗筛有用,连接生命周期准确度有限 - ─────────────────────────────────────────────────── ───────────────────────────────────────────── ───────────────────────────────────────────────── - BLE 扫描停止、连接断开、超时关键词 ble_scanning 适合特定 wrapper 命名规范,建议提示模式 - ─────────────────────────────────────────────────── ───────────────────────────────────────────── ───────────────────────────────────────────────── - 硬编码凭据、明文 MQTT/HTTP、显式不安全 BLE 关键词 iot_security 可作 smoke check,必须允许 baseline/suppression - ─────────────────────────────────────────────────── ───────────────────────────────────────────── ───────────────────────────────────────────────── - 依赖版本和废弃包 pubspec_security 当前标准项目布局下基本失效,不应使用 - - 最有价值的使用方式 - - 1. 架构治理 - - 为 presentation/domain/data/core 或 mqtt/ble/shared 配置明确 glob 和 allowed_deps,用全量扫描阻止跨层、跨模块 import。这是当前最有差异化的能力。 - - 2. 代码规模治理 - - 根据团队现状设置文件、类和 build() 阈值,用于识别持续膨胀的模块和 Widget。 - - 3. 显式资源泄漏初筛 - - 可以发现直接声明的 Timer、StreamSubscription、Controller、MqttClient、BluetoothDevice 等字段未在本类 dispose() 中直接释放的情况。 - - 4. IoT 安全反模式筛查 - - 可快速发现源码中的 password = "..."、tcp://、1883 端口和非 localhost 的 http://。适合提醒,不能证明系统安全。 - - 5. 存量项目渐进式接入 - - baseline、源码 suppression、JSON/SARIF、--fail-on 和 --min-score 已形成完整闭环,适合先记录历史问题,再限制新增问题。 - - 不适合直接依赖的场景 - - - CVE、真实依赖漏洞或传递依赖扫描。 - - TLS 证书校验、密钥存储安全、权限合规。 - - 跨方法、跨类、继承、mixin、DI 场景的资源生命周期证明。 - - MQTT/BLE API 调用图、异步异常路径或真实连接状态分析。 - - 运行时性能、功耗、内存泄漏检测。 - - 整个 Melos monorepo 的 package graph 分析。 - - 仅依赖 --changed-only 作为合并门禁。 - - MQTT、BLE 和设备生命周期规则目前检查的主要是方法声明名称,而不是 _client.connect()、startScan() 等真实调用关系;安全规则也会扫描注释、示例和测试字符串。这些规则默认 - 直接阻断 high 风险会产生噪声。 - - 推荐落地策略 - - - PR:changed-only 用于快速提示。 - - 主干、夜间、发布前:执行全量扫描。 - - 硬门禁:优先启用经过 config doctor 验证的 layer/module 规则和规模规则。 - - IoT、生命周期、安全规则:先报告和 baseline,积累真实误报数据后再决定是否参与门禁。 - - 架构硬门禁建议使用独立配置,关闭尚未校准的 device_lifecycle、mqtt_connection、ble_scanning 和 iot_security。 - - 当前更准确的产品定位是:Flutter/IoT 项目的架构与反模式治理 CLI,而不是完整的 Dart analyzer 或安全扫描平台。 diff --git a/docs/USAGE.md b/docs/USAGE.md deleted file mode 100644 index 635b93e..0000000 --- a/docs/USAGE.md +++ /dev/null @@ -1,855 +0,0 @@ -# FlutterGuard — Complete Usage Guide - -> 版本: 0.6.0 | 支持: macOS / Windows / Linux | Dart SDK >=3.11.5 - ---- - -## 目录 - -1. [安装](#1-安装) -2. [快速开始](#2-快速开始) -3. [CLI 命令参考](#3-cli-命令参考) -4. [配置文件](#4-配置文件) -5. [规则详解](#5-规则详解) -6. [评分系统](#6-评分系统) -7. [输出格式](#7-输出格式) -8. [CI 集成](#8-ci-集成) -9. [路径处理说明](#9-路径处理说明) -10. [常见问题](#10-常见问题) -11. [开发指南](#11-开发指南) - -配置策略总览见 [CONFIGURATION_STRATEGY.md](../CONFIGURATION_STRATEGY.md):CLI 输出只放即时下一步,完整命令与配置心智模型放在专门说明文档中。 - ---- - -## 1. 安装 - -### 1.1 前置条件 - -| 组件 | 最低版本 | 安装方式 | -|------|---------|---------| -| Dart SDK | 3.11.5+ | [dart.dev/get-dart](https://dart.dev/get-dart) | -| Git | 任意 | 用于克隆仓库 | - -### 1.2 全局命令安装(推荐) - -
-macOS / Linux - -```bash -git clone https://github.com/lizy-coding/flutterguard.git -cd flutterguard - -dart pub get -dart pub global activate melos -melos bootstrap - -dart pub global activate --source path packages/flutterguard_cli -flutterguard --help -``` - -确认 `$HOME/.pub-cache/bin` 已在 `PATH` 中: - -```bash -export PATH="$PATH:$HOME/.pub-cache/bin" # 添加到 ~/.zshrc 或 ~/.bashrc -``` -
- -
-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 pub global activate --source path packages\flutterguard_cli -flutterguard --help -``` - -确认 `%USERPROFILE%\AppData\Local\Pub\Cache\bin` 已在 `PATH` 中(Dart 安装器默认添加)。 - -若 `flutterguard` 命令未识别,检查并手动添加: - -```powershell -$env:Path += ";$env:USERPROFILE\AppData\Local\Pub\Cache\bin" -``` -
- -### 1.3 编译独立二进制 - -
-macOS / Linux - -```bash -dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard -./flutterguard --help -``` -
- -
-Windows - -```powershell -dart compile exe packages\flutterguard_cli\bin\flutterguard.dart -o flutterguard.exe -.\flutterguard.exe --help -``` -
- -### 1.4 验证安装 - -```bash -flutterguard --version # 输出: flutterguard 0.6.0 -flutterguard --help # 输出帮助信息 -``` - ---- - -## 2. 快速开始 - -### 扫描一个项目 - -```bash -# macOS / Linux -flutterguard scan -p /path/to/flutter_project - -# Windows -flutterguard scan -p D:\dev\my_flutter_app - -# 扫描当前目录 -flutterguard scan -p . -``` - -### 扫描示例项目 - -```bash -flutterguard scan -p examples/scan_demo -``` - -### CI 门禁模式 - -```bash -# JSON 输出 + HIGH 级别失败 -flutterguard scan -p . --format json --fail-on high - -# 最低分 80 分 -flutterguard scan -p . --format json --min-score 80 -``` - ---- - -## 3. CLI 命令参考 - -### 3.1 命令结构 - -``` -flutterguard [options] - -Commands: - scan Scan a Flutter project for architecture issues - --help Show usage - --version Show version -``` - -### 3.2 scan 命令参数 - -| 参数 | 简写 | 类型 | 默认值 | 说明 | -|------|------|------|--------|------| -| `--path` | `-p` | path | `.` | 要扫描的项目路径 | -| `--config` | `-c` | path | `flutterguard.yaml` | 配置文件路径(相对于项目根目录) | -| `--format` | `-f` | `table` \| `json` | `table` | 输出格式 | -| `--output` | `-o` | path | `.flutterguard` | JSON 报告输出目录 | -| `--verbose` | `-v` | flag | off | 显示详细代码上下文 | -| `--fail-on` | — | `none` \| `high` \| `medium` \| `low` | `none` | CI 门禁等级 | -| `--min-score` | — | int (0-100) | unset | 最低可接受评分 | -| `--help` | `-h` | flag | — | 显示 scan 帮助 | - -### 3.3 退出码 - -| 退出码 | 含义 | -|--------|------| -| `0` | 成功完成(含 help/version 及增量扫描没有相关变更) | -| `1` | CI 门禁失败(存在超过 `--fail-on` 的问题或评分低于 `--min-score`) | -| `2` | 扫描设置错误(路径不存在、显式配置缺失、配置无效或未匹配到配置范围内的 Dart 文件) | - -### 3.4 使用示例 - -```bash -# 基础扫描 -flutterguard scan -p ./my_app - -# 指定配置文件 -flutterguard scan -p . -c my_config.yaml - -# JSON 输出到指定目录 -flutterguard scan -p . --format json -o reports - -# 详细输出模式 -flutterguard scan -p . -v - -# CI 门禁:存在 HIGH 级别问题即失败 -flutterguard scan -p . --fail-on high - -# CI 门禁:存在任何问题即失败 -flutterguard scan -p . --fail-on low - -# 综合门禁:HIGH 且评分低于 80 失败 -flutterguard scan -p . --fail-on high --min-score 80 - -# Windows 示例 -flutterguard scan -p D:\dev\flutter_app -c config\flutterguard.yaml -flutterguard scan -p . --format json --fail-on medium --min-score 60 -``` - ---- - -## 4. 配置文件 - -### 4.1 配置位置 - -FlutterGuard 按以下优先级加载配置: - -1. 命令行 `--config` 指定的文件路径(最高优先级) -2. 项目根目录下的 `flutterguard.yaml` -3. 内置默认配置(无配置文件时使用) - -### 4.2 完整配置示例 - -```yaml -# ========== 文件收集 ========== -include: - - lib/** - - test/** - -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 - - side_effect_in_build: - enabled: true - severity: high - allowlist: [] - ignore_paths: [] - - riverpod_read_used_for_render: - enabled: true - severity: medium - -# ========== 状态管理规则总开关 ========== -state_management: - enabled: true - framework_auto_detect: true - confidence_threshold: certain - -# ========== 架构约束 ========== -architecture: - # 层间依赖约束 - layers: - - name: presentation - path: lib/presentation/** - allowed_deps: [domain, core] - - - name: domain - path: lib/domain/** - allowed_deps: [core] - - - name: data - path: lib/data/** - allowed_deps: [domain, core] - - - name: core - path: lib/core/** - allowed_deps: [] - - # 业务模块隔离 - modules: - - name: device_mqtt - path: lib/device/mqtt/** - allowed_deps: [domain, core] - - - name: device_ble - path: lib/device/ble/** - allowed_deps: [domain, core] - - # 循环依赖检测 - detect_cycles: true - - # 规则开关 - layer_violation: - enabled: true - module_violation: - enabled: true -``` - -### 4.3 配置项说明 - -#### include / exclude - -| 字段 | 类型 | 说明 | -|------|------|------| -| `include` | `List` | glob 模式,指定要扫描的文件。默认 `['lib/**']` | -| `exclude` | `List` | glob 模式,排除匹配的文件。默认排除 generated/freezed/mocks | - -#### rules.*.enabled - -每个规则有独立的 `enabled` 开关。设为 `false` 可禁用该规则。 - -#### rules.*.maxLines - -阈值类规则(large_file / large_class / large_build_method)支持 `maxLines` 自定义。超过该值即报告问题。 - -#### state_management 与状态规则 - -- `state_management.enabled`: 10 条状态管理规则的总开关,默认 `true` -- `framework_auto_detect`: 默认根据 Riverpod/Bloc/Provider import 与 AST 形态共同识别;设为 `false` 时只跳过 import 门槛 -- `confidence_threshold`: `certain` / `probable` / `informational`;当前规则均为 `certain` -- 每条状态规则支持 `enabled`、`severity`、`allowlist`、`ignore_paths` -- `severity` 映射 high→P0、medium→P1、low→P2,并影响评分与 CI gate -- `ignore_paths` 使用项目相对 POSIX glob;依赖环 allowlist 边使用 `Source->Target` - -#### architecture.layers - -- `name`: 层名称(任意字符串) -- `path`: glob 模式,匹配该层的文件 -- `allowed_deps`: 该层允许依赖的其他层名称列表 - -**重要**: 层规则需要显式声明所有层。未声明的文件不在任何层内,不受检查。 - -#### architecture.modules - -- 结构与 layers 完全相同 -- `name`: 模块名称 -- `path`: 模块文件匹配模式 -- `allowed_deps`: 允许依赖的模块列表 - -#### architecture.detect_cycles - -- 类型: `bool` -- 默认: `false`(默认配置)/ `true`(推荐) -- 启用文件级循环依赖检测 - -#### architecture.layer_violation.enabled / module_violation.enabled - -- 类型: `bool` -- 默认: `true` -- 全局开关,即使配置了 layers/modules 也可临时关闭 - -### 4.4 默认配置(无 flutterguard.yaml 时) - -```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 } - side_effect_in_build: { enabled: true, severity: high } - state_manager_created_in_build: { enabled: true, severity: high } - mutable_state_exposed: { enabled: true, severity: medium } - state_layer_ui_dependency: { enabled: true, severity: high } - state_dependency_cycle: { enabled: true, severity: high } - riverpod_read_used_for_render: { enabled: true, severity: medium } - riverpod_watch_in_callback: { enabled: true, severity: medium } - bloc_equatable_props_incomplete: { enabled: true, severity: medium } - provider_value_lifecycle_misuse: { enabled: true, severity: medium } - notify_listeners_in_loop: { enabled: true, severity: medium } - -state_management: - enabled: true - framework_auto_detect: true - confidence_threshold: certain - -architecture: - layers: [] # 未配置层,不执行层规则 - modules: [] # 未配置模块,不执行模块规则 - detect_cycles: false - layer_violation: { enabled: true } - module_violation: { enabled: true } -``` - ---- - -## 5. 规则详解 - -### 5.1 large_file — 文件过大 - -| 属性 | 值 | -|------|----| -| **ID** | `large_file` | -| **风险等级** | LOW | -| **领域** | standards(代码规范) | -| **优先级** | P2 | -| **可配置** | `enabled`, `maxLines` | -| **检测方式** | 读取文件行数,超过 `maxLines`(默认 500)报告问题 | - -### 5.2 large_class — 类过大 - -| 属性 | 值 | -|------|----| -| **ID** | `large_class` | -| **风险等级** | LOW | -| **领域** | standards(代码规范) | -| **优先级** | P2 | -| **可配置** | `enabled`, `maxLines` | -| **检测方式** | 找到 `class ClassName` 声明,计算类体行数,超过 `maxLines`(默认 300)报告 | - -### 5.3 large_build_method — Build 方法过大 - -| 属性 | 值 | -|------|----| -| **ID** | `large_build_method` | -| **风险等级** | MEDIUM | -| **领域** | performance(性能) | -| **优先级** | P1 | -| **可配置** | `enabled`, `maxLines` | -| **检测方式** | 找到 `Widget build(BuildContext)` 方法,计算行数,超过 `maxLines`(默认 80)报告 | - -### 5.4 lifecycle_resource_not_disposed — 资源未释放 - -| 属性 | 值 | -|------|----| -| **ID** | `lifecycle_resource_not_disposed` | -| **风险等级** | MEDIUM | -| **领域** | performance(性能) | -| **优先级** | P1 | -| **可配置** | `enabled` | - -**检测的资源类型**: - -| 类型 | 期望释放方法 | IoT 相关 | -|------|-------------|---------| -| `StreamSubscription` | `.cancel()` | | -| `Timer` | `.cancel()` | | -| `AnimationController` | `.dispose()` | | -| `TextEditingController` | `.dispose()` | | -| `ScrollController` | `.dispose()` | | -| `FocusNode` | `.dispose()` | | -| `MqttClient` | `.disconnect()` | ✅ IoT | -| `BluetoothDevice` | `.disconnect()` | ✅ IoT | -| `StreamController` | `.close()` | | - -**检测方式**: 对每个类,检查字段声明 → 类型匹配 → 是否在 `dispose()` 方法中调用了对应的释放方法。 - -### 5.5 layer_violation — 层间依赖违规 - -| 属性 | 值 | -|------|----| -| **ID** | `layer_violation` | -| **风险等级** | HIGH | -| **领域** | architecture(架构) | -| **优先级** | P0 | -| **可配置** | `architecture.layer_violation.enabled`, `architecture.layers` | - -**示例**:`presentation` 层允许依赖 `[domain, core]`。如果 `presentation` 层中的文件 import 了 `data` 层文件,报告违规。 - -### 5.6 module_violation — 模块依赖违规 - -| 属性 | 值 | -|------|----| -| **ID** | `module_violation` | -| **风险等级** | HIGH | -| **领域** | architecture(架构) | -| **优先级** | P0 | -| **可配置** | `architecture.module_violation.enabled`, `architecture.modules` | - -**与 layer_violation 的区别**: module 用于业务模块隔离(如 device_mqtt 不应依赖 device_ble),layer 用于架构分层约束。 - -### 5.7 circular_dependency — 循环依赖 - -| 属性 | 值 | -|------|----| -| **ID** | `circular_dependency` | -| **风险等级** | MEDIUM | -| **领域** | architecture(架构) | -| **优先级** | P1 | -| **可配置** | `architecture.detect_cycles` | - -**检测方式**: 构建有向图 → DFS 染色检测环 → 每个文件级环报告一次。 - -### 5.8 missing_const_constructor — 缺少 const 构造函数 - -| 属性 | 值 | -|------|----| -| **ID** | `missing_const_constructor` | -| **风险等级** | LOW | -| **领域** | standards(代码规范) | -| **优先级** | P2 | -| **可配置** | `enabled` | - -**检测方式**: 找到 `StatelessWidget` / `StatefulWidget` 子类 → 检查是否有 `const` 构造函数。 - ---- - -### 5.9 状态管理可维护性规则(10 条) - -| ID | 默认等级 | 框架 | True positive | Safe pattern | -|----|----------|------|---------------|--------------| -| `side_effect_in_build` | HIGH | generic | build 中 `emit()`、`notifyListeners()`、连接设备或 notifier 命令 | 事件回调或 listener 内执行 | -| `state_manager_created_in_build` | HIGH | generic | build 中 `DeviceController()` | State 字段或 Provider `create` 持有 | -| `mutable_state_exposed` | MEDIUM | generic | public 可变字段/集合 getter、`state.items.add` | unmodifiable view、copyWith | -| `state_layer_ui_dependency` | HIGH | generic | Controller 参数为 BuildContext 或调用 Navigator | 状态层输出事件,Widget 执行 UI 行为 | -| `state_dependency_cycle` | HIGH | generic | Provider/Controller/Service 形成 SCC | 单向接口或协调器 | -| `riverpod_read_used_for_render` | MEDIUM | Riverpod | `Text(ref.read(p).name)` | 渲染用 `ref.watch`,命令用 `ref.read` | -| `riverpod_watch_in_callback` | MEDIUM | Riverpod | `onTap: () => ref.watch(p)` | 回调使用 `ref.read` | -| `bloc_equatable_props_incomplete` | MEDIUM | Bloc | final 字段未加入 `props` | 所有值字段均在 `props` | -| `provider_value_lifecycle_misuse` | MEDIUM | Provider | `.value(value: Controller())` / `create: (_) => existing` | 新实例用 create,已有实例用 .value | -| `notify_listeners_in_loop` | MEDIUM | Provider | for/while/forEach 内通知 | 批量修改后统一通知一次 | - -显式行级抑制继续使用现有注释: - -```dart -// flutterguard: ignore side_effect_in_build -Widget build(BuildContext context) { - ref.read(deviceProvider.notifier).refresh(); - return const DeviceView(); -} -``` - -## 6. 评分系统 - -### 6.1 计算公式 - -``` -score = max(0, 100 - HIGH×10 - MEDIUM×4 - LOW×1) -``` - -| 问题等级 | 扣分 | -|---------|------| -| HIGH | -10 | -| MEDIUM | -4 | -| LOW | -1 | - -### 6.2 评分等级 - -| 分数段 | 等级 | 含义 | -|--------|------|------| -| 80-100 | 优秀 | 代码质量良好 | -| 50-79 | 需关注 | 存在一定问题,建议处理 | -| 0-49 | 需整改 | 存在较多严重问题,需要立即处理 | - ---- - -## 7. 输出格式 - -### 7.1 Table 格式(默认) - -终端输出,按领域分组显示。示例: - -``` - FlutterGuard Report ─ my_flutter_app -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 总评分: 88/100 优秀 扫描文件: 15 问题总数: 3 -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - 架构违规 1 items ▰ HIGH -───────────────────────────────────────────────────────────────── - - HIGH P0 优先 - 层间依赖违规 - lib/presentation/home_page.dart:42 - presentation 层不可依赖 data 层 - 修复: 将导入的内容移至 domain 或 core层 -``` - -### 7.2 JSON 格式 - -`--format json` 输出到 stdout(摘要)和 `--output` 目录下 `report.json`。 - -```json -{ - "version": "1.0.0", - "generatedAt": "2026-06-09T12:00:00.000Z", - "projectPath": "/Users/dev/my_flutter_app", - "score": 88, - "summary": { - "total": 3, - "high": 1, - "medium": 1, - "low": 1, - "byDomain": { - "architecture": { "high": 1, "medium": 1, "low": 0, "total": 2 }, - "performance": { "high": 0, "medium": 0, "low": 0, "total": 0 }, - "standards": { "high": 0, "medium": 0, "low": 1, "total": 1 } - } - }, - "issues": [ - { - "id": "layer_violation", - "title": "层间依赖违规", - "file": "/Users/dev/my_flutter_app/lib/presentation/home_page.dart", - "line": 42, - "level": "high", - "domain": "architecture", - "priority": "p0", - "message": "presentation 层不可依赖 data 层", - "detail": "导入: package:app/data/repo.dart\n源层: presentation (lib/presentation/**)\n目标层: data (lib/data/**)\n允许依赖: domain, core", - "suggestion": "将导入的内容移至 domain 或 core层", - "metadata": { - "sourceLayer": "presentation", - "targetLayer": "data", - "imported": "package:app/data/repo.dart", - "allowedDeps": ["domain", "core"] - } - } - ] -} -``` - -### 7.3 输出路径 - -| 平台 | CLI --output 默认值 | 实际输出路径 | -|------|-------------------|-------------| -| macOS | `.flutterguard` | `/Users/xxx/project/.flutterguard/report.json` | -| Windows | `.flutterguard` | `D:\xxx\project\.flutterguard\report.json` | - ---- - -## 8. CI 集成 - -### 8.1 GitHub Actions - -```yaml -name: FlutterGuard - -on: [push, pull_request] - -jobs: - scan: - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - uses: dart-lang/setup-dart@v1 - with: - sdk: 3.11.5 - - name: Install FlutterGuard - run: dart pub global activate flutterguard_cli - - name: Scan - run: flutterguard scan -p . --format json --fail-on high --min-score 80 -``` - -### 8.2 GitLab CI - -```yaml -flutterguard: - image: dart:3.11.5 - script: - - dart pub global activate flutterguard_cli - - flutterguard scan -p . --format json --fail-on high --min-score 80 - artifacts: - paths: - - .flutterguard/report.json - when: always -``` - -### 8.3 预提交钩子 (pre-commit) - -```yaml -# .pre-commit-config.yaml -repos: - - repo: local - hooks: - - id: flutterguard - name: FlutterGuard scan - entry: flutterguard scan -p . --fail-on high - language: system - pass_filenames: false - always_run: true -``` - -### 8.4 Windows 本地 CI 脚本 - -```powershell -# tools\scan_ci.ps1 -$ErrorActionPreference = "Stop" - -Write-Host "FlutterGuard Scan" -ForegroundColor Cyan -flutterguard scan -p . --format json --fail-on high --min-score 80 - -if ($LASTEXITCODE -eq 0) { - Write-Host "Passed!" -ForegroundColor Green -} else { - Write-Host "Failed! Check .flutterguard/report.json for details." -ForegroundColor Red - exit 1 -} -``` - ---- - -## 9. 路径处理说明 - -### 9.1 跨平台策略 - -FlutterGuard 所有路径操作均通过 `package:path` 的 Context 系统完成,自动适配不同平台的路径分隔符。 - -| 场景 | macOS | Windows | 处理方式 | -|------|-------|---------|---------| -| 内部路径操作 | `/` | `\` | `p.Context(style: p.Style.posix/windows)` | -| glob 模式匹配 | `/` | `/` | `replaceAll('\\', '/')` 归一化 | -| import 解析 | `/` | `\` → `/` | Context.normalize | -| 配置文件 glob | `lib/**` | `lib/**` | 统一正斜杠 | - -### 9.2 glob 模式约定 - -无论什么平台,YAML 配置中的 glob 模式均使用**正斜杠** `/`: - -```yaml -# 正确 -path: lib/presentation/** - -# 错误(Windows 也不要使用反斜杠) -path: lib\presentation\** -``` - -### 9.3 方法级 Context 参数 - -以下公共 API 支持传入 `p.Context` 以适配特定平台: - -| 方法 | Context 参数 | -|------|-------------| -| `projectPathContext()` | `context` | -| `normalizePath()` | `context`, `basePath` | -| `matchesProjectGlob()` | `context` | -| `projectRelativePath()` | `context` | -| `resolveImport()` | `context` | - ---- - -## 10. 常见问题 - -### Q: 未找到配置文件怎么办? - -自动使用内置默认配置(所有规则启用,无架构约束)。可通过 `-c` 参数显式指定。 - -### Q: 如何忽略生成的代码? - -默认已经排除了 `lib/generated/**`, `lib/**.g.dart`, `lib/**.freezed.dart`, `lib/**.mocks.dart`。可在 `flutterguard.yaml` 的 `exclude` 中添加更多模式。 - -### Q: 架构规则未生效? - -检查以下几点: -1. `architecture.layers` / `architecture.modules` 是否已声明 -2. `architecture.layer_violation.enabled` / `module_violation.enabled` 是否为 `true` -3. glob 模式是否匹配目标文件(使用正斜杠) - -### Q: Windows 终端输出乱码? - -```powershell -# PowerShell 设置 UTF-8 -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 - -# 推荐使用 Windows Terminal(Windows 10/11 自带) -``` - -### Q: Windows 下颜色不显示? - -旧 cmd.exe 不支持 ANSI 转义码,使用 Windows Terminal 即可。颜色仅影响外观,不影响功能。 - -### Q: 如何生成原生可执行文件并分发? - -```bash -# macOS -dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard - -# Windows -dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard.exe -``` - -编译产物可独立运行,无需 Dart 环境。 - ---- - -## 11. 开发指南 - -### 11.1 环境准备 - -```bash -# macOS / Linux -git clone https://github.com/lizy-coding/flutterguard.git -cd flutterguard -dart pub get -dart pub global activate melos -melos bootstrap - -# Windows -git clone https://github.com/lizy-coding/flutterguard.git -cd flutterguard -dart pub get -dart pub global activate melos -melos bootstrap -``` - -### 11.2 常用命令 - -| 命令 | 说明 | -|------|------| -| `dart run melos run analyze` | 静态分析 | -| `dart run melos run test:cli` | 运行 CLI 测试(26 个测试) | -| `dart run flutterguard scan -p examples/scan_demo` | 扫描示例项目 | -| `dart compile exe ...` | 编译原生二进制 | - -### 11.3 测试结构 - -``` -packages/flutterguard_cli/test/ -├── scanner_test.dart # 26 个测试 -└── fixtures/ # 规则与配置 fixture - ├── large_file.dart - ├── large_class.dart - ├── large_build.dart - ├── lifecycle_issue.dart - ├── boundary_issue.dart - ├── forbidden_file.dart - ├── cycle_a.dart - ├── cycle_b.dart - ├── cycle_c.dart - ├── missing_const.dart - ├── architecture_config.yaml - └── architecture_disabled.yaml -``` - -### 11.4 添加新规则 - -1. 在 `docs/FLUTTERGUARD_SPEC.md` 添加规则规格 -2. 在 `config_loader.dart` 添加类型定义(如需要新配置字段) -3. 在 `lib/src/rules/` 实现规则类 -4. 在 `test/fixtures/` 添加测试 fixture -5. 在 `test/scanner_test.dart` 添加测试 -6. 在 `scanner.dart:_analyze()` 中注册规则 -7. 在 `bin/flutterguard.dart` 的 help 文本中更新说明 -8. 运行 `melos run analyze && melos run test:cli` diff --git a/docs/WINDOWS_ASSESSMENT.md b/docs/WINDOWS_ASSESSMENT.md deleted file mode 100644 index 051cbb5..0000000 --- a/docs/WINDOWS_ASSESSMENT.md +++ /dev/null @@ -1,241 +0,0 @@ -# FlutterGuard Windows Availability Assessment - -> 更新日期: 2026-07-16 | 版本: 0.6.0 | 评估范围: flutterguard_cli 全部源码及依赖链 - ---- - -## 1. 总体评估:完全可用 - -FlutterGuard CLI 在 Windows 平台上**完全可用**,无需任何代码修改。代码库从设计阶段即考虑了跨平台兼容性,在依赖选择、路径处理、文件 I/O 等方面均采用纯 Dart 跨平台方案。 - -| 维度 | 状态 | 说明 | -|------|------|------| -| Dart SDK 兼容 | ✅ | Dart 3.11.5+ 全平台支持 (Windows/macOS/Linux) | -| 依赖链兼容 | ✅ | 5 个运行时依赖全部纯 Dart,零原生代码 | -| 路径处理 | ✅ | `package:path` + Context 抽象 + 反斜杠归一化 | -| 文件 I/O | ✅ | 仅使用 `dart:io` 跨平台 API | -| 测试覆盖 | ✅ | 3 个 Windows 专项路径测试 | -| 命令行输出 | ✅ | ANSI 转义码 — Win10+ Terminal 完整支持 | -| 原生编译 | ✅ | `dart compile exe` 产出独立 .exe | - ---- - -## 2. 依赖链逐项审查 - -### 2.1 运行时依赖 - -| 依赖 | 版本 | 类型 | Windows 兼容 | 备注 | -|------|------|------|-------------|------| -| `args` | ^2.5.0 | 纯 Dart | ✅ | CLI 参数解析 | -| `analyzer` | ^7.3.0 | 纯 Dart | ✅ | Dart AST 解析 | -| `glob` | ^2.1.2 | 纯 Dart | ✅ | 文件模式匹配 | -| `path` | ^1.9.0 | 纯 Dart | ✅ | 跨平台路径(含 Windows 风格 Context) | -| `yaml` | ^3.1.2 | 纯 Dart | ✅ | YAML 解析 | - -### 2.2 开发依赖 - -| 依赖 | 类型 | Windows 兼容 | -|------|------|-------------| -| `test` | 纯 Dart | ✅ | -| `lints` | 纯 Dart | ✅ | -| `melos` | 纯 Dart | ✅ | - -**结论**: 零原生依赖,零 FFI 调用。编译产物为单文件 native exe,无需安装 Dart runtime。 - ---- - -## 3. 代码级 Windows 兼容性分析 - -### 3.1 `dart:io` 使用情况 - -共 11 个源文件使用 `dart:io`,仅使用跨平台 API: - -| API | 用途 | Windows 兼容 | -|-----|------|-------------| -| `File.readAsStringSync()` | 读取源码 | ✅ | -| `File.readAsLinesSync()` | 按行读取 | ✅ | -| `File.writeAsStringSync()` | 写入报告 | ✅ | -| `File.existsSync()` | 文件存在检查 | ✅ | -| `Directory.existsSync()` | 目录存在检查 | ✅ | -| `Directory.createSync()` | 创建输出目录 | ✅ | -| `stdout.writeln()` | 控制台输出 | ✅ | -| `stderr.writeln()` | 错误输出 | ✅ | -| `exit()` | 进程退出 | ✅ | - -**未使用的 API**: `Platform.isWindows`, `Platform.isMacOS`, `Platform.operatingSystem`, `Process.run`, `Process.start`, `dart:ffi` — 均无引用。 - -### 3.2 路径处理策略 - -核心机制:`package:path` 的 `p.Context` 系统。 - -``` -p.Context(style: p.Style.windows, current: r'C:\repo') - └── 自动适配 \ 分隔符 → normalize / join / relative 等操作 -``` - -反斜杠归一化:以下 5 处代码显式将 `\` 转为 `/`,确保 `package:glob` 正常工作(glob 包要求正斜杠): - -| 文件 | 行号位置 | 上下文 | -|------|---------|--------| -| `file_collector.dart` | 15, 24 | include/exclude 模式 | -| `layer_violation.dart` | 34 | layer path 模式 | -| `module_violation.dart` | 34 | module path 模式 | -| `path_utils.dart` | 31 | matchesProjectGlob 内部 | - -测试中的 Windows 路径 Context 覆盖: - -```dart -// scanner_test.dart:302-314 — Windows 路径 glob 匹配测试 -final windows = p.Context(style: p.Style.windows, current: r'C:\repo'); -matchesProjectGlob( - r'C:\repo\lib\presentation\device_page.dart', - 'lib/presentation/**', - r'C:\repo', - context: windows, -) // → true - -// scanner_test.dart:331-345 — Windows 路径 package: 导入解析 -resolveImport( - r'C:\repo\lib\presentation\page.dart', - 'package:app/data/repo.dart', - {...}, - projectPath: r'C:\repo', - context: windows, -) // → C:\repo\lib\data\repo.dart -``` - -### 3.3 潜在风险点 - -| 风险 | 等级 | 影响 | 缓解 | -|------|------|------|------| -| 无 Windows CI 真实验证 | ⚠️ 低 | — | Windows Context 模拟测试已覆盖核心路径逻辑 | -| ANSI 转义码在旧 cmd.exe 不可见 | ⚠️ 低 | 显示原始转义字符,不影响功能 | Win10 1709+ 默认启用;建议在 Windows Terminal 中运行 | -| 驱动器号路径 (C:\\) | ✅ 无 | — | `p.Context(style: p.Style.windows)` 完整支持 | -| 长路径 (MAX_PATH > 260) | ⚠️ 低 | 极深嵌套项目可能遇到 | Dart SDK 3.11.5+ 已支持长路径 | -| 文件名大小写敏感 | ⚠️ 低 | Windows 文件系统不区分大小写,但 Dart 操作区分 | 影响极小 — glob 匹配和 import 解析均使用归一化路径比对 | - ---- - -## 4. 安装与运行 — Windows 完整步骤 - -### 4.1 前置条件 - -``` -1. 安装 Dart SDK 3.11.5+ - - 下载: https://dart.dev/get-dart - - Windows 安装包 (x64 / arm64) - - 验证: dart --version - -2. 确认 PATH 环境变量包含: - - Dart SDK bin 目录 (安装器自动配置) - - Pub cache bin: %USERPROFILE%\AppData\Local\Pub\Cache\bin -``` - -### 4.2 从源码编译 - -```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 -``` - -### 4.3 全局激活 - -```powershell -dart pub global activate --source path packages\flutterguard_cli -flutterguard --help -``` - -### 4.4 常见 Windows 问题排查 - -**问题 1**: `where flutterguard` 指向旧版本 - -```powershell -where flutterguard # 检查当前路径 -dart pub global deactivate flutterguard_cli # 删除旧版 -dart pub global activate --source path packages\flutterguard_cli # 安装当前版 -``` - -**问题 2**: PowerShell 显示 "API key required" - -说明系统解析到了另一个 FlutterGuard 二进制(旧版运行时追踪版本)。本仓库 CLI 是纯静态扫描工具,不需要 API Key。运行: - -```powershell -.\flutterguard.exe scan -p D:\your\project # 显式指定本地编译产物 -``` - -**问题 3**: 中文输出显示乱码 - -```powershell -# PowerShell 中设置 UTF-8 编码 -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 -# 或使用 Windows Terminal(推荐),默认支持 UTF-8 -``` - -**问题 4**: 颜色/ANSI 转义码不显示 - -确认使用 Windows Terminal(Windows 10/11 自带)而非旧 cmd.exe。旧 cmd.exe 可能显示原始转义字符 `[31m` 等,不影响功能,仅影响外观。 - ---- - -## 5. 与 macOS 的差异对比 - -| 特性 | macOS | Windows | -|------|-------|---------| -| Dart SDK 安装 | `brew install dart` 或官网下载 | 官网安装包 | -| 全局命令路径 | `$HOME/.pub-cache/bin` | `%USERPROFILE%\AppData\Local\Pub\Cache\bin` | -| 路径分隔符 | `/` | `\`(代码已做归一化处理) | -| 原生二进制输出 | `flutterguard` | `flutterguard.exe` | -| 终端颜色 | 默认支持 | 需 Windows Terminal / Win10+ | -| 示例命令 | `flutterguard scan -p /Users/me/project` | `flutterguard scan -p D:\dev\project` | - ---- - -## 6. CI 集成 — Windows Runner - -### GitHub Actions - -```yaml -jobs: - flutterguard: - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - uses: dart-lang/setup-dart@v1 - with: - sdk: 3.11.5 - - name: Install FlutterGuard - run: | - dart pub global activate flutterguard_cli - - name: Scan - run: flutterguard scan -p . --format json --fail-on high -``` - -### 本地 CI 脚本 (PowerShell) - -```powershell -# scan.ps1 -$score = & flutterguard scan -p . --format json --fail-on high --min-score 80 -if ($LASTEXITCODE -ne 0) { - Write-Host "CI gate failed!" -ForegroundColor Red - exit 1 -} -Write-Host "All checks passed!" -ForegroundColor Green -``` - ---- - -## 7. 总结 - -**FlutterGuard CLI 在 Windows 上已达到生产可用状态**。所有路径处理、文件 I/O、import 解析等核心逻辑均通过 `package:path` Context 系统进行了跨平台抽象,并在测试中覆盖了 Windows 路径场景。 - -**建议**: -1. 在真实 Windows 环境中运行一次全量测试,作为 CI 补充验证 -2. 使用 Windows Terminal 以获得最佳输出效果 -3. 如需要在旧 cmd.exe 运行,可考虑添加 `--no-color` 参数(建议后续版本实现) diff --git a/opencode.json b/opencode.json deleted file mode 100644 index 162d075..0000000 --- a/opencode.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "instructions": [ - "AGENTS.md", - "PROJECT_RULES.md", - "packages/*/AGENTS.md", - "docs/ARCHITECTURE.md" - ] -} From 5baa5b9a664fdcc91af2a6ef41a10947c0ecb00f Mon Sep 17 00:00:00 2001 From: lizy Date: Sun, 19 Jul 2026 11:58:57 +0800 Subject: [PATCH 12/16] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=E6=97=A7?= =?UTF-8?q?=E7=9A=84=20monorepo=20=E7=BB=93=E6=9E=84=E3=80=81=E6=97=A7?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E3=80=81=E6=97=A7=E7=A4=BA=E4=BE=8B=E5=92=8C?= =?UTF-8?q?=E6=97=A7=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ARCHITECTURE.md | 152 -- docs/FLUTTERGUARD_SPEC.md | 732 ------- examples/scan_demo/README.md | 16 - examples/scan_demo/flutterguard.yaml | 13 - examples/scan_demo/lib/main.dart | 14 - .../scan_demo/lib/services/user_service.dart | 51 - .../scan_demo/lib/widgets/profile_card.dart | 62 - examples/scan_demo/pubspec.yaml | 6 - melos.yaml | 21 - packages/flutterguard_cli/AGENTS.md | 72 - packages/flutterguard_cli/CHANGELOG.md | 122 -- packages/flutterguard_cli/LICENSE | 21 - packages/flutterguard_cli/README.md | 213 -- .../flutterguard_cli/analysis_options.yaml | 5 - packages/flutterguard_cli/bin/AGENTS.md | 27 - .../flutterguard_cli/bin/flutterguard.dart | 523 ----- packages/flutterguard_cli/lib/AGENTS.md | 12 - .../lib/flutterguard_cli.dart | 33 - packages/flutterguard_cli/lib/src/AGENTS.md | 29 - .../flutterguard_cli/lib/src/baseline.dart | 136 -- .../lib/src/boundary_engine.dart | 79 - .../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 | 80 - .../lib/src/cli/scan_command.dart | 99 - .../lib/src/config_loader.dart | 520 ----- .../lib/src/config_tools.dart | 775 ------- packages/flutterguard_cli/lib/src/domain.dart | 5 - .../lib/src/file_collector.dart | 149 -- .../lib/src/import_graph.dart | 74 - .../lib/src/import_utils.dart | 69 - .../lib/src/install_doctor.dart | 62 - .../lib/src/issue_export.dart | 113 - .../flutterguard_cli/lib/src/path_utils.dart | 46 - .../flutterguard_cli/lib/src/priority.dart | 1 - .../lib/src/project_resolver.dart | 53 - .../lib/src/report_generator.dart | 308 --- .../flutterguard_cli/lib/src/rule_meta.dart | 47 - .../flutterguard_cli/lib/src/rules/AGENTS.md | 42 - .../lib/src/rules/ble_scanning.dart | 169 -- .../lib/src/rules/bloc_state_management.dart | 115 -- .../lib/src/rules/catalog.dart | 262 --- .../lib/src/rules/circular_dependency.dart | 130 -- .../lib/src/rules/device_lifecycle.dart | 104 - .../src/rules/generic_state_management.dart | 506 ----- .../lib/src/rules/iot_security.dart | 190 -- .../lib/src/rules/large_units.dart | 195 -- .../lib/src/rules/layer_violation.dart | 90 - .../lib/src/rules/lifecycle_resource.dart | 133 -- .../src/rules/missing_const_constructor.dart | 98 - .../lib/src/rules/module_violation.dart | 90 - .../lib/src/rules/mqtt_connection.dart | 161 -- .../src/rules/provider_state_management.dart | 348 ---- .../lib/src/rules/pubspec_security.dart | 200 -- .../lib/src/rules/registry.dart | 13 - .../src/rules/riverpod_state_management.dart | 306 --- .../lib/src/rules/state_dependency_cycle.dart | 437 ---- .../lib/src/rules/state_management_utils.dart | 236 --- .../lib/src/sarif_report.dart | 104 - .../lib/src/scan_context.dart | 26 - .../flutterguard_cli/lib/src/scanner.dart | 220 -- .../lib/src/source_utils.dart | 4 - .../lib/src/source_workspace.dart | 97 - .../lib/src/static_issue.dart | 61 - .../flutterguard_cli/lib/src/suppression.dart | 67 - packages/flutterguard_cli/pubspec.yaml | 28 - packages/flutterguard_cli/test/AGENTS.md | 31 - packages/flutterguard_cli/test/cli_test.dart | 209 -- .../flutterguard_cli/test/fixtures/AGENTS.md | 33 - .../test/fixtures/architecture_config.yaml | 31 - .../test/fixtures/architecture_disabled.yaml | 35 - .../test/fixtures/ble_scanning_issue.dart | 17 - .../test/fixtures/bloc_state.dart | 34 - .../test/fixtures/boundary_issue.dart | 5 - .../test/fixtures/cycle_a.dart | 7 - .../test/fixtures/cycle_b.dart | 7 - .../test/fixtures/cycle_c.dart | 7 - .../test/fixtures/device_lifecycle_issue.dart | 8 - .../test/fixtures/forbidden_file.dart | 2 - .../test/fixtures/generic_state.dart | 90 - .../test/fixtures/iot_security_issue.dart | 17 - .../test/fixtures/large_build.dart | 105 - .../test/fixtures/large_class.dart | 303 --- .../test/fixtures/large_file.dart | 501 ----- .../test/fixtures/lifecycle_issue.dart | 18 - .../test/fixtures/missing_const.dart | 27 - .../test/fixtures/mqtt_connection_issue.dart | 14 - .../test/fixtures/provider_state.dart | 48 - .../test/fixtures/riverpod_state.dart | 45 - .../test/fixtures/state_suppression.dart | 66 - .../flutterguard_cli/test/scanner_test.dart | 1820 ----------------- scripts/compile.ps1 | 16 - scripts/compile.sh | 17 - scripts/flutterguard-dev | 5 - scripts/flutterguard-dev.ps1 | 5 - scripts/scan_ci.ps1 | 29 - scripts/scan_ci.sh | 28 - 99 files changed, 13216 deletions(-) delete mode 100644 docs/ARCHITECTURE.md delete mode 100644 docs/FLUTTERGUARD_SPEC.md delete mode 100644 examples/scan_demo/README.md delete mode 100644 examples/scan_demo/flutterguard.yaml delete mode 100644 examples/scan_demo/lib/main.dart delete mode 100644 examples/scan_demo/lib/services/user_service.dart delete mode 100644 examples/scan_demo/lib/widgets/profile_card.dart delete mode 100644 examples/scan_demo/pubspec.yaml delete mode 100644 melos.yaml delete mode 100644 packages/flutterguard_cli/AGENTS.md delete mode 100644 packages/flutterguard_cli/CHANGELOG.md delete mode 100644 packages/flutterguard_cli/LICENSE delete mode 100644 packages/flutterguard_cli/README.md delete mode 100644 packages/flutterguard_cli/analysis_options.yaml delete mode 100644 packages/flutterguard_cli/bin/AGENTS.md delete mode 100644 packages/flutterguard_cli/bin/flutterguard.dart delete mode 100644 packages/flutterguard_cli/lib/AGENTS.md delete mode 100644 packages/flutterguard_cli/lib/flutterguard_cli.dart delete mode 100644 packages/flutterguard_cli/lib/src/AGENTS.md delete mode 100644 packages/flutterguard_cli/lib/src/baseline.dart delete mode 100644 packages/flutterguard_cli/lib/src/boundary_engine.dart delete mode 100644 packages/flutterguard_cli/lib/src/cli/baseline_commands.dart delete mode 100644 packages/flutterguard_cli/lib/src/cli/cli_parsers.dart delete mode 100644 packages/flutterguard_cli/lib/src/cli/config_commands.dart delete mode 100644 packages/flutterguard_cli/lib/src/cli/issue_commands.dart delete mode 100644 packages/flutterguard_cli/lib/src/cli/rule_commands.dart delete mode 100644 packages/flutterguard_cli/lib/src/cli/scan_command.dart delete mode 100644 packages/flutterguard_cli/lib/src/config_loader.dart delete mode 100644 packages/flutterguard_cli/lib/src/config_tools.dart delete mode 100644 packages/flutterguard_cli/lib/src/domain.dart delete mode 100644 packages/flutterguard_cli/lib/src/file_collector.dart delete mode 100644 packages/flutterguard_cli/lib/src/import_graph.dart delete mode 100644 packages/flutterguard_cli/lib/src/import_utils.dart delete mode 100644 packages/flutterguard_cli/lib/src/install_doctor.dart delete mode 100644 packages/flutterguard_cli/lib/src/issue_export.dart delete mode 100644 packages/flutterguard_cli/lib/src/path_utils.dart delete mode 100644 packages/flutterguard_cli/lib/src/priority.dart delete mode 100644 packages/flutterguard_cli/lib/src/project_resolver.dart delete mode 100644 packages/flutterguard_cli/lib/src/report_generator.dart delete mode 100644 packages/flutterguard_cli/lib/src/rule_meta.dart delete mode 100644 packages/flutterguard_cli/lib/src/rules/AGENTS.md delete mode 100644 packages/flutterguard_cli/lib/src/rules/ble_scanning.dart delete mode 100644 packages/flutterguard_cli/lib/src/rules/bloc_state_management.dart delete mode 100644 packages/flutterguard_cli/lib/src/rules/catalog.dart delete mode 100644 packages/flutterguard_cli/lib/src/rules/circular_dependency.dart delete mode 100644 packages/flutterguard_cli/lib/src/rules/device_lifecycle.dart delete mode 100644 packages/flutterguard_cli/lib/src/rules/generic_state_management.dart delete mode 100644 packages/flutterguard_cli/lib/src/rules/iot_security.dart delete mode 100644 packages/flutterguard_cli/lib/src/rules/large_units.dart delete mode 100644 packages/flutterguard_cli/lib/src/rules/layer_violation.dart delete mode 100644 packages/flutterguard_cli/lib/src/rules/lifecycle_resource.dart delete mode 100644 packages/flutterguard_cli/lib/src/rules/missing_const_constructor.dart delete mode 100644 packages/flutterguard_cli/lib/src/rules/module_violation.dart delete mode 100644 packages/flutterguard_cli/lib/src/rules/mqtt_connection.dart delete mode 100644 packages/flutterguard_cli/lib/src/rules/provider_state_management.dart delete mode 100644 packages/flutterguard_cli/lib/src/rules/pubspec_security.dart delete mode 100644 packages/flutterguard_cli/lib/src/rules/registry.dart delete mode 100644 packages/flutterguard_cli/lib/src/rules/riverpod_state_management.dart delete mode 100644 packages/flutterguard_cli/lib/src/rules/state_dependency_cycle.dart delete mode 100644 packages/flutterguard_cli/lib/src/rules/state_management_utils.dart delete mode 100644 packages/flutterguard_cli/lib/src/sarif_report.dart delete mode 100644 packages/flutterguard_cli/lib/src/scan_context.dart delete mode 100644 packages/flutterguard_cli/lib/src/scanner.dart delete mode 100644 packages/flutterguard_cli/lib/src/source_utils.dart delete mode 100644 packages/flutterguard_cli/lib/src/source_workspace.dart delete mode 100644 packages/flutterguard_cli/lib/src/static_issue.dart delete mode 100644 packages/flutterguard_cli/lib/src/suppression.dart delete mode 100644 packages/flutterguard_cli/pubspec.yaml delete mode 100644 packages/flutterguard_cli/test/AGENTS.md delete mode 100644 packages/flutterguard_cli/test/cli_test.dart delete mode 100644 packages/flutterguard_cli/test/fixtures/AGENTS.md delete mode 100644 packages/flutterguard_cli/test/fixtures/architecture_config.yaml delete mode 100644 packages/flutterguard_cli/test/fixtures/architecture_disabled.yaml delete mode 100644 packages/flutterguard_cli/test/fixtures/ble_scanning_issue.dart delete mode 100644 packages/flutterguard_cli/test/fixtures/bloc_state.dart delete mode 100644 packages/flutterguard_cli/test/fixtures/boundary_issue.dart delete mode 100644 packages/flutterguard_cli/test/fixtures/cycle_a.dart delete mode 100644 packages/flutterguard_cli/test/fixtures/cycle_b.dart delete mode 100644 packages/flutterguard_cli/test/fixtures/cycle_c.dart delete mode 100644 packages/flutterguard_cli/test/fixtures/device_lifecycle_issue.dart delete mode 100644 packages/flutterguard_cli/test/fixtures/forbidden_file.dart delete mode 100644 packages/flutterguard_cli/test/fixtures/generic_state.dart delete mode 100644 packages/flutterguard_cli/test/fixtures/iot_security_issue.dart delete mode 100644 packages/flutterguard_cli/test/fixtures/large_build.dart delete mode 100644 packages/flutterguard_cli/test/fixtures/large_class.dart delete mode 100644 packages/flutterguard_cli/test/fixtures/large_file.dart delete mode 100644 packages/flutterguard_cli/test/fixtures/lifecycle_issue.dart delete mode 100644 packages/flutterguard_cli/test/fixtures/missing_const.dart delete mode 100644 packages/flutterguard_cli/test/fixtures/mqtt_connection_issue.dart delete mode 100644 packages/flutterguard_cli/test/fixtures/provider_state.dart delete mode 100644 packages/flutterguard_cli/test/fixtures/riverpod_state.dart delete mode 100644 packages/flutterguard_cli/test/fixtures/state_suppression.dart delete mode 100644 packages/flutterguard_cli/test/scanner_test.dart delete mode 100644 scripts/compile.ps1 delete mode 100755 scripts/compile.sh delete mode 100755 scripts/flutterguard-dev delete mode 100644 scripts/flutterguard-dev.ps1 delete mode 100644 scripts/scan_ci.ps1 delete mode 100755 scripts/scan_ci.sh diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index 31bd11e..0000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,152 +0,0 @@ -# FlutterGuard Architecture - -## Monorepo Structure -``` -flutterguard/ -├── flutterguard.yaml # CLI config example / root defaults -├── melos.yaml # Monorepo bootstrap + script orchestration -├── AGENTS.md # Agent-parseable project rules -├── PROJECT_RULES.md # Human + agent constitution -├── analysis_options.yaml # Root strict analysis config -├── pubspec.yaml # Root workspace (melos only) -├── docs/ -│ ├── FLUTTERGUARD_SPEC.md # Single source of truth -│ └── ARCHITECTURE.md # This file -├── packages/ -│ └── flutterguard_cli/ # [ACTIVE] CLI static analysis -├── archive/ # Runtime tracing (v0.1.0, reserved) -│ ├── flutterguard_core/ -│ ├── flutterguard_dio/ -│ └── flutterguard_flutter/ -└── examples/ - └── scan_demo/ # CLI scan target -``` - -## Dependency Graph -``` - ┌──────────────────┐ - │ flutterguard │ (root workspace) - └────────┬─────────┘ - │ melos - ▼ - ┌──────────────────────┐ - │ flutterguard_cli │ - │ [ACTIVE] │ - │ deps: args,analyzer,│ - │ glob,path,yaml │ - └──────────────────────┘ -``` - -## CLI Scan Data Flow -``` -User runs: dart run flutterguard scan -p [--config ] - │ - ├── 1. ArgParser parses CLI flags - ├── 2. ScanConfig.fromFile() loads YAML config - │ └── includes architecture.layers/modules declarations - ├── 3. FileCollector.collect() globs .dart files - │ └── applies include/exclude patterns - ├── 4. ScanContext separates all files from changed target files - ├── 5. SourceWorkspace reads/parses each target source once - │ └── read/parse failures become ScanDiagnostic entries - ├── 6. RuleCatalog explicitly wires 21 rule classes / 23 rule IDs - │ ├── source rules share SourceWorkspace content/AST/line info - │ ├── architecture rules share one ImportGraph - │ └── layer/module checks share DependencyBoundaryEngine - ├── 7. Issues sorted by risk level (high → medium → low) - ├── 8. Suppression and baseline filters produce visible issues - ├── 9. ReportGenerator generates output - │ ├── Table → terminal stdout - │ └── JSON → .flutterguard/report.json - └── 10. CI gate check (exit 1 if fail threshold exceeded) -``` - -## Rule Architecture -``` -Direct rule tests and programmatic consumers retain the standalone API: - ┌──────────────────────────────────────────────┐ - │ class XxxRule { │ - │ analyze(List files, │ - │ {SourceWorkspace? workspace}) │ - │ → List │ - │ } │ - └──────────────────────────────────────────────┘ - -Issue model (StaticIssue): - id, title, file, line, level - + domain (architecture/performance/standards) - + priority (p0/p1/p2) - + message, detail, suggestion, metadata - + framework, confidence, evidence (up to 5 entries) -``` - -Scanner execution uses one `ScanContext` and one explicit `RuleCatalog`. -There is no dynamic plugin loading, reflection, or rule code generation. - -Architecture data flow: - -``` -SourceWorkspace → ImportGraph → DependencyBoundaryEngine - ├── LayerViolationRule - └── ModuleViolationRule - ImportGraph ───── CircularDependencyRule -``` - -State-management data flow: - -``` -SourceWorkspace AST - ├── shared build/callback/import/owner/glob helpers - ├── generic build, mutability and UI-boundary rules - ├── Riverpod / Bloc / Provider framework rules - └── project-wide state graph → Tarjan SCC → deterministic shortest cycle -``` - -State rules are gated in this order: global `state_management.enabled`, the -per-rule switch, confidence threshold, framework import auto-detection, -`ignore_paths`, AST detection, then the existing suppression and baseline -filters. Changed-only state-cycle analysis builds from `allFiles` but reports -only components touching `targetFiles`. - -## CLI Command Layout - -`bin/flutterguard.dart` owns top-level routing, help, positional-path -normalization, and documented exit codes. Functional commands live under -`lib/src/cli/`: - -- `cli_parsers.dart`: parser tree and command option contracts -- `scan_command.dart`: scan reporting and CI gates -- `baseline_commands.dart`: create/stats/prune/check -- `config_commands.dart`: init/config/doctor behavior -- `issue_commands.dart`: issue feedback export -- `rule_commands.dart`: rules/explain output - -## Output Formats - -| Format | Purpose | Enabled | -|--------|---------|---------| -| table | Human-readable terminal output grouped by domain | Default | -| json | Machine-readable for CI and custom tooling | --format=json | -| sarif | GitHub Code Scanning integration | --format=sarif | - -## Override Chains - -### pubspec_overrides.yaml (melos-managed) -- Only `flutterguard_cli` remains (no path dependencies) -- Run `melos bootstrap` after any pubspec.yaml change - -### analysis_options.yaml Inheritance -``` -root/analysis_options.yaml - strict-casts: true, strict-inference: true - └── packages/flutterguard_cli/analysis_options.yaml - inherits root + package:lints/recommended.yaml - excludes: test/fixtures/** -``` - -### flutterguard.yaml Config Override -``` -root/flutterguard.yaml (default config, documented example) - ├── /flutterguard.yaml (per-project config, merges over root) - └── --config flag (CLI arg, highest priority) -``` diff --git a/docs/FLUTTERGUARD_SPEC.md b/docs/FLUTTERGUARD_SPEC.md deleted file mode 100644 index 577d47e..0000000 --- a/docs/FLUTTERGUARD_SPEC.md +++ /dev/null @@ -1,732 +0,0 @@ -# FlutterGuard — Specification Document - -> **Internal use only. Not git tracked.** -> This document is the single source of truth for agent-driven implementation. - -Version: M5 (Milestone 5) — State Maintainability | Last Updated: 2026-07-16 - ---- - -## 0. Scope - -### 0.1 Identity -FlutterGuard is an **IoT/smart home Flutter project static analysis CLI plugin**. It scans Flutter/Dart source code to detect architecture issues, security vulnerabilities, and anti-patterns specific to IoT device applications. - -### 0.2 Active Development -- **Path A (CLI static analysis)**: PRIMARY — actively developed. All new feature work targets `packages/flutterguard_cli/`. -- **Path B (runtime tracing)**: ARCHIVED — superseded by CLI static analysis. The packages `flutterguard_core/`, `flutterguard_dio/`, `flutterguard_flutter/` reside in `archive/` for future reference. - -### 0.3 What FlutterGuard IS -- A CLI tool (compiled native binary) for CI-gated static analysis -- An architecture enforcement tool with YAML-driven config -- An IoT-domain-aware rule engine for Flutter projects -- An import dependency and layer compliance checker - -### 0.4 What FlutterGuard is NOT -- NOT a runtime observability or APM SDK -- NOT a crash reporter (Sentry/Crashlytics alternative) -- NOT a general-purpose Dart linter (use `dart analyze` / `custom_lint`) -- NOT a web dashboard or data visualization platform -- NOT a network proxy or HTTP inspector -- NOT a Flutter widget library - -### 0.5 Analysis Scope -- **Input**: `.dart` source files under `lib/` (configurable via glob patterns) -- **Output**: Table (terminal) / JSON (CI) / SARIF (GitHub Code Scanning) — no Markdown -- **Config**: `flutterguard.yaml` in project root -- **Runtime**: No runtime instrumentation — purely static analysis at compile time - ---- - -## 1. Architecture Overview - -``` -User runs: flutterguard scan [] [--changed-only] [--base main] [--baseline .flutterguard/baseline.json] - │ - ├── 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. Scan: 21 rule classes analyze source/project state (23 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) - │ ├── 5 generic state rules (build, mutability, UI boundary, cycle) - │ ├── 2 Riverpod rules (read/render, watch/callback) - │ ├── 1 Bloc rule (Equatable props completeness) - │ └── 2 Provider rules (ownership, loop notifications) - │ └── (changed-only: import cycle skipped; state cycle uses full graph) - ├── 5. Issues sorted by risk level (high → medium → low) - ├── 6. Suppression comments and optional baseline filter visible issues - ├── 7. ReportGenerator generates output - │ ├── Table → terminal stdout - │ └── JSON → .flutterguard/report.json - │ └── SARIF → .flutterguard/report.sarif - └── 8. CI gate check (exit 1 if fail threshold exceeded) -``` - -### Key Design Decisions - -| Decision | Rationale | -|----------|-----------| -| `package:analyzer` for AST | Resolves type information for resource detection | -| `package:glob` for file matching | Consistent glob support for include/exclude and architecture paths | -| Shared scan data | `ScanContext` carries project/all/target files; `SourceWorkspace` caches source text, AST, line info, and diagnostics | -| Rule class compatibility | Direct rule calls retain `analyze(List files, {SourceWorkspace? workspace})`; scanner execution is wired by `RuleCatalog` | -| Shared architecture kernel | Layer/module/cycle rules consume one `ImportGraph`; layer/module checks share `DependencyBoundaryEngine` | -| No plugin system | Rules are explicitly wired in `RuleCatalog` — no reflection, no codegen | - ---- - -## 2. Package Map & Dependencies - -``` -flutterguard/ -├── packages/ -│ └── flutterguard_cli/ Dart CLI (ACTIVE) -│ └── depends: args, analyzer ^7.3.0, glob, path, yaml -├── archive/ Reserved — runtime tracing (v0.1.0) -│ ├── flutterguard_core/ -│ ├── flutterguard_dio/ -│ └── flutterguard_flutter/ -└── examples/ - └── scan_demo/ Scan target demo -``` - -**Compile target**: `dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard` → native binary - ---- - -## 3. Data Models - -### 3.1 StaticIssue (CLI) - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| id | String | yes | Rule ID e.g. `layer_violation` | -| title | String | yes | Human title | -| file | String | yes | Absolute path | -| line | int? | no | Line number | -| level | RiskLevel | yes | low / medium / high | -| domain | IssueDomain | yes | architecture / performance / standards | -| priority | Priority | yes | p0 / p1 / p2 | -| message | String | yes | Short explanation | -| detail | String | no | Long description with context | -| suggestion | String | yes | Fix recommendation | -| metadata | Map | yes | Rule-specific data | -| framework | StateManagementFramework | yes | generic / riverpod / bloc / provider | -| confidence | RuleConfidence | yes | certain / probable / informational | -| evidence | List | yes | At most five compact AST evidence entries | - -### 3.2 RiskLevel Enum - -```dart -enum RiskLevel { low, medium, high } -``` - -### 3.3 IssueDomain Enum - -```dart -enum IssueDomain { architecture, performance, standards } -``` - -### 3.4 Priority Enum - -```dart -enum Priority { p0, p1, p2 } -``` - -### 3.5 State-management enums - -```dart -enum StateManagementFramework { riverpod, bloc, provider, generic } -enum RuleConfidence { certain, probable, informational } -``` - ---- - -## 4. CLI Contract - -### Command - -``` -flutterguard scan [options] - --path (-p) Project path to scan (default: .) - --config (-c) Config file path (default: flutterguard.yaml) - --format (-f) Output format: table | json | sarif (default: table) - --output (-o) Output directory (default: .flutterguard) - --verbose (-v) Show detailed output with code context - --fail-on CI gate: none | high | medium | low (default: none) - --min-score Minimum score threshold 0-100 - --changed-only Only scan .dart files changed since --base (default: false) - --base Git base ref for changed-only (default: main) - --baseline Baseline JSON file used to hide existing issues - -flutterguard baseline create [] - --output (-o) Baseline output path (default: .flutterguard/baseline.json) - -flutterguard rules [options] - --format (-f) Output format: table | json (default: table) - -flutterguard explain -``` - -### Exit Codes - -| Code | Meaning | -|------|---------| -| 0 | Success, including a valid changed-only scan with no relevant changes | -| 1 | CI gate failed (issues at/below `--fail-on` level or score < `--min-score`) | -| 2 | Scan/explain setup error (bad path, missing explicit config, invalid config, zero configured Dart files, unknown rule ID) | - -### File Collection - -- Use `glob` package to match `include` patterns -- Remove matching files for `exclude` patterns -- Only `.dart` files -- Default include: `lib/**` -- Default exclude: `lib/generated/**`, `lib/**.g.dart`, `lib/**.freezed.dart`, `lib/**.mocks.dart` - ---- - -## 5. Config Schema (flutterguard.yaml) - -```yaml -include: - - lib/** # Glob patterns for files to scan - -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 - side_effect_in_build: - enabled: true - severity: high - allowlist: [] - ignore_paths: [] - # The other nine state rules use the same four keys. - -state_management: - enabled: true - framework_auto_detect: true - confidence_threshold: certain - -architecture: # Architecture layer/module rules - layers: # Layered architecture enforcement - - 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: # Business module isolation - - name: device_mqtt - path: lib/device/mqtt/** - allowed_deps: [domain, core] - - name: device_ble - path: lib/device/ble/** - allowed_deps: [domain, core] - - detect_cycles: true # Circular dependency detection -``` - -State-rule `severity` accepts `high`, `medium`, or `low` and maps to P0, P1, -or P2 respectively. `ignore_paths` values are project-relative POSIX globs. -`allowlist` values are exact rule-specific symbols; dependency-cycle edges use -`Source->Target`. Unknown enum values, invalid severity values, and wrong value -types are configuration errors. Missing state configuration uses the defaults -above and remains compatible with older YAML files. - ---- - -## 6. Export Format Spec - -### 6.1 JSON Report Schema - -```json -{ - "version": "1.0.0", - "generatedAt": "ISO8601", - "scanMode": "full|changed", - "projectPath": "/absolute/path", - "score": 85, - "summary": { - "total": 4, - "high": 1, - "medium": 2, - "low": 1, - "suppressed": 0, - "suppressedByBaseline": 0, - "diagnostics": 0, - "byDomain": { - "architecture": { "high": 1, "medium": 1, "low": 0, "total": 2 }, - "performance": { "high": 0, "medium": 1, "low": 0, "total": 1 }, - "standards": { "high": 0, "medium": 0, "low": 1, "total": 1 } - } - }, - "issues": [ - { - "id": "layer_violation", - "ruleId": "layer_violation", - "title": "层间依赖违规", - "file": "/absolute/path/to/file.dart", - "line": 42, - "level": "high", - "severity": "high", - "domain": "architecture", - "priority": "p0", - "message": "Short description", - "detail": "Long description with import path and layer info", - "suggestion": "Fix recommendation", - "metadata": { "sourceLayer": "service", "targetLayer": "widget" }, - "framework": "generic", - "confidence": "certain", - "evidence": [] - } - ], - "diagnostics": [] -} -``` - -### 6.2 Score Calculation - -``` -score = max(0, 100 - high*10 - medium*4 - low*1) -``` - -### 6.3 Table Output (Terminal) - -Default output format. Example: - -``` - -### 6.4 SARIF Output - -`--format sarif` writes `.flutterguard/report.sarif` and prints a short stdout summary. - -- SARIF version: `2.1.0` -- Rule metadata source: `RuleRegistry` -- Result severity mapping: high → `error`, medium → `warning`, low → `note` -- Location URI: project-relative path when possible -- Location line: `StaticIssue.line`, or line `1` when absent - -### 6.5 Suppression Comments - -Supported source comments: - -```dart -// flutterguard: ignore -// flutterguard: ignore , -// flutterguard: ignore all -``` - -Suppression applies only to issues on the comment line and the immediately following line. It does not support file-wide disable blocks or cross-file suppression. Suppressed issues are hidden from normal outputs and CI gates. JSON summary includes `suppressed`. - -### 6.6 Baseline - -```bash -flutterguard baseline create . --output .flutterguard/baseline.json -flutterguard scan . --baseline .flutterguard/baseline.json -``` - -Baseline files contain a sorted list of issue fingerprints. The fingerprint input is: - -``` -id + relative file path + line + message -``` - -Baseline-matched issues are hidden from stdout/JSON/SARIF and do not affect `--fail-on` or `--min-score`. Missing or invalid baseline files are scan errors with exit code 2 through the CLI. JSON summary includes `suppressedByBaseline`. - FlutterGuard Report ─ scan_demo -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 总评分: 88/100 优秀 文件总数: 2 问题总数: 3 -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - 架构违规 1 items HIGH - ──────────────────────────────────────────────────────────────── - HIGH P0 优先 - 层间依赖违规 - lib/services/user_service.dart:7 - service 层不可依赖 widget 层 - 修复: 将导入的内容移至 core 或更抽象层 -``` - ---- - -## 7. Static Rules Detail - -### 7.1 large_file (RiskLevel: low, Domain: standards, Priority: p2) - -**Detection**: Count lines of file. If > maxLines (default 500), issue. - -**Implementation**: Simple file line count via `File.readAsLinesSync()`. - -### 7.2 large_class (RiskLevel: low, Domain: standards, Priority: p2) - -**Detection**: Find `class ClassName {` via analyzer AST. Calculate lines from class declaration to matching `}`. If > maxLines (default 300), issue. - -**Implementation**: Parse with `package:analyzer`, find `ClassDeclaration`, calculate line span. - -### 7.3 large_build_method (RiskLevel: medium, Domain: performance, Priority: p1) - -**Detection**: Find `Widget build(BuildContext ...)` method in any `ClassDeclaration`. Calculate line span. If > maxLines (default 80), issue. - -**Implementation**: Parse with `package:analyzer`, visit class members, match `MethodDeclaration` with name `build` and return type `Widget`. - -### 7.4 lifecycle_resource_not_disposed (RiskLevel: medium, Domain: performance, Priority: p1) - -**Detection**: -1. For each class, find non-static field declarations -2. Check if type matches: `StreamSubscription`, `Timer`, `AnimationController`, `TextEditingController`, `ScrollController`, `FocusNode` -3. Check type either as exact match or containing generic (``) -4. Find `dispose()` method in the class -5. In dispose method body, check if `${fieldName}.${expectedCall}()` exists -6. If not found, report issue - -**Resource → expected call**: -- StreamSubscription → cancel -- Timer → cancel -- AnimationController → dispose -- TextEditingController → dispose -- ScrollController → dispose -- FocusNode → dispose -- MqttClient → disconnect -- BluetoothDevice → disconnect -- StreamController → close - -**Implementation**: Parse with `package:analyzer`, visit `FieldDeclaration`, `MethodDeclaration`. Check dispose body via string contains. - -### 7.5 layer_violation (RiskLevel: high, Domain: architecture, Priority: p0) - -**Detection**: For each file, determine which architecture layer it belongs to by matching its path against `architecture.layers[].path` glob patterns. For each `import` directive, resolve the imported file and determine its layer. If the target layer is not in the source layer's `allowed_deps` list, report a violation. - -**Implementation**: Parse with `package:analyzer`, visit `ImportDirective`. Resolve imports via path normalization (supports relative and package: imports). Match layers via `Glob` from `package:glob`. - -### 7.6 module_violation (RiskLevel: high, Domain: architecture, Priority: p0) - -**Detection**: Same mechanism as `layer_violation` but operates on `architecture.modules`. Checks import relationships between named business modules. - -**Implementation**: Same as 7.5 but uses `ModuleConfig` instead of `LayerConfig`. - -### 7.7 circular_dependency (RiskLevel: medium, Domain: architecture, Priority: p1) - -**Detection**: Build a directed import graph for all scanned files. Use DFS with white/gray/black coloring to detect cycles. Report each unique cycle found. - -**Limitations**: -- Detects file-level cycles only (not class-level) -- Only resolves imports within the scanned file set -- May report the same logical cycle from different entry points - -### 7.8 missing_const_constructor (RiskLevel: low, Domain: standards, Priority: p2) - -**Detection**: -1. Find all `StatelessWidget` and `StatefulWidget` subclass declarations -2. Check if the class has a `const` constructor -3. If no `const` constructor is found and the class is a widget, report issue -4. Also checks plain classes where all fields are final and could be const - -### 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 | - -### 7.14-7.23 State-management maintainability rules - -All rules are AST-first, default enabled, and currently emit `certain` -findings. Common gating is global/per-rule enablement, confidence threshold, -framework import auto-detection, project-relative `ignore_paths`, then AST -detection. Existing source suppression and baseline filtering run afterward. - -| ID | Default | Framework | Detection contract | -|----|---------|-----------|--------------------| -| `side_effect_in_build` | high/P0 | generic | One issue per Widget/State/Consumer build root for direct state/resource effects; nested callbacks and local collection `add` are excluded. | -| `state_manager_created_in_build` | high/P0 | generic | One issue per Controller/Bloc/Cubit/Notifier/Flutter-controller construction in build; nested event and ownership callbacks are excluded. | -| `mutable_state_exposed` | medium/P1 | generic | Public non-final fields, mutable collection references/getters, and in-place `state` collection mutation in business state owners; Flutter `State` and unmodifiable values are excluded. | -| `state_layer_ui_dependency` | high/P0 | generic | One issue per state owner using BuildContext/Widget or navigation/dialog/messenger/media/theme APIs. Nested generic types without runtime use are ignored. | -| `state_dependency_cycle` | high/P0 | generic | Provider/state/service graph, Tarjan SCC, one deterministic shortest cycle per SCC containing a state node. Changed mode uses all files and reports only cycles touching a changed file. | -| `riverpod_read_used_for_render` | medium/P1 | riverpod | Local flow from `ref.read(provider)` into returned Widget construction or conditional/collection rendering; commands and callbacks are excluded. | -| `riverpod_watch_in_callback` | medium/P1 | riverpod | One issue per event/listener/timer/async callback containing `ref.watch`; build and provider declaration bodies are excluded. | -| `bloc_equatable_props_incomplete` | medium/P1 | bloc | One merged issue per Equatable class whose final instance fields are absent from `props`. | -| `provider_value_lifecycle_misuse` | medium/P1 | provider | `.value(value: new Instance())` and `create: (_) => existingInstance`; const/immutable/allowlisted values are excluded. BlocProvider is accepted. | -| `notify_listeners_in_loop` | medium/P1 | provider | One issue per for/for-in/while/do/forEach root containing `notifyListeners`; literal 0/1 iteration is excluded. | - -Framework auto-detection recognizes Riverpod, Bloc, Equatable, Provider and -Flutter Bloc imports. With `framework_auto_detect: false`, import gates are -skipped but AST shapes remain mandatory. Evidence is deduplicated and capped at -five entries. - ---- - -## 8. Test Contracts - -### 8.1 CLI Tests (83 tests) - -| Test | Fixture | Then | -|------|---------|------| -| scan_detects_large_file | large_file.dart (501 lines) | 1 issue, id=large_file | -| scan_detects_large_class | large_class.dart (class 303 lines) | 1 issue, id=large_class | -| scan_detects_large_build_method | large_build.dart (build method 81+ lines) | 1 issue, id=large_build_method | -| scan_detects_lifecycle_resource | lifecycle_issue.dart | 2+ issues, id=lifecycle_resource_not_disposed | -| scan_detects_layer_violation | boundary_issue.dart + architecture_config.yaml | 1+ issues, id=layer_violation | -| scan_detects_module_violation | boundary_issue.dart + architecture_config.yaml | 1+ issues, id=module_violation | -| scan_detects_circular_dependency | cycle_a.dart, cycle_b.dart, cycle_c.dart | 1+ issues, id=circular_dependency | -| scan_detects_missing_const_constructor | missing_const.dart | 1+ issues, id=missing_const_constructor | -| config_parses_enabled_flags | architecture_config.yaml + architecture_disabled.yaml | enabled/true, disabled/false | -| wiring_disabled_layer_module | architecture_disabled.yaml + boundary fixtures | 0 issues | -| ci_fail_on_high | array with high issue | shouldFail(issues, 'high') == true | -| json_report_generated | issues array | output contains expected fields | -| scan_detects_lifecycle_iot_resource | lifecycle_issue.dart (with MqttClient) | matches IoT types | -| 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_23_rules | RuleRegistry.all() | length == 23 | -| registry_find_returns_correct_meta | find('large_file') | non-null, correct id/domain | -| registry_find_unknown_returns_null | find('nonexistent') | null | - -### 8.2 Fixture Files - -Located at `packages/flutterguard_cli/test/fixtures/`: - -| File | Description | -|------|-------------| -| large_file.dart | 501 generated comment lines | -| large_class.dart | Class with 303 lines (2 line wrapper + 301 filler) | -| large_build.dart | Widget build method with 81+ lines | -| lifecycle_issue.dart | StreamSubscription + Timer fields, empty dispose() | -| boundary_issue.dart | Imports forbidden_file.dart (reused for layer/module tests) | -| forbidden_file.dart | Target of layer/module violation | -| cycle_a.dart | Circular dependency start (imports cycle_b) | -| cycle_b.dart | Circular dependency middle (imports cycle_c) | -| cycle_c.dart | Circular dependency end (imports cycle_a) | -| architecture_config.yaml | Config with layer + module declarations | -| architecture_disabled.yaml | Config with layer/module violations disabled | -| missing_const.dart | Widget subclass without const constructor | - ---- - -## 9. Evolution Roadmap - -### M1 (Completed) — Static Scan MVP - -Key deliverables: -- CLI static scan with 4 rules (large_file, large_class, large_build_method, lifecycle_resource_not_disposed) -- JSON + Table report generation with CI gate -- Runtime tracing packages archived to `archive/` - -### M2 (Current) — Architecture Detection + Output Reform - -- Architecture layer/module enforcement (layer_violation, module_violation) -- Circular dependency detection (circular_dependency) -- `table` output format with domain grouping (architecture / performance / standards) -- Priority system (P0/P1/P2) per issue -- Cleaned up CLI params (removed markdown, group-by, top, no-module-score) -- SPEC rewrite: removed all runtime tracing documentation - -### M3 (Completed v0.3.0) — Incremental Scan + Rule Introspection + IoT Rules - -Key deliverables: -- `--changed-only` incremental scan via git diff (skips cyclic dep in changed mode) -- `flutterguard rules` / `flutterguard explain` subcommands with RuleMeta registry -- `device_lifecycle`, `mqtt_connection`, `ble_scanning`, `iot_security`, `pubspec_security` -- 57 tests across reusable scanner/rule coverage and process-level CLI behavior -- RuleMeta class + RuleRegistry for rule introspection - -### M4 — CI Adoption - -- Single-line / next-line suppression comments for false positive control -- Baseline creation and `scan --baseline` filtering for legacy projects -- SARIF output format for GitHub Code Scanning -- GitHub Actions CI examples for JSON gates and SARIF upload -- Rule accuracy regression tests around suppression, baseline, SARIF, and IoT rules - -### M5 — Enterprise + DX - -- GitHub Actions annotations mode -- Team configuration sharing -- Pre-commit hook integration -- IDE plugin (VS Code / IntelliJ) for inline results - ---- - -## 10. Known Limitations (M4 Current) - -1. **Lifecycle resource detection**: Uses string contains in dispose body, not full AST visitor. Does not detect: - - Disposal via helper method calls - - Disposal via `disposeAll()` style patterns - - Resources created via factory methods -2. **Import resolution**: Package imports are resolved by stripping the package: prefix and matching against the scanned file set. Imports from external packages are not resolved. -3. **Cyclic detection**: Reports cycles at file granularity, not class/module granularity. Same logical cycle may be reported from different DFS entry points. -4. **Architecture rules**: Layer/module rules require the user to explicitly declare all layers/modules in flutterguard.yaml. No auto-detection. -5. **Multi-isolate**: Not supported (single-isolate only) -6. **State graph resolution**: State dependency edges are syntactic and project-local; runtime service locators and generated provider code are not resolved. -7. **Incremental scan**: `--changed-only` skips circular_dependency entirely - in changed mode. Layer/module violations are detected only when the changed - file is the source of the illegal import; unchanged target files remain - available for import resolution. Project-level pubspec security is checked - in full scans and when `pubspec.yaml` changes in incremental scans. - `state_dependency_cycle` is the exception: it always builds the full graph - and reports only cycles that touch a changed source file. -8. **Suppression**: Only current-line and next-line comments are supported. - There is no file-wide disable/enable block. -9. **Baseline**: Fingerprints intentionally include line and message, so moving - code or changing rule wording can surface old issues again. - ---- - -## 11. Commands Reference - -```bash -# Bootstrap (development) -cd flutterguard -dart pub get -melos bootstrap - -# Analyze -dart run melos run analyze - -# Test -dart run melos run test:cli - -# Compile CLI -dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard - -# Scan a project -./flutterguard scan --path /path/to/flutter_project - -# Run IoT scan demo -./flutterguard scan --path examples/scan_demo - -# Create a baseline and scan only new issues -./flutterguard baseline create . --output .flutterguard/baseline.json -./flutterguard scan . --baseline .flutterguard/baseline.json - -# Generate SARIF for GitHub Code Scanning -./flutterguard scan . --format sarif --baseline .flutterguard/baseline.json -``` - ---- - -## 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 -- `framework` — generic / riverpod / bloc / provider -- `confidence` — certain / probable / informational - -### 13.2 RuleRegistry -Singleton in `lib/src/rules/registry.dart`: -- `all()` → `List` (23 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. Resolve the containing repository with `git rev-parse --show-toplevel` -2. Verify `^{commit}` and reject option-like or invalid refs -3. `git diff --name-only --diff-filter=ACMR -z --` -4. `git ls-files --others --exclude-standard -z` (untracked files) -5. Union both NUL-delimited sets, anchor paths to the target project, and filter to `.dart` files -6. Feed filtered files to all rules -7. CircularDependencyRule is disabled only for a valid changed-mode scan - -### Behavior Matrix -| Condition | Behavior | scanMode | -|-----------|----------|----------| -| Non-git dir | Fallback to full scan | full | -| --changed-only, 0 changes | Empty successful report | changed | -| --changed-only, changes > 0 | Only scan changed .dart files | changed | -| Invalid `--base` | Scan setup error (exit 2) | — | -| --base not specified | Defaults to 'main' | — | diff --git a/examples/scan_demo/README.md b/examples/scan_demo/README.md deleted file mode 100644 index d386cec..0000000 --- a/examples/scan_demo/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Scan Demo - -A deliberately imperfect project to demonstrate `flutterguard scan`. - -```bash -# From the repo root, run scan with low thresholds to trigger issues -./flutterguard scan -p examples/scan_demo - -# With verbose output -./flutterguard scan -p examples/scan_demo --verbose - -# JSON output -./flutterguard scan -p examples/scan_demo --format json -``` - -The `flutterguard.yaml` in this directory uses low thresholds (maxLines: 80/30/20) to demonstrate issue detection even in small projects. diff --git a/examples/scan_demo/flutterguard.yaml b/examples/scan_demo/flutterguard.yaml deleted file mode 100644 index 0242a36..0000000 --- a/examples/scan_demo/flutterguard.yaml +++ /dev/null @@ -1,13 +0,0 @@ -include: - - lib/** - -rules: - large_file: - enabled: true - maxLines: 80 - large_class: - enabled: true - maxLines: 30 - large_build_method: - enabled: true - maxLines: 20 diff --git a/examples/scan_demo/lib/main.dart b/examples/scan_demo/lib/main.dart deleted file mode 100644 index a4ceba3..0000000 --- a/examples/scan_demo/lib/main.dart +++ /dev/null @@ -1,14 +0,0 @@ -class UserManager { - final List _users = []; - - void addUser(String name) { - _users.add(name); - } - - String? findUser(String name) { - for (final user in _users) { - if (user == name) return user; - } - return null; - } -} diff --git a/examples/scan_demo/lib/services/user_service.dart b/examples/scan_demo/lib/services/user_service.dart deleted file mode 100644 index 9149f18..0000000 --- a/examples/scan_demo/lib/services/user_service.dart +++ /dev/null @@ -1,51 +0,0 @@ -import '../widgets/profile_card.dart'; - -class UserService { - final List _cache = []; - - Future fetchUser(String id) async { - await Future.delayed(const Duration(milliseconds: 100)); - return _cache.firstWhere( - (u) => u.displayName.contains(id), - orElse: () => _cache.first, - ); - } - - Future syncUsers() async { - for (var i = 0; i < 10; i++) { - await Future.delayed(const Duration(milliseconds: 50)); - _cache.add(_createMockUser(i)); - } - } - - ProfileCard _createMockUser(int index) { - return ProfileCard( - name: 'User $index', - email: 'user$index@example.com', - age: 20 + index, - bio: 'Bio for user $index', - avatarUrl: 'https://example.com/avatars/$index.png', - location: 'Location $index', - website: 'https://user$index.example.com', - company: 'Company $index', - role: 'Role $index', - department: 'Department $index', - phone: '555-${index.toString().padLeft(4, '0')}', - address: '${100 + index} Main St', - city: 'City $index', - country: 'Country $index', - postalCode: '${10000 + index}', - timezone: 'UTC${index > 5 ? "+$index" : "-$index"}', - language: 'en', - theme: 'light', - notificationsEnabled: true, - emailVerified: index > 0, - twoFactorEnabled: index > 5, - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - lastLoginAt: DateTime.now(), - tags: ['tag$index'], - metadata: {'index': index, 'source': 'mock'}, - ); - } -} diff --git a/examples/scan_demo/lib/widgets/profile_card.dart b/examples/scan_demo/lib/widgets/profile_card.dart deleted file mode 100644 index 9fa1655..0000000 --- a/examples/scan_demo/lib/widgets/profile_card.dart +++ /dev/null @@ -1,62 +0,0 @@ -class ProfileCard { - String name; - String email; - int age; - String bio; - String avatarUrl; - String location; - String website; - String company; - String role; - String department; - String phone; - String address; - String city; - String country; - String postalCode; - String timezone; - String language; - String theme; - bool notificationsEnabled; - bool emailVerified; - bool twoFactorEnabled; - DateTime createdAt; - DateTime updatedAt; - DateTime lastLoginAt; - List tags; - Map metadata; - - ProfileCard({ - required this.name, - required this.email, - required this.age, - required this.bio, - required this.avatarUrl, - required this.location, - required this.website, - required this.company, - required this.role, - required this.department, - required this.phone, - required this.address, - required this.city, - required this.country, - required this.postalCode, - required this.timezone, - required this.language, - required this.theme, - required this.notificationsEnabled, - required this.emailVerified, - required this.twoFactorEnabled, - required this.createdAt, - required this.updatedAt, - required this.lastLoginAt, - required this.tags, - required this.metadata, - }); - - String get displayName => '$name ($role, $department)'; - - bool get isActive => - lastLoginAt.isAfter(DateTime.now().subtract(const Duration(days: 30))); -} diff --git a/examples/scan_demo/pubspec.yaml b/examples/scan_demo/pubspec.yaml deleted file mode 100644 index 7fdf4c1..0000000 --- a/examples/scan_demo/pubspec.yaml +++ /dev/null @@ -1,6 +0,0 @@ -name: scan_demo -description: Target project for flutterguard scan demonstration. -publish_to: none - -environment: - sdk: ">=3.3.0 <4.0.0" diff --git a/melos.yaml b/melos.yaml deleted file mode 100644 index 3e3ef07..0000000 --- a/melos.yaml +++ /dev/null @@ -1,21 +0,0 @@ -name: flutterguard - -packages: - - packages/flutterguard_cli - - examples/** - -command: - bootstrap: - usePubspecOverrides: true - version: - branch: main - -scripts: - analyze: - run: dart run melos exec -c 1 -- "dart analyze ." - format: - run: dart run melos exec -- "dart format ." - test: - run: dart run melos exec --scope="flutterguard_cli" -- dart test - test:cli: - run: dart run melos exec --scope="flutterguard_cli" -- dart test diff --git a/packages/flutterguard_cli/AGENTS.md b/packages/flutterguard_cli/AGENTS.md deleted file mode 100644 index 60591d5..0000000 --- a/packages/flutterguard_cli/AGENTS.md +++ /dev/null @@ -1,72 +0,0 @@ -# Package: flutterguard_cli [ACTIVE] - -## Role -Primary CLI tool for IoT Flutter static architecture scanning and CI gating. - -## Dependency Map -- depends on: args, analyzer ^7.3.0, glob, path, yaml -- depended by: nothing - -## Entry Points -- bin: `bin/flutterguard.dart` — thin CLI router, help, and exit codes -- lib barrel: `lib/flutterguard_cli.dart` - -## Key Source Files -| File | Responsibility | -|------|---------------| -| `bin/flutterguard.dart` | Top-level routing, positional path, help, exit codes | -| `src/cli/` | Parser tree and functional command handlers | -| `src/scan_context.dart` | Project/all/target file scope and scan mode | -| `src/source_workspace.dart` | Shared source/AST cache and diagnostics | -| `src/import_graph.dart` | Shared import graph for architecture rules | -| `src/rules/catalog.dart` | Explicit metadata and execution wiring | -| `src/config_loader.dart` | YAML → ScanConfig typed parsing (20 rule configs + state management + architecture) | -| `src/file_collector.dart` | Glob-based .dart file discovery | -| `src/project_resolver.dart` | Project auto-discovery (walk-up flutterguard.yaml / pubspec.yaml / lib/) | -| `src/import_utils.dart` | Shared import resolution utility | -| `src/path_utils.dart` | Cross-platform path/glob helpers (p.Context abstraction) | -| `src/source_utils.dart` | Analyzer offset → line number conversion | -| `src/static_issue.dart` | StaticIssue model + RiskLevel enum | -| `src/report_generator.dart` | JSON + Table report generation + score + --no-color | -| `src/rules/large_units.dart` | 3 sub-rules: file size, class size, build method size | -| `src/rules/lifecycle_resource.dart` | Undisposed controllers/streams/MQTT/BLE detection | -| `src/rules/layer_violation.dart` | Cross-layer import violation detection | -| `src/rules/module_violation.dart` | Cross-module import violation detection | -| `src/rules/circular_dependency.dart` | File-level cycle detection | -| `src/rules/missing_const_constructor.dart` | Widgets missing const constructor | -| `src/rules/iot_security.dart` | Hardcoded secrets, cleartext MQTT/HTTP, insecure BLE | -| `src/rules/device_lifecycle.dart` | Device init/teardown pair checks | -| `src/rules/mqtt_connection.dart` | MQTT connect/disconnect, subscribe/unsubscribe, broker URLs | -| `src/rules/ble_scanning.dart` | BLE startScan/stopScan, connect/disconnect, scan timeout | -| `src/rules/pubspec_security.dart` | Unbounded deps, deprecated packages, outdated IoT dependencies | - -## Wired Rules (21 rule classes, 23 rule IDs) -Standards: LargeUnitsRule (3 IDs), MissingConstConstructorRule, PubspecSecurityRule -Performance: LifecycleResourceRule -Architecture: LayerViolationRule, ModuleViolationRule, CircularDependencyRule -IoT: DeviceLifecycleRule, MqttConnectionRule, BleScanningRule, IotSecurityRule -State management: 5 generic, 2 Riverpod, 1 Bloc, and 2 Provider rules - -## Test -- command: `melos run test:cli` -- test files: `test/scanner_test.dart` (78 tests) and `test/cli_test.dart` (5 process-level tests) -- fixtures: `test/fixtures/` (21 functional fixture files) -- every new rule needs: spec entry → config typedef → class → fixture → test → wire in rules/catalog.dart - -## Current Toolchain Flow -1. `bin/flutterguard.dart` routes CLI commands (supports positional ``) and maps validation errors to exit codes; `lib/src/cli/` owns parser and command-family behavior. -2. `lib/src/project_resolver.dart` auto-discovers project root by walking up for flutterguard.yaml / pubspec.yaml / lib/. -3. `lib/src/scanner.dart` owns scan orchestration: config loading, file collection, rule execution, issue sorting, and optional JSON writing. -4. `lib/src/config_loader.dart` parses `flutterguard.yaml` into typed record configs. -5. `lib/src/file_collector.dart` resolves include/exclude globs to Dart files. -6. `lib/src/rules/catalog.dart` explicitly registers metadata and execution. Do not add reflection or dynamic plugin loading. -7. `SourceWorkspace` and `ImportGraph` provide per-scan shared analysis data. -8. `lib/src/report_generator.dart` renders table output (with optional `--no-color`) and JSON report payloads. - -## Change Boundaries -- Put top-level CLI routing/help in `bin/`; put functional command parsing and behavior in `lib/src/cli/`. -- Put reusable scan behavior in `lib/src/scanner.dart`, not in `bin/`. -- Put rule-specific detection in `lib/src/rules/`. -- Put shared path/import/source helpers in `lib/src/*_utils.dart`. -- Put project resolution logic in `lib/src/project_resolver.dart`. -- Add or update tests in `test/scanner_test.dart` for every behavior change. diff --git a/packages/flutterguard_cli/CHANGELOG.md b/packages/flutterguard_cli/CHANGELOG.md deleted file mode 100644 index 74726b3..0000000 --- a/packages/flutterguard_cli/CHANGELOG.md +++ /dev/null @@ -1,122 +0,0 @@ -# Changelog - -## 0.6.0 (2026-07-16) - -- Added 10 AST-first state-management maintainability rules, bringing the registry to 23 rule IDs. -- Added shared state configuration, severity/priority overrides, framework auto-detection, allowlists, ignore paths, confidence, and evidence. -- Extended table, JSON, rules/explain, and SARIF output without changing baseline fingerprints or existing report fields. -- State dependency cycles now use file-qualified nodes and import-aware resolution, preventing false cycles when projects contain duplicate type names. -- Aligned the published package SDK constraint and install examples with the Dart 3.11.5 release workflow. -- Added generic, Riverpod, Bloc, and Provider fixtures; the CLI suite now contains 83 tests, including version synchronization, per-rule enablement, suppression, baseline, duplicate-name, and changed-only graph coverage. - -## 0.5.0 (2026-07-12) - -### Architecture Refactor - -- **cli:** Introduced shared scan analysis infrastructure: `ScanContext`, `SourceWorkspace`, `ImportGraph`, and `DependencyBoundaryEngine`. -- **cli:** Added `rules/catalog.dart` as the single source of truth for rule metadata and execution, routing all rules through the shared workspace. -- **cli:** Split command handlers into `lib/src/cli/`, reducing `bin/flutterguard.dart` to routing, help, and exit codes. -- **ci:** Aligned the release workflow Dart SDK with the workspace `^3.11.5` constraint. -- **fix:** Reject full scans whose include/exclude policy matches no Dart files instead of returning a successful empty report. -- **fix:** Resolve relative configs from the target project and reject missing explicitly selected config files. -- **fix:** Treat clean changed-only scans as successful empty incremental reports and reject invalid Git base refs. -- **ci:** Validate the current checkout on all supported CI operating systems instead of scanning with a pub.dev installation. - -## 0.4.1 (2026-07-09) - -### Adoption Hardening - -- **cli:** Added `flutterguard doctor install` to diagnose executable version, Dart entrypoint, and PATH conflicts. -- **cli:** Added `flutterguard issue export` for local, user-reviewed false-positive feedback bundles. -- **cli:** Added `flutterguard baseline stats`, `flutterguard baseline prune`, and `flutterguard baseline check --no-growth`. -- **cli:** Added `flutterguard init --profile` starter profiles and positional `init []` support. -- **test:** Added coverage for install diagnostics, issue export, baseline management, and config profiles. - -## 0.4.0 (2026-07-08) - -### CI Adoption - -- **cli:** Added `flutterguard baseline create []` to snapshot existing issues into `.flutterguard/baseline.json`. -- **cli:** Added `scan --baseline ` to hide baseline-matched issues from stdout, JSON, SARIF, scoring, and CI gates. -- **cli:** Added source suppression comments: `// flutterguard: ignore `, comma-separated IDs, and `ignore all`. -- **cli:** Added `--format sarif` with SARIF 2.1.0 output at `.flutterguard/report.sarif` for GitHub Code Scanning. -- **cli:** JSON summary now reports `suppressed` and `suppressedByBaseline` counts. -- **docs:** Updated README command references, CI onboarding order, suppression examples, baseline usage, and SARIF upload workflow. -- **release:** Documented source-checkout launcher usage to avoid stale global binaries. - -### Tests - -- **test:** Added coverage for suppression matching, next-line `ignore all`, baseline filtering, missing baseline errors, JSON counters, and SARIF structure. -- **test:** CLI test suite now covers 43 tests. - -## 0.3.0 (2026-06-28) - -### Incremental Scan (--changed-only) - -- **cli:** New `--changed-only` flag — only scans `.dart` files changed since `--base` (default: `main`) -- **cli:** Integrated `git diff --name-only` + `git ls-files --others` for change detection -- **cli:** Non-git fallback: gracefully degrades to full scan -- **cli:** `circular_dependency` auto-disabled in changed-only mode -- **cli:** JSON report now includes `scanMode: full|changed` field - -### Rule Introspection (rules / explain) - -- **cli:** New `flutterguard rules` subcommand — list all 13 rules with ID, domain, name -- **cli:** New `flutterguard explain ` subcommand — detailed purpose, risk, example, fix, config -- **cli:** New `RuleMeta` class in `lib/src/rule_meta.dart` — structured rule metadata -- **cli:** New `RuleRegistry` singleton in `lib/src/rules/registry.dart` — registry with `all()` and `find()` -- **cli:** Output supports both `table` (default) and `--format json` - -### Tests - -- **test:** 37 total tests (26 base + 6 new + 5 existing) -- **test:** 3 `changed-only` tests: git repo filter, non-git fallback, skip cycle -- **test:** 3 registry tests: all 13 rules, find by ID, unknown returns null - -### Infrastructure - -- **meta:** Version bumped to 0.3.0 -- **meta:** `AGENTS.md` and `FLUTTERGUARD_SPEC.md` updated for new features - -## 0.1.1 (2026-06-09) - -### pub.dev Publishing - -- **cli:** Published to pub.dev — `dart pub global activate flutterguard_cli` -- **cli:** Added pubspec.yaml metadata (repository, issue_tracker, topics) -- **cli:** Added package-level README, LICENSE, CHANGELOG -- **cli:** Removed Flutter imports from test fixtures for pure Dart compatibility - -### Cross-Platform Documentation - -- **docs:** `USAGE.md` — comprehensive usage guide (macOS / Windows / Linux) -- **docs:** `WINDOWS_ASSESSMENT.md` — full Windows compatibility audit -- **docs:** Enhanced README.md and README.zh.md with platform-specific install/usage/troubleshooting sections - -### Fixes - -- **cli:** Fixed pub.dev topic count limit (5 max) -- **cli:** Fixed test fixture Flutter dependency warnings - -## 0.1.0 (2026-05-17) - -### Initial Release — CLI Static Analysis - -- **cli:** 6 static analysis rules: large_file, large_class, large_build_method, lifecycle_resource_not_disposed (IoT-aware), layer_violation, module_violation, circular_dependency, missing_const_constructor -- **cli:** YAML-driven config with include/exclude patterns, rule thresholds, and architecture layers/modules -- **cli:** Table (terminal) and JSON output formats with domain-grouped reporting -- **cli:** CI gate integration with --fail-on threshold and --min-score support -- **cli:** Architecture layer/module enforcement with configurable enabled/disabled -- **cli:** Config key validation (warns on unknown YAML keys) -- **cli:** --version and comprehensive --help output -- **cli:** Native binary compilation (dart compile exe) -- **docs:** FLUTTERGUARD_SPEC.md with full rule contracts, config schema, and output spec -- **docs:** ARCHITECTURE.md, AGENTS.md, PROJECT_RULES.md with dependency graph and override chains -- **meta:** melos monorepo setup with 4 packages + 2 examples -- **meta:** MIT license - -### Known Limitations - -- IoT-specific rules (device_lifecycle, mqtt_connection, ble_scanning, iot_security, pubspec_security) defined in spec but not yet implemented -- Lifecycle resource detection uses string pattern matching (not type-resolution) -- runtime tracing packages (core/dio/flutter) are frozen — Path A (static analysis) is primary diff --git a/packages/flutterguard_cli/LICENSE b/packages/flutterguard_cli/LICENSE deleted file mode 100644 index 127a57b..0000000 --- a/packages/flutterguard_cli/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 FlutterGuard - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/flutterguard_cli/README.md b/packages/flutterguard_cli/README.md deleted file mode 100644 index 320c340..0000000 --- a/packages/flutterguard_cli/README.md +++ /dev/null @@ -1,213 +0,0 @@ -[![pub package](https://img.shields.io/pub/v/flutterguard_cli.svg)](https://pub.dev/packages/flutterguard_cli) -[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT) - -# flutterguard_cli - -IoT Flutter project static analysis CLI for architecture enforcement, code quality, and CI gating. - -**Platforms**: macOS / Windows / Linux — pure Dart, zero native dependencies. - -## Quick Start - -```bash -# Install globally -dart pub global activate flutterguard_cli -flutterguard --version - -# Scan a Flutter project -flutterguard scan -p /path/to/flutter_project - -# Create and validate a starter config -flutterguard init --profile migration -flutterguard config doctor -flutterguard doctor install - -# JSON output with CI gate -flutterguard scan -p . --format json --fail-on high --min-score 80 - -# Baseline existing issues before enforcing CI -flutterguard baseline create . -flutterguard baseline stats -flutterguard baseline check . --baseline .flutterguard/baseline.json --no-growth -flutterguard scan . --baseline .flutterguard/baseline.json --fail-on high - -# GitHub Code Scanning output -flutterguard scan . --format sarif --baseline .flutterguard/baseline.json - -# Export one finding for feedback -flutterguard issue export --rule mqtt_connection --file lib/device/mqtt.dart --line 42 -``` - -When working from a source checkout, prefer the local launcher so you always -run the current files instead of an older global executable: - -```bash -./scripts/flutterguard-dev --version -./scripts/flutterguard-dev scan . -``` - -## Checks - -| Rule | Level | What it checks | -|------|-------|----------------| -| `large_file` | LOW | File line count | -| `large_class` | LOW | Class body line count | -| `large_build_method` | MEDIUM | `build()` method size | -| `lifecycle_resource_not_disposed` | MEDIUM | Undisposed `StreamSubscription`, `Timer`, `AnimationController`, `TextEditingController`, `ScrollController`, `FocusNode`, `MqttClient` (IoT), `BluetoothDevice` (IoT), `StreamController` | -| `layer_violation` | HIGH | Cross-layer architecture import violations | -| `module_violation` | HIGH | Cross-module architecture import violations | -| `circular_dependency` | MEDIUM | File-level import cycles | -| `missing_const_constructor` | LOW | Widget classes missing `const` constructor | -| `device_lifecycle` | HIGH | Unbalanced device init/teardown pairs | -| `mqtt_connection` | HIGH | MQTT connect/disconnect and broker URL checks | -| `iot_security` | HIGH | Hardcoded secrets, cleartext MQTT/HTTP, insecure BLE patterns | -| `ble_scanning` | MEDIUM | BLE scan/stop and timeout checks | -| `pubspec_security` | MEDIUM | Unbounded, deprecated, or outdated dependency checks | -| `side_effect_in_build` | HIGH | Side effects during Widget/Consumer build | -| `state_manager_created_in_build` | HIGH | State managers/controllers created during build | -| `mutable_state_exposed` | MEDIUM | Public mutable state and in-place state mutation | -| `state_layer_ui_dependency` | HIGH | State owners coupled to Flutter UI APIs | -| `state_dependency_cycle` | HIGH | Provider/state/service dependency cycles | -| `riverpod_read_used_for_render` | MEDIUM | Riverpod `ref.read` used for rendering | -| `riverpod_watch_in_callback` | MEDIUM | Riverpod `ref.watch` in callbacks | -| `bloc_equatable_props_incomplete` | MEDIUM | Equatable fields missing from `props` | -| `provider_value_lifecycle_misuse` | MEDIUM | Provider `.value`/`create` ownership misuse | -| `notify_listeners_in_loop` | MEDIUM | Repeated `notifyListeners()` in loops | - -## Configuration - -Create `flutterguard.yaml` in your project root: - -Start without config, then run `flutterguard init` only when you need custom -thresholds, excludes, CI gates, or explicit architecture boundaries. Use -`flutterguard config print` to inspect merged defaults and -`flutterguard config doctor` before enabling CI gates. - -```yaml -rules: - large_file: - enabled: true - maxLines: 500 - lifecycle_resource: - enabled: true - side_effect_in_build: - enabled: true - severity: high - allowlist: [] - ignore_paths: [] - -state_management: - enabled: true - framework_auto_detect: true - confidence_threshold: certain - -architecture: - layers: - - name: presentation - path: lib/presentation/** - allowed_deps: [domain, core] - - name: domain - path: lib/domain/** - allowed_deps: [core] - modules: - - name: device_mqtt - path: lib/device/mqtt/** - allowed_deps: [domain, core] - detect_cycles: true -``` - -If the default config is omitted, built-in defaults are used (all default rules enabled, no architecture constraints). Any path explicitly passed with `--config` must exist and relative paths resolve from the target project root. - -## CLI Reference - -``` -flutterguard scan [] [options] - -p, --path Project path (default: .) - -c, --config Config file (default: flutterguard.yaml) - -f, --format table | json | sarif (default: table) - -o, --output Output directory (default: .flutterguard) - -v, --verbose Show detailed context - --changed-only Only scan changed Dart files - --base Git base ref for changed-only mode - --baseline Baseline JSON file for existing issues - --fail-on CI gate: none | high | medium | low - --min-score Minimum score threshold 0-100 - -flutterguard baseline create [] [--output .flutterguard/baseline.json] -flutterguard baseline stats -flutterguard baseline prune [] [--dry-run] -flutterguard baseline check [] --no-growth -flutterguard doctor install -flutterguard init [--with-architecture] [--force] -flutterguard init [] [--profile recommended|migration|strict|iot-security|architecture-only|performance-only] -flutterguard config print -flutterguard config doctor -flutterguard issue export [--rule ] [--file ] [--line ] -flutterguard rules [--format table|json] -flutterguard explain -``` - -Exit codes: `0` success, `1` CI gate failed, `2` scan setup error. A full scan that matches no configured Dart files exits with `2`; a changed-only scan with no relevant changes exits with `0`. - -## Scoring - -``` -score = max(0, 100 - HIGH*10 - MEDIUM*4 - LOW*1) -``` - -| Score | Rating | -|-------|--------| -| 80-100 | Excellent | -| 50-79 | Needs review | -| 0-49 | Needs action | - -## CI Integration - -Recommended adoption order: - -```bash -flutterguard config doctor -flutterguard baseline create . -flutterguard scan . --baseline .flutterguard/baseline.json --format json --fail-on high -``` - -```yaml -# GitHub Actions -- uses: dart-lang/setup-dart@v1 - with: - sdk: 3.11.5 -- run: dart pub global activate flutterguard_cli -- run: flutterguard scan . --format json --baseline .flutterguard/baseline.json --fail-on high --min-score 80 -``` - -For GitHub Code Scanning: - -```yaml -- run: flutterguard scan . --format sarif --baseline .flutterguard/baseline.json -- uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: .flutterguard/report.sarif -``` - -Known false positives can be suppressed on the same line or the next line: - -```dart -// flutterguard: ignore missing_const_constructor -// flutterguard: ignore iot_security, mqtt_connection -// flutterguard: ignore all -``` - -## Requirements - -- Dart SDK >=3.11.5 -- Supported OS: macOS, Windows, Linux - -## Further Reading - -- [Full Usage Guide](https://github.com/lizy-coding/flutterguard/blob/develop/docs/USAGE.md) -- [Windows Compatibility](https://github.com/lizy-coding/flutterguard/blob/develop/docs/WINDOWS_ASSESSMENT.md) -- [Specification](https://github.com/lizy-coding/flutterguard/blob/develop/docs/FLUTTERGUARD_SPEC.md) - -## License - -MIT diff --git a/packages/flutterguard_cli/analysis_options.yaml b/packages/flutterguard_cli/analysis_options.yaml deleted file mode 100644 index d27b937..0000000 --- a/packages/flutterguard_cli/analysis_options.yaml +++ /dev/null @@ -1,5 +0,0 @@ -include: package:lints/recommended.yaml - -analyzer: - exclude: - - test/fixtures/** diff --git a/packages/flutterguard_cli/bin/AGENTS.md b/packages/flutterguard_cli/bin/AGENTS.md deleted file mode 100644 index 3f76672..0000000 --- a/packages/flutterguard_cli/bin/AGENTS.md +++ /dev/null @@ -1,27 +0,0 @@ -# CLI Entry Layer - -## Responsibility -`flutterguard.dart` is only the top-level command router. - -It should: -- Use the parser tree from `lib/src/cli/cli_parsers.dart`. -- Support positional path: `flutterguard scan ./my_project` (no `-p` required). -- Print help/version text. -- Convert validation or scan errors into documented exit codes. -- Delegate functional command behavior to `lib/src/cli/`. - -It should not: -- Implement rules. -- Read Dart files directly. -- Generate reports beyond calling `ReportGenerator`. -- Duplicate scan orchestration already in `lib/src/scanner.dart`. - -## Exit Codes -- `0`: success or help/version output -- `1`: CI gate failed -- `2`: bad input, bad config, or scan setup error - -## Required Checks -After changing this layer, run: -- `dart run melos run analyze` -- `dart run melos run test:cli` diff --git a/packages/flutterguard_cli/bin/flutterguard.dart b/packages/flutterguard_cli/bin/flutterguard.dart deleted file mode 100644 index 164d812..0000000 --- a/packages/flutterguard_cli/bin/flutterguard.dart +++ /dev/null @@ -1,523 +0,0 @@ -import 'dart:io'; - -import 'package:args/args.dart'; - -import 'package:flutterguard_cli/src/cli/scan_command.dart'; -import 'package:flutterguard_cli/src/cli/baseline_commands.dart'; -import 'package:flutterguard_cli/src/cli/cli_parsers.dart'; -import 'package:flutterguard_cli/src/cli/config_commands.dart'; -import 'package:flutterguard_cli/src/cli/issue_commands.dart'; -import 'package:flutterguard_cli/src/cli/rule_commands.dart'; - -const _version = '0.6.0'; - -void main(List args) { - final normalizedArgs = _extractPositionalPath(args); - - final parsers = CliParsers(); - final scanParser = parsers.scan; - final baselineCreateParser = parsers.baselineCreate; - final baselineStatsParser = parsers.baselineStats; - final baselinePruneParser = parsers.baselinePrune; - final baselineCheckParser = parsers.baselineCheck; - final baselineParser = parsers.baseline; - final initParser = parsers.init; - final installDoctorParser = parsers.installDoctor; - final doctorParser = parsers.doctor; - final issueExportParser = parsers.issueExport; - final issueParser = parsers.issue; - final configPrintParser = parsers.configPrint; - final configDoctorParser = parsers.configDoctor; - final configParser = parsers.config; - final rulesParser = parsers.rules; - final explainParser = parsers.explain; - final parser = parsers.root; - - try { - final results = parser.parse(normalizedArgs); - - if (results['version'] == true) { - stdout.writeln('flutterguard $_version'); - exit(0); - } - - if (results['help'] == true || normalizedArgs.isEmpty) { - _printUsage(parser); - exit(0); - } - - final command = results.command; - if (command == null) { - _printUsage(parser); - exit(0); - } - - if (command.name == 'scan') { - if (command['help'] == true) { - _printScanUsage(scanParser); - exit(0); - } - _handleScan(command); - } else if (command.name == 'baseline') { - _handleBaseline( - command, - baselineParser, - baselineCreateParser, - baselineStatsParser, - baselinePruneParser, - baselineCheckParser, - ); - } else if (command.name == 'doctor') { - _handleDoctor(command, doctorParser, installDoctorParser); - } else if (command.name == 'init') { - if (command['help'] == true) { - _printInitUsage(initParser); - exit(0); - } - _handleInit(command); - } else if (command.name == 'config') { - _handleConfig( - command, configParser, configPrintParser, configDoctorParser); - } else if (command.name == 'issue') { - _handleIssue(command, issueParser, issueExportParser); - } else if (command.name == 'rules') { - if (command['help'] == true) { - _printRulesUsage(rulesParser); - exit(0); - } - _handleRules(command); - } else if (command.name == 'explain') { - if (command['help'] == true) { - _printExplainUsage(explainParser); - exit(0); - } - _handleExplain(command); - } else { - _printUsage(parser); - exit(0); - } - } on FormatException catch (e) { - stderr.writeln('Error: ${e.message}'); - stderr.writeln(); - _printUsage(parser); - exit(2); - } -} - -void _handleInit(ArgResults args) { - ConfigCommands.init(args); -} - -void _handleConfig( - ArgResults command, - ArgParser configParser, - ArgParser configPrintParser, - ArgParser configDoctorParser, -) { - if (command['help'] == true || command.command == null) { - _printConfigUsage(configParser); - exit(0); - } - - final subcommand = command.command!; - if (subcommand.name == 'print') { - if (subcommand['help'] == true) { - _printConfigPrintUsage(configPrintParser); - exit(0); - } - _handleConfigPrint(subcommand); - return; - } - if (subcommand.name == 'doctor') { - if (subcommand['help'] == true) { - _printConfigDoctorUsage(configDoctorParser); - exit(0); - } - _handleConfigDoctor(subcommand); - return; - } - - _printConfigUsage(configParser); - exit(0); -} - -void _handleConfigPrint(ArgResults args) { - ConfigCommands.printEffective( - args, - configPath: _explicitConfigPath(args), - ); -} - -void _handleConfigDoctor(ArgResults args) { - ConfigCommands.doctor(args, configPath: _explicitConfigPath(args)); -} - -List _extractPositionalPath(List args) { - if (args.isEmpty) return args; - - final scanIndex = args.indexOf('scan'); - if (scanIndex == -1) return args; - - final positionalStart = scanIndex + 1; - if (positionalStart >= args.length) return args; - - final candidate = args[positionalStart]; - if (candidate.startsWith('-')) return args; - - final positionalParts = [candidate]; - var i = positionalStart + 1; - while (i < args.length && !args[i].startsWith('-')) { - positionalParts.add(args[i]); - i++; - } - - final before = args.sublist(0, positionalStart); - final after = args.sublist(positionalStart + positionalParts.length); - final result = [...before, '-p', ...positionalParts, ...after]; - return result; -} - -void _handleScan(ArgResults args) { - ScanCommand.run(args, configPath: _explicitConfigPath(args)); -} - -void _handleBaseline( - ArgResults command, - ArgParser baselineParser, - ArgParser baselineCreateParser, - ArgParser baselineStatsParser, - ArgParser baselinePruneParser, - ArgParser baselineCheckParser, -) { - if (command['help'] == true || command.command == null) { - _printBaselineUsage(baselineParser); - exit(0); - } - - final subcommand = command.command!; - if (subcommand.name == 'create') { - if (subcommand['help'] == true) { - _printBaselineCreateUsage(baselineCreateParser); - exit(0); - } - _handleBaselineCreate(subcommand); - return; - } - if (subcommand.name == 'stats') { - if (subcommand['help'] == true) { - _printBaselineStatsUsage(baselineStatsParser); - exit(0); - } - _handleBaselineStats(subcommand); - return; - } - if (subcommand.name == 'prune') { - if (subcommand['help'] == true) { - _printBaselinePruneUsage(baselinePruneParser); - exit(0); - } - _handleBaselinePrune(subcommand); - return; - } - if (subcommand.name == 'check') { - if (subcommand['help'] == true) { - _printBaselineCheckUsage(baselineCheckParser); - exit(0); - } - _handleBaselineCheck(subcommand); - return; - } - - _printBaselineUsage(baselineParser); - exit(0); -} - -void _handleBaselineCreate(ArgResults args) { - BaselineCommands.create(args, configPath: _explicitConfigPath(args)); -} - -void _handleBaselineStats(ArgResults args) { - BaselineCommands.stats(args); -} - -void _handleBaselinePrune(ArgResults args) { - BaselineCommands.prune(args, configPath: _explicitConfigPath(args)); -} - -void _handleBaselineCheck(ArgResults args) { - BaselineCommands.check(args, configPath: _explicitConfigPath(args)); -} - -void _handleDoctor( - ArgResults command, - ArgParser doctorParser, - ArgParser installDoctorParser, -) { - if (command['help'] == true || command.command == null) { - _printDoctorUsage(doctorParser); - exit(0); - } - final subcommand = command.command!; - if (subcommand.name == 'install') { - if (subcommand['help'] == true) { - _printInstallDoctorUsage(installDoctorParser); - exit(0); - } - ConfigCommands.installDoctor(version: _version); - return; - } - _printDoctorUsage(doctorParser); - exit(0); -} - -void _handleIssue( - ArgResults command, - ArgParser issueParser, - ArgParser issueExportParser, -) { - if (command['help'] == true || command.command == null) { - _printIssueUsage(issueParser); - exit(0); - } - final subcommand = command.command!; - if (subcommand.name != 'export') { - _printIssueUsage(issueParser); - exit(0); - } - if (subcommand['help'] == true) { - _printIssueExportUsage(issueExportParser); - exit(0); - } - IssueCommands.export( - subcommand, - configPath: _explicitConfigPath(subcommand), - ); -} - -String? _explicitConfigPath(ArgResults args) { - return args.wasParsed('config') ? args['config'] as String : null; -} - -void _handleRules(ArgResults args) { - RuleCommands.list(args); -} - -void _handleExplain(ArgResults args) { - RuleCommands.explain(args); -} - -void _printUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — IoT Flutter architecture static analysis CLI'); - stdout.writeln( - 'No API key is required. This CLI scans local source code only.'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard [options]'); - stdout.writeln(); - stdout.writeln('Commands:'); - stdout.writeln( - ' scan [] Scan a Flutter project for architecture issues'); - stdout.writeln(' baseline Create a baseline for existing issues'); - stdout.writeln(' doctor Diagnose installation and environment'); - stdout.writeln(' init Create a starter flutterguard.yaml'); - stdout.writeln(' config Print or validate effective configuration'); - stdout.writeln(' issue Export issue feedback bundles'); - stdout.writeln(' rules List available rules'); - stdout.writeln(' explain Explain a rule by ID'); - stdout.writeln(); - stdout.writeln('Global Options:'); - stdout.writeln(' -h, --help Show this help message'); - stdout.writeln(' -V, --version Show version'); - stdout.writeln(); - stdout.writeln('Examples:'); - stdout.writeln( - ' flutterguard scan # Scan current directory'); - stdout - .writeln(' flutterguard init # Create basic config'); - stdout.writeln( - ' flutterguard config doctor # Validate config and globs'); - stdout.writeln( - ' flutterguard scan ./my_flutter_app # Scan specific project'); - stdout.writeln(' flutterguard scan -p /path/to/app # Explicit path flag'); - stdout.writeln(' flutterguard scan . --format json --fail-on high'); - stdout.writeln(' flutterguard baseline create .'); - stdout - .writeln(' flutterguard scan . --baseline .flutterguard/baseline.json'); - stdout.writeln(' flutterguard doctor install'); - stdout.writeln(' flutterguard issue export --rule mqtt_connection'); - stdout.writeln(); - stdout.writeln('Configuration strategy:'); - stdout.writeln( - ' Start with zero config, add flutterguard.yaml only for thresholds, excludes,'); - stdout.writeln(' CI gates, or explicit architecture layers/modules.'); -} - -void _printScanUsage(ArgParser scanParser) { - stdout.writeln('FlutterGuard — scan command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard scan [] [options]'); - stdout.writeln(); - stdout.writeln( - ' Project path to scan (default: current directory)'); - stdout.writeln(); - stdout.writeln(scanParser.usage); - stdout.writeln(); - stdout.writeln('Configuration strategy:'); - stdout.writeln(' 1. Zero config: run scan with built-in defaults.'); - stdout - .writeln(' 2. Basic config: tune include/exclude and rule thresholds.'); - stdout.writeln( - ' 3. Baseline: run baseline create . before gating legacy projects.'); - stdout.writeln( - ' 4. CI config: add --format json --baseline .flutterguard/baseline.json --fail-on high.'); - stdout.writeln( - ' 5. Changed-only: add --changed-only --base main to scan git changes.'); - stdout.writeln( - ' 6. Architecture config: declare layers/modules explicitly before enabling boundary gates.'); - stdout.writeln(); - stdout.writeln('Docs: README.md and CONFIGURATION_STRATEGY.md'); -} - -void _printBaselineUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — baseline command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard baseline [options]'); - stdout.writeln(); - stdout.writeln('Commands:'); - stdout.writeln(' create [] Create a baseline for existing issues'); - stdout.writeln(' stats Show baseline fingerprint counts'); - stdout.writeln(' prune [] Remove fixed issues from a baseline'); - stdout.writeln(' check [] Check current issues against a baseline'); - stdout.writeln(); - stdout.writeln(parser.usage); -} - -void _printBaselineCreateUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — baseline create command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard baseline create [] [options]'); - stdout.writeln(); - stdout.writeln(parser.usage); -} - -void _printBaselineStatsUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — baseline stats command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard baseline stats [options]'); - stdout.writeln(); - stdout.writeln(parser.usage); -} - -void _printBaselinePruneUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — baseline prune command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard baseline prune [] [options]'); - stdout.writeln(); - stdout.writeln(parser.usage); -} - -void _printBaselineCheckUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — baseline check command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard baseline check [] [options]'); - stdout.writeln(); - stdout.writeln(parser.usage); -} - -void _printDoctorUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — doctor command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard doctor [options]'); - stdout.writeln(); - stdout.writeln('Commands:'); - stdout.writeln(' install Diagnose installed executable and PATH'); - stdout.writeln(); - stdout.writeln(parser.usage); -} - -void _printInstallDoctorUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — doctor install command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard doctor install [options]'); - stdout.writeln(); - stdout.writeln(parser.usage); -} - -void _printIssueUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — issue command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard issue [options]'); - stdout.writeln(); - stdout.writeln('Commands:'); - stdout.writeln(' export Export a local feedback bundle for one issue'); - stdout.writeln(); - stdout.writeln(parser.usage); -} - -void _printIssueExportUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — issue export command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard issue export [options]'); - stdout.writeln(); - stdout.writeln(parser.usage); -} - -void _printRulesUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — rules command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard rules [options]'); - stdout.writeln(); - stdout.writeln('List all available rules.'); - stdout.writeln(parser.usage); -} - -void _printExplainUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — explain command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard explain '); - stdout.writeln(); - stdout.writeln('Show detailed explanation for a rule.'); - stdout.writeln(parser.usage); -} - -void _printInitUsage(ArgParser initParser) { - stdout.writeln('FlutterGuard — init command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard init [] [options]'); - stdout.writeln(); - stdout.writeln(initParser.usage); - stdout.writeln(); - stdout.writeln('Examples:'); - stdout.writeln(' flutterguard init'); - stdout.writeln(' flutterguard init --profile migration'); - stdout.writeln(' flutterguard init --with-architecture'); - stdout.writeln(' flutterguard init -p ./my_flutter_app --force'); -} - -void _printConfigUsage(ArgParser configParser) { - stdout.writeln('FlutterGuard — config command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard config [options]'); - stdout.writeln(); - stdout.writeln('Commands:'); - stdout.writeln(' print Show merged effective config'); - stdout.writeln( - ' doctor Validate config, globs, and architecture references'); - stdout.writeln(); - stdout.writeln(configParser.usage); -} - -void _printConfigPrintUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — config print command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard config print [options]'); - stdout.writeln(); - stdout.writeln(parser.usage); -} - -void _printConfigDoctorUsage(ArgParser parser) { - stdout.writeln('FlutterGuard — config doctor command'); - stdout.writeln(); - stdout.writeln('Usage: flutterguard config doctor [options]'); - stdout.writeln(); - stdout.writeln(parser.usage); -} diff --git a/packages/flutterguard_cli/lib/AGENTS.md b/packages/flutterguard_cli/lib/AGENTS.md deleted file mode 100644 index 1bf9942..0000000 --- a/packages/flutterguard_cli/lib/AGENTS.md +++ /dev/null @@ -1,12 +0,0 @@ -# Public Library Layer - -## Responsibility -`lib/flutterguard_cli.dart` is the public barrel for tests and programmatic consumers. - -Export reusable models, config, scanner, report generation, rules, and shared utilities from here when they are stable enough for internal reuse. - -## Rules -- Keep implementation in `lib/src/`. -- Do not put business logic directly in the barrel file. -- Avoid exporting experimental helpers unless tests or downstream usage need them. -- Preserve the static-analysis CLI identity; do not add runtime SDK APIs here. diff --git a/packages/flutterguard_cli/lib/flutterguard_cli.dart b/packages/flutterguard_cli/lib/flutterguard_cli.dart deleted file mode 100644 index b5a14cf..0000000 --- a/packages/flutterguard_cli/lib/flutterguard_cli.dart +++ /dev/null @@ -1,33 +0,0 @@ -export 'src/config_loader.dart'; -export 'src/domain.dart'; -export 'src/file_collector.dart'; -export 'src/import_utils.dart'; -export 'src/path_utils.dart'; -export 'src/priority.dart'; -export 'src/project_resolver.dart'; -export 'src/report_generator.dart'; -export 'src/rule_meta.dart'; -export 'src/scan_context.dart'; -export 'src/rules/ble_scanning.dart'; -export 'src/rules/bloc_state_management.dart'; -export 'src/rules/catalog.dart'; -export 'src/rules/circular_dependency.dart'; -export 'src/rules/device_lifecycle.dart'; -export 'src/rules/iot_security.dart'; -export 'src/rules/generic_state_management.dart'; -export 'src/rules/large_units.dart'; -export 'src/rules/layer_violation.dart'; -export 'src/rules/lifecycle_resource.dart'; -export 'src/rules/missing_const_constructor.dart'; -export 'src/rules/module_violation.dart'; -export 'src/rules/mqtt_connection.dart'; -export 'src/rules/pubspec_security.dart'; -export 'src/rules/provider_state_management.dart'; -export 'src/rules/registry.dart'; -export 'src/rules/riverpod_state_management.dart'; -export 'src/rules/state_dependency_cycle.dart'; -export 'src/rules/state_management_utils.dart'; -export 'src/scanner.dart'; -export 'src/source_utils.dart'; -export 'src/source_workspace.dart'; -export 'src/static_issue.dart'; diff --git a/packages/flutterguard_cli/lib/src/AGENTS.md b/packages/flutterguard_cli/lib/src/AGENTS.md deleted file mode 100644 index 23277ab..0000000 --- a/packages/flutterguard_cli/lib/src/AGENTS.md +++ /dev/null @@ -1,29 +0,0 @@ -# Internal Implementation Layer - -## Responsibility -This directory contains reusable implementation for the CLI. - -## Main Files -- `scanner.dart`: global scan orchestration and `ScanResult`. -- `scan_context.dart`: project/all/target file scope and scan mode. -- `source_workspace.dart`: shared source text/AST/line cache and diagnostics. -- `import_graph.dart`: resolved import graph shared by architecture rules. -- `boundary_engine.dart`: common layer/module dependency enforcement. -- `rules/catalog.dart`: explicit rule metadata and execution wiring. -- `config_loader.dart`: YAML parsing into typed record configs (20 rule configs + state management + architecture). -- `file_collector.dart`: include/exclude glob file discovery. -- `project_resolver.dart`: project auto-discovery (walk-up flutterguard.yaml / pubspec.yaml / lib/). -- `report_generator.dart`: table and JSON output with optional `--no-color` support. -- `static_issue.dart`: issue data model. -- `path_utils.dart`: cross-platform path/glob helpers. -- `import_utils.dart`: Dart import resolution against collected files. -- `source_utils.dart`: analyzer source location helpers. -- `rules/`: rule implementations only (21 rule classes, 23 rule IDs). - -## Design Rules -- Keep `bin/` thin; reusable logic belongs here. -- Prefer typed records and small helper classes over dynamic maps. -- Convert analyzer offsets to line numbers before storing `StaticIssue.line`. -- Keep Windows path behavior covered by tests when touching path/import logic. -- Do not depend on Flutter; this package is a Dart CLI. -- pubspec_security handles project-root YAML parsing (uses `package:yaml` directly). diff --git a/packages/flutterguard_cli/lib/src/baseline.dart b/packages/flutterguard_cli/lib/src/baseline.dart deleted file mode 100644 index 4bc8823..0000000 --- a/packages/flutterguard_cli/lib/src/baseline.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:path/path.dart' as p; - -import 'static_issue.dart'; - -class Baseline { - final Set fingerprints; - - const Baseline(this.fingerprints); - - static String fingerprint(StaticIssue issue, String projectPath) { - final relativePath = - p.isWithin(projectPath, issue.file) || p.equals(projectPath, issue.file) - ? p.relative(issue.file, from: projectPath) - : issue.file; - final normalizedPath = relativePath.replaceAll('\\', '/'); - final source = [ - issue.id, - normalizedPath, - issue.line ?? 1, - issue.message, - ].join('|'); - return _stableHash(source); - } - - bool contains(StaticIssue issue, String projectPath) { - return fingerprints.contains(fingerprint(issue, projectPath)); - } - - static Baseline load(String path) { - final file = File(path); - if (!file.existsSync()) { - throw FormatException('Baseline file "$path" does not exist.'); - } - - return loadFromString(file.readAsStringSync()); - } - - static Baseline loadFromString(String content) { - final decoded = jsonDecode(content); - if (decoded is! Map) { - throw const FormatException('Baseline file must be a JSON object.'); - } - final fingerprints = decoded['fingerprints']; - if (fingerprints is! List) { - throw const FormatException( - 'Baseline file must contain a fingerprints list.', - ); - } - - return Baseline(fingerprints.map((v) => v.toString()).toSet()); - } - - static Map stats(String path) { - final baseline = load(path); - return { - 'path': path, - 'fingerprints': baseline.fingerprints.length, - }; - } - - static List newFingerprints({ - required String projectPath, - required Baseline baseline, - required List issues, - }) { - final current = issues - .map((issue) => fingerprint(issue, projectPath)) - .where((fingerprint) => !baseline.fingerprints.contains(fingerprint)) - .toSet() - .toList() - ..sort(); - return current; - } - - static String encodeFingerprints({ - required String projectPath, - required List fingerprints, - }) { - final sorted = fingerprints.toSet().toList()..sort(); - final payload = { - 'version': '1.0.0', - 'generatedAt': DateTime.now().toIso8601String(), - 'projectPath': projectPath, - 'issueCount': sorted.length, - 'fingerprints': sorted, - }; - return const JsonEncoder.withIndent(' ').convert(payload); - } - - static String prune({ - required String projectPath, - required Baseline baseline, - required List issues, - }) { - final current = issues - .map((issue) => fingerprint(issue, projectPath)) - .where(baseline.fingerprints.contains) - .toList(); - return encodeFingerprints( - projectPath: projectPath, - fingerprints: current, - ); - } - - static String encode({ - required String projectPath, - required List issues, - }) { - final fingerprints = issues - .map((issue) => fingerprint(issue, projectPath)) - .toSet() - .toList() - ..sort(); - - final payload = { - 'version': '1.0.0', - 'generatedAt': DateTime.now().toIso8601String(), - 'projectPath': projectPath, - 'issueCount': issues.length, - 'fingerprints': fingerprints, - }; - return const JsonEncoder.withIndent(' ').convert(payload); - } -} - -String _stableHash(String source) { - var hash = 2166136261; - for (final codeUnit in utf8.encode(source)) { - hash ^= codeUnit; - hash = (hash * 16777619) & 0xffffffff; - } - return hash.toRadixString(16).padLeft(8, '0'); -} diff --git a/packages/flutterguard_cli/lib/src/boundary_engine.dart b/packages/flutterguard_cli/lib/src/boundary_engine.dart deleted file mode 100644 index 0737481..0000000 --- a/packages/flutterguard_cli/lib/src/boundary_engine.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:glob/glob.dart'; - -import 'import_graph.dart'; -import 'path_utils.dart'; -import 'source_workspace.dart'; - -class BoundaryDefinition { - final String name; - final String path; - final List allowedDeps; - - const BoundaryDefinition({ - required this.name, - required this.path, - required this.allowedDeps, - }); -} - -class BoundaryViolation { - final ImportEdge edge; - final BoundaryDefinition source; - final BoundaryDefinition target; - - const BoundaryViolation({ - required this.edge, - required this.source, - required this.target, - }); -} - -class DependencyBoundaryEngine { - static List analyze({ - required Iterable sourceFiles, - required Iterable allFiles, - required List boundaries, - required ImportGraph graph, - required SourceWorkspace workspace, - String? projectPath, - }) { - final fileToBoundary = {}; - for (final file in allFiles.map(normalizePath)) { - for (final boundary in boundaries) { - try { - final matches = projectPath == null - ? Glob(boundary.path.replaceAll('\\', '/')).matches(file) - : matchesProjectGlob(file, boundary.path, projectPath); - if (matches) { - fileToBoundary[file] = boundary; - break; - } - } on Object catch (error) { - workspace.addDiagnostic(ScanDiagnostic( - stage: 'boundary_glob', - file: file, - message: 'Invalid boundary glob "${boundary.path}": $error', - )); - } - } - } - - final violations = []; - for (final file in sourceFiles.map(normalizePath)) { - final source = fileToBoundary[file]; - if (source == null) continue; - for (final edge in graph.outgoing(file)) { - final target = fileToBoundary[edge.target]; - if (target == null || target.name == source.name) continue; - if (!source.allowedDeps.contains(target.name)) { - violations.add(BoundaryViolation( - edge: edge, - source: source, - target: target, - )); - } - } - } - return violations; - } -} diff --git a/packages/flutterguard_cli/lib/src/cli/baseline_commands.dart b/packages/flutterguard_cli/lib/src/cli/baseline_commands.dart deleted file mode 100644 index 7cf8eb1..0000000 --- a/packages/flutterguard_cli/lib/src/cli/baseline_commands.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:args/args.dart'; -import 'package:path/path.dart' as p; - -import '../baseline.dart'; -import '../scanner.dart'; - -class BaselineCommands { - static void create(ArgResults args, {String? configPath}) { - final projectPath = - args.rest.isNotEmpty ? args.rest.first : args['path'] as String; - final output = args['output'] as String; - - try { - final result = FlutterGuardScanner.scan( - projectPath: projectPath, - configPath: configPath, - applySuppression: false, - ); - final outputPath = - p.isAbsolute(output) ? output : p.join(result.projectPath, output); - Directory(p.dirname(outputPath)).createSync(recursive: true); - File(outputPath).writeAsStringSync(Baseline.encode( - projectPath: result.projectPath, - issues: result.rawIssues, - )); - stdout.writeln( - 'Created FlutterGuard baseline: $outputPath ' - '(${result.rawIssues.length} issues)', - ); - } on ScanException catch (error) { - _fail(error.message); - } on FormatException catch (error) { - _fail(error.message); - } - } - - static void stats(ArgResults args) { - try { - final stats = Baseline.stats(args['baseline'] as String); - stdout.writeln(const JsonEncoder.withIndent(' ').convert(stats)); - } on FormatException catch (error) { - _fail(error.message); - } - } - - static void prune(ArgResults args, {String? configPath}) { - final projectPath = - args.rest.isNotEmpty ? args.rest.first : args['path'] as String; - final baselinePath = args['baseline'] as String; - try { - final result = FlutterGuardScanner.scan( - projectPath: projectPath, - configPath: configPath, - applySuppression: false, - ); - final resolvedBaselinePath = _resolve(result.projectPath, baselinePath); - final baseline = Baseline.load(resolvedBaselinePath); - final pruned = Baseline.prune( - projectPath: result.projectPath, - baseline: baseline, - issues: result.rawIssues, - ); - final prunedBaseline = Baseline.loadFromString(pruned); - final removed = - baseline.fingerprints.length - prunedBaseline.fingerprints.length; - if (args['dry-run'] == true) { - stdout.writeln( - 'Baseline prune dry-run: ${baseline.fingerprints.length} -> ' - '${prunedBaseline.fingerprints.length} fingerprints ' - '($removed removed)', - ); - return; - } - File(resolvedBaselinePath).writeAsStringSync(pruned); - stdout.writeln( - 'Pruned baseline: ${baseline.fingerprints.length} -> ' - '${prunedBaseline.fingerprints.length} fingerprints ($removed removed)', - ); - } on ScanException catch (error) { - _fail(error.message); - } on FormatException catch (error) { - _fail(error.message); - } - } - - static void check(ArgResults args, {String? configPath}) { - final projectPath = - args.rest.isNotEmpty ? args.rest.first : args['path'] as String; - try { - final result = FlutterGuardScanner.scan( - projectPath: projectPath, - configPath: configPath, - applySuppression: false, - ); - final baseline = Baseline.load(_resolve( - result.projectPath, - args['baseline'] as String, - )); - final newFingerprints = Baseline.newFingerprints( - projectPath: result.projectPath, - baseline: baseline, - issues: result.rawIssues, - ); - stdout.writeln( - 'Baseline check: ${newFingerprints.length} issue(s) missing from baseline.', - ); - for (final fingerprint in newFingerprints.take(20)) { - stdout.writeln(' $fingerprint'); - } - if (args['no-growth'] == true && newFingerprints.isNotEmpty) { - exit(1); - } - } on ScanException catch (error) { - _fail(error.message); - } on FormatException catch (error) { - _fail(error.message); - } - } - - static String _resolve(String projectPath, String filePath) => - p.isAbsolute(filePath) ? filePath : p.join(projectPath, filePath); - - static Never _fail(String message) { - stderr.writeln('Error: $message'); - exit(2); - } -} diff --git a/packages/flutterguard_cli/lib/src/cli/cli_parsers.dart b/packages/flutterguard_cli/lib/src/cli/cli_parsers.dart deleted file mode 100644 index 7417ed1..0000000 --- a/packages/flutterguard_cli/lib/src/cli/cli_parsers.dart +++ /dev/null @@ -1,202 +0,0 @@ -import 'package:args/args.dart'; - -import '../config_tools.dart'; - -class CliParsers { - late final ArgParser scan; - late final ArgParser baselineCreate; - late final ArgParser baselineStats; - late final ArgParser baselinePrune; - late final ArgParser baselineCheck; - late final ArgParser baseline; - late final ArgParser init; - late final ArgParser installDoctor; - late final ArgParser doctor; - late final ArgParser issueExport; - late final ArgParser issue; - late final ArgParser configPrint; - late final ArgParser configDoctor; - late final ArgParser config; - late final ArgParser rules; - late final ArgParser explain; - late final ArgParser root; - - CliParsers() { - scan = ArgParser(allowTrailingOptions: false) - ..addOption('path', - abbr: 'p', defaultsTo: '.', help: 'Project path to scan') - ..addOption('config', - abbr: 'c', - defaultsTo: 'flutterguard.yaml', - help: 'Path to flutterguard.yaml config file') - ..addOption('format', - abbr: 'f', - defaultsTo: 'table', - allowed: ['table', 'json', 'sarif'], - help: 'Output format') - ..addOption('output', - abbr: 'o', - defaultsTo: '.flutterguard', - help: 'Output directory for reports') - ..addFlag('verbose', - abbr: 'v', - help: 'Show detailed output with code context', - negatable: false) - ..addFlag('no-color', - help: 'Disable ANSI terminal colors', negatable: false) - ..addFlag('changed-only', - help: 'Only scan files changed since --base', negatable: false) - ..addOption('base', - defaultsTo: 'main', help: 'Git base branch/ref for changed-only mode') - ..addOption('fail-on', - defaultsTo: 'none', - allowed: ['none', 'high', 'medium', 'low'], - help: 'Fail threshold for CI gate') - ..addOption('min-score', help: 'Minimum score threshold (0-100)') - ..addOption('baseline', - help: 'Baseline JSON file used to hide existing issues') - ..addFlag('help', abbr: 'h', help: 'Show scan usage', negatable: false); - - baselineCreate = ArgParser(allowTrailingOptions: false) - ..addOption('path', - abbr: 'p', defaultsTo: '.', help: 'Project path to scan') - ..addOption('config', - abbr: 'c', - defaultsTo: 'flutterguard.yaml', - help: 'Path to flutterguard.yaml config file') - ..addOption('output', - abbr: 'o', - defaultsTo: '.flutterguard/baseline.json', - help: 'Baseline file to create') - ..addFlag('help', - abbr: 'h', help: 'Show baseline create usage', negatable: false); - baselineStats = ArgParser() - ..addOption('baseline', - abbr: 'b', - defaultsTo: '.flutterguard/baseline.json', - help: 'Baseline file to inspect') - ..addFlag('help', - abbr: 'h', help: 'Show baseline stats usage', negatable: false); - baselinePrune = ArgParser(allowTrailingOptions: false) - ..addOption('path', - abbr: 'p', defaultsTo: '.', help: 'Project path to scan') - ..addOption('config', - abbr: 'c', - defaultsTo: 'flutterguard.yaml', - help: 'Path to flutterguard.yaml config file') - ..addOption('baseline', - abbr: 'b', - defaultsTo: '.flutterguard/baseline.json', - help: 'Baseline file to prune') - ..addFlag('dry-run', - help: 'Print prune result without updating the baseline file', - negatable: false) - ..addFlag('help', - abbr: 'h', help: 'Show baseline prune usage', negatable: false); - baselineCheck = ArgParser(allowTrailingOptions: false) - ..addOption('path', - abbr: 'p', defaultsTo: '.', help: 'Project path to scan') - ..addOption('config', - abbr: 'c', - defaultsTo: 'flutterguard.yaml', - help: 'Path to flutterguard.yaml config file') - ..addOption('baseline', - abbr: 'b', - defaultsTo: '.flutterguard/baseline.json', - help: 'Baseline file to compare against') - ..addFlag('no-growth', - help: 'Fail if current scan has issues missing from baseline', - negatable: false) - ..addFlag('help', - abbr: 'h', help: 'Show baseline check usage', negatable: false); - baseline = ArgParser() - ..addCommand('create', baselineCreate) - ..addCommand('stats', baselineStats) - ..addCommand('prune', baselinePrune) - ..addCommand('check', baselineCheck) - ..addFlag('help', - abbr: 'h', help: 'Show baseline usage', negatable: false); - - init = ArgParser() - ..addOption('path', - abbr: 'p', - defaultsTo: '.', - help: 'Project path for flutterguard.yaml') - ..addOption('config', - abbr: 'c', - defaultsTo: 'flutterguard.yaml', - help: 'Config file path to create') - ..addFlag('with-architecture', - help: 'Include architecture layer/module template', negatable: false) - ..addOption('profile', - defaultsTo: 'recommended', - allowed: ConfigTools.profiles.toList()..sort(), - help: 'Starter config profile') - ..addFlag('force', - help: 'Overwrite an existing config file', negatable: false) - ..addFlag('help', abbr: 'h', help: 'Show init usage', negatable: false); - - installDoctor = ArgParser() - ..addFlag('help', - abbr: 'h', help: 'Show install doctor usage', negatable: false); - doctor = ArgParser() - ..addCommand('install', installDoctor) - ..addFlag('help', abbr: 'h', help: 'Show doctor usage', negatable: false); - - issueExport = ArgParser(allowTrailingOptions: false) - ..addOption('path', - abbr: 'p', defaultsTo: '.', help: 'Project path to scan') - ..addOption('config', - abbr: 'c', - defaultsTo: 'flutterguard.yaml', - help: 'Path to flutterguard.yaml config file') - ..addOption('rule', help: 'Rule ID to export') - ..addOption('file', help: 'Project-relative or absolute file path') - ..addOption('line', help: 'Issue line number') - ..addOption('context', - defaultsTo: '2', help: 'Number of context lines around issue') - ..addOption('output', abbr: 'o', help: 'Output file. Defaults to stdout') - ..addFlag('help', - abbr: 'h', help: 'Show issue export usage', negatable: false); - issue = ArgParser() - ..addCommand('export', issueExport) - ..addFlag('help', abbr: 'h', help: 'Show issue usage', negatable: false); - - configPrint = _configLeafParser(); - configDoctor = _configLeafParser(); - config = ArgParser() - ..addCommand('print', configPrint) - ..addCommand('doctor', configDoctor) - ..addFlag('help', abbr: 'h', help: 'Show config usage', negatable: false); - rules = ArgParser() - ..addOption('format', - abbr: 'f', - defaultsTo: 'table', - allowed: ['table', 'json'], - help: 'Output format') - ..addFlag('help', abbr: 'h', help: 'Show rules usage', negatable: false); - explain = ArgParser() - ..addFlag('help', - abbr: 'h', help: 'Show explain usage', negatable: false); - - root = ArgParser() - ..addCommand('scan', scan) - ..addCommand('baseline', baseline) - ..addCommand('doctor', doctor) - ..addCommand('init', init) - ..addCommand('config', config) - ..addCommand('issue', issue) - ..addCommand('rules', rules) - ..addCommand('explain', explain) - ..addFlag('help', abbr: 'h', help: 'Show usage', negatable: false) - ..addFlag('version', abbr: 'V', help: 'Show version', negatable: false); - } - - ArgParser _configLeafParser() => ArgParser() - ..addOption('path', - abbr: 'p', defaultsTo: '.', help: 'Project path for config resolution') - ..addOption('config', - abbr: 'c', defaultsTo: 'flutterguard.yaml', help: 'Config file path') - ..addFlag('help', - abbr: 'h', help: 'Show config command usage', negatable: false); -} diff --git a/packages/flutterguard_cli/lib/src/cli/config_commands.dart b/packages/flutterguard_cli/lib/src/cli/config_commands.dart deleted file mode 100644 index b309e36..0000000 --- a/packages/flutterguard_cli/lib/src/cli/config_commands.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'dart:io'; - -import 'package:args/args.dart'; - -import '../config_loader.dart'; -import '../config_tools.dart'; -import '../install_doctor.dart'; -import '../project_resolver.dart'; - -class ConfigCommands { - static void init(ArgResults args) { - try { - final projectPath = - args.rest.isNotEmpty ? args.rest.first : args['path'] as String; - final outputPath = ConfigTools.writeInitConfig( - projectPath: projectPath, - configPath: args['config'] as String, - withArchitecture: args['with-architecture'] as bool, - force: args['force'] as bool, - profile: args['profile'] as String, - ); - stdout.writeln('Created FlutterGuard config: $outputPath'); - stdout.writeln('Next: flutterguard config doctor -p $projectPath'); - } on StateError catch (error) { - _fail(error.message); - } - } - - static void printEffective(ArgResults args, {String? configPath}) { - try { - final projectPath = ProjectResolver.resolveProjectPath( - args['path'] as String, - ); - final resolvedConfigPath = ConfigTools.resolveConfigPathForProject( - projectPath: projectPath, - configPath: configPath, - ); - final config = ScanConfig.fromFile( - resolvedConfigPath, - requireFile: configPath != null, - ); - stdout.write(ConfigTools.effectiveYaml(config)); - } on FormatException catch (error) { - _fail(error.message); - } - } - - static void doctor(ArgResults args, {String? configPath}) { - try { - final result = ConfigTools.doctor( - projectPath: args['path'] as String, - configPath: configPath, - ); - stdout.write(ConfigTools.formatDoctorResult(result)); - if (result.hasErrors) exit(1); - } on StateError catch (error) { - _fail(error.message); - } on FormatException catch (error) { - _fail(error.message); - } - } - - static void installDoctor({required String version}) { - stdout.write(InstallDoctor.generate(version: version)); - } - - static Never _fail(String message) { - stderr.writeln('Error: $message'); - exit(2); - } -} diff --git a/packages/flutterguard_cli/lib/src/cli/issue_commands.dart b/packages/flutterguard_cli/lib/src/cli/issue_commands.dart deleted file mode 100644 index 860f6db..0000000 --- a/packages/flutterguard_cli/lib/src/cli/issue_commands.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'dart:io'; - -import 'package:args/args.dart'; -import 'package:path/path.dart' as p; - -import '../issue_export.dart'; -import '../scanner.dart'; - -class IssueCommands { - static void export(ArgResults args, {String? configPath}) { - try { - final contextLines = int.tryParse(args['context'] as String); - if (contextLines == null || contextLines < 0) { - throw const FormatException( - 'Expected --context to be a non-negative integer.', - ); - } - final lineRaw = args['line'] as String?; - final line = lineRaw == null ? null : int.tryParse(lineRaw); - if (lineRaw != null && (line == null || line < 1)) { - throw const FormatException( - 'Expected --line to be a positive integer.', - ); - } - - final result = FlutterGuardScanner.scan( - projectPath: args['path'] as String, - configPath: configPath, - applySuppression: false, - ); - final exported = IssueExporter.export( - projectPath: result.projectPath, - issues: result.rawIssues, - ruleId: args['rule'] as String?, - filePath: args['file'] as String?, - line: line, - contextLines: contextLines, - workspace: result.sources, - ); - final output = args['output'] as String?; - if (output == null) { - stdout.writeln(exported); - return; - } - final outputPath = - p.isAbsolute(output) ? output : p.join(result.projectPath, output); - File(outputPath).parent.createSync(recursive: true); - File(outputPath).writeAsStringSync(exported); - stdout.writeln('Exported issue feedback bundle: $outputPath'); - } on ScanException catch (error) { - _fail(error.message); - } on FormatException catch (error) { - _fail(error.message); - } - } - - static Never _fail(String message) { - stderr.writeln('Error: $message'); - exit(2); - } -} diff --git a/packages/flutterguard_cli/lib/src/cli/rule_commands.dart b/packages/flutterguard_cli/lib/src/cli/rule_commands.dart deleted file mode 100644 index 29f1942..0000000 --- a/packages/flutterguard_cli/lib/src/cli/rule_commands.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:args/args.dart'; - -import '../rules/registry.dart'; - -class RuleCommands { - static void list(ArgResults args) { - final all = RuleRegistry.all(); - if (args['format'] == 'json') { - stdout.writeln(const JsonEncoder.withIndent(' ').convert( - all.map((meta) => meta.toJson()).toList(), - )); - return; - } - - all.sort((a, b) => a.id.compareTo(b.id)); - stdout.writeln('可用规则 (${all.length}):'); - stdout.writeln(); - for (final rule in all) { - stdout.writeln( - ' ${rule.id.padRight(36)} ${rule.domain.padRight(14)} ' - '${rule.framework.padRight(10)} ${rule.confidence.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('框架: ${meta.framework}'); - stdout.writeln('置信度: ${meta.confidence}'); - 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 deleted file mode 100644 index 620e792..0000000 --- a/packages/flutterguard_cli/lib/src/cli/scan_command.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'dart:io'; - -import 'package:args/args.dart'; - -import '../report_generator.dart'; -import '../scanner.dart'; - -class ScanCommand { - static void run(ArgResults args, {String? configPath}) { - final format = args['format'] as String; - final verbose = args['verbose'] as bool; - final noColor = args['no-color'] as bool; - final failOn = args['fail-on'] as String; - final minScore = _parseMinScore(args['min-score'] as String?); - - late final ScanResult result; - try { - result = FlutterGuardScanner.scan( - projectPath: args['path'] as String, - configPath: configPath, - outputDir: args['output'] as String, - writeJson: format == 'json', - writeSarif: format == 'sarif', - changedOnly: args['changed-only'] as bool, - base: args['base'] as String, - baselinePath: args['baseline'] as String?, - ); - } on ScanException catch (error) { - stderr.writeln('Error: ${error.message}'); - exit(2); - } on FormatException catch (error) { - stderr.writeln('Error: ${error.message}'); - exit(2); - } - - if (verbose && result.diagnostics.isNotEmpty) { - stderr.writeln('Scan diagnostics (${result.diagnostics.length}):'); - for (final diagnostic in result.diagnostics) { - final location = diagnostic.file == null ? '' : ' ${diagnostic.file}'; - stderr.writeln( - ' ${diagnostic.severity.name} [${diagnostic.stage}]$location: ' - '${diagnostic.message}', - ); - } - } - - if (result.files.isEmpty && result.issues.isEmpty) { - stdout.writeln('No changed Dart files matched the scan configuration.'); - if (format == 'sarif') { - stdout.writeln('SARIF report: ${result.reportDir}/report.sarif'); - } - return; - } - - if (format == 'sarif') { - stdout.writeln( - 'FlutterGuard scanned ${result.files.length} files, found ' - '${result.issues.length} visible issues ' - '(${result.suppressedCount} suppressed, ' - '${result.suppressedByBaselineCount} baseline).', - ); - stdout.writeln('SARIF report: ${result.reportDir}/report.sarif'); - } else { - final output = ReportGenerator.generateStdout( - projectPath: result.projectPath, - issues: result.issues, - scannedFileCount: result.files.length, - verbose: verbose, - noColor: noColor, - ); - stdout.writeln(output); - } - - if (failOn != 'none' && ReportGenerator.shouldFail(result.issues, failOn)) { - stderr.writeln( - 'CI gate failed: Issues found at or above "$failOn" level.', - ); - exit(1); - } - - if (minScore != null && result.score < minScore) { - stderr.writeln( - 'CI gate failed: Score ${result.score} is below minimum $minScore.', - ); - exit(1); - } - } - - static int? _parseMinScore(String? value) { - if (value == null) return null; - final score = int.tryParse(value); - if (score == null || score < 0 || score > 100) { - throw const FormatException( - 'Expected --min-score to be an integer 0-100.', - ); - } - return score; - } -} diff --git a/packages/flutterguard_cli/lib/src/config_loader.dart b/packages/flutterguard_cli/lib/src/config_loader.dart deleted file mode 100644 index 53deb5f..0000000 --- a/packages/flutterguard_cli/lib/src/config_loader.dart +++ /dev/null @@ -1,520 +0,0 @@ -// ignore_for_file: avoid_dynamic_calls - -import 'dart:io'; - -import 'package:yaml/yaml.dart'; - -import 'static_issue.dart'; - -typedef LayerConfig = ({ - String name, - String path, - List allowedDeps, -}); - -typedef ModuleConfig = ({ - String name, - String path, - List allowedDeps, -}); - -typedef ArchitectureConfig = ({ - List layers, - List modules, - bool detectCycles, - bool layerViolationEnabled, - bool moduleViolationEnabled, -}); - -typedef RulesConfig = ({ - LargeFileRuleConfig largeFile, - LargeClassRuleConfig largeClass, - LargeBuildMethodRuleConfig largeBuildMethod, - LifecycleResourceRuleConfig lifecycleResource, - MissingConstConstructorRuleConfig missingConstConstructor, - DeviceLifecycleRuleConfig deviceLifecycle, - MqttConnectionRuleConfig mqttConnection, - BleScanningRuleConfig bleScanning, - IotSecurityRuleConfig iotSecurity, - PubspecSecurityRuleConfig pubspecSecurity, - StateRuleConfig sideEffectInBuild, - StateRuleConfig stateManagerCreatedInBuild, - StateRuleConfig mutableStateExposed, - StateRuleConfig stateLayerUiDependency, - StateRuleConfig stateDependencyCycle, - StateRuleConfig riverpodReadUsedForRender, - StateRuleConfig riverpodWatchInCallback, - StateRuleConfig blocEquatablePropsIncomplete, - StateRuleConfig providerValueLifecycleMisuse, - StateRuleConfig notifyListenersInLoop, -}); - -typedef LargeFileRuleConfig = ({bool enabled, int maxLines}); -typedef LargeClassRuleConfig = ({bool enabled, int maxLines}); -typedef LargeBuildMethodRuleConfig = ({bool enabled, int maxLines}); -typedef LifecycleResourceRuleConfig = ({bool enabled}); -typedef MissingConstConstructorRuleConfig = ({bool enabled}); -typedef DeviceLifecycleRuleConfig = ({bool enabled}); -typedef MqttConnectionRuleConfig = ({bool enabled}); -typedef BleScanningRuleConfig = ({bool enabled, int maxScanDurationMs}); -typedef IotSecurityRuleConfig = ({bool enabled, bool requireTls}); -typedef PubspecSecurityRuleConfig = ({bool enabled}); -typedef StateRuleConfig = ({ - bool enabled, - RiskLevel severity, - List allowlist, - List ignorePaths, -}); - -typedef StateManagementConfig = ({ - bool enabled, - bool frameworkAutoDetect, - RuleConfidence confidenceThreshold, -}); - -class ScanConfig { - final List include; - final List exclude; - final RulesConfig rules; - final ArchitectureConfig architecture; - final StateManagementConfig stateManagement; - - const ScanConfig({ - required this.include, - required this.exclude, - required this.rules, - required this.architecture, - required this.stateManagement, - }); - - static const _knownTopLevelKeys = { - 'include', - 'exclude', - 'rules', - 'architecture', - 'state_management', - }; - static const _knownRuleKeys = { - 'large_file', - 'large_class', - 'large_build_method', - 'lifecycle_resource', - 'missing_const_constructor', - 'device_lifecycle', - 'mqtt_connection', - 'ble_scanning', - 'iot_security', - 'pubspec_security', - 'side_effect_in_build', - 'state_manager_created_in_build', - 'mutable_state_exposed', - 'state_layer_ui_dependency', - 'state_dependency_cycle', - 'riverpod_read_used_for_render', - 'riverpod_watch_in_callback', - 'bloc_equatable_props_incomplete', - 'provider_value_lifecycle_misuse', - 'notify_listeners_in_loop', - }; - static const _knownStateRuleKeys = { - 'enabled', - 'severity', - 'allowlist', - 'ignore_paths', - }; - static const _knownStateManagementKeys = { - 'enabled', - 'framework_auto_detect', - 'confidence_threshold', - }; - static const _knownLayerKeys = { - 'name', - 'path', - 'allowed_deps', - }; - static const _knownArchKeys = { - 'layers', - 'modules', - 'detect_cycles', - 'layer_violation', - 'module_violation', - }; - - factory ScanConfig.fromFile( - String path, { - bool requireFile = false, - }) { - final file = File(path); - if (!file.existsSync()) { - if (requireFile) { - throw FormatException('Config file "$path" does not exist.'); - } - return _defaultConfig(); - } - - final content = file.readAsStringSync(); - final parsed = loadYaml(content); - if (parsed == null) { - return _defaultConfig(); - } - if (parsed is! YamlMap) { - throw FormatException('Config file "$path" must contain a YAML map.'); - } - final yaml = parsed; - - _warnUnknownKeys(yaml, _knownTopLevelKeys, 'config'); - - final rules = _optionalMap(yaml['rules'], 'rules'); - _warnUnknownKeys(rules, _knownRuleKeys, 'rules'); - - final arch = _optionalMap(yaml['architecture'], 'architecture'); - _warnUnknownKeys(arch, _knownArchKeys, 'architecture'); - - final stateManagement = _optionalMap( - yaml['state_management'], - 'state_management', - ); - _warnUnknownKeys( - stateManagement, - _knownStateManagementKeys, - 'state_management', - ); - for (final ruleId in _stateRuleIds) { - final rule = _optionalMap(rules[ruleId], 'rules.$ruleId'); - _warnUnknownKeys(rule, _knownStateRuleKeys, 'rules.$ruleId'); - } - - if (arch['layers'] is YamlList) { - for (final layer in arch['layers'] as YamlList) { - if (layer is! YamlMap) { - throw const FormatException('Each architecture layer must be a map.'); - } - _warnUnknownKeys(layer, _knownLayerKeys, 'layer'); - } - } - if (arch['modules'] is YamlList) { - for (final module in arch['modules'] as YamlList) { - if (module is! YamlMap) { - throw const FormatException( - 'Each architecture module must be a map.'); - } - _warnUnknownKeys(module, _knownLayerKeys, 'module'); - } - } - - return ScanConfig( - include: _parseStringList(yaml['include']) ?? ['lib/**'], - exclude: _parseStringList(yaml['exclude']) ?? - [ - 'lib/generated/**', - 'lib/**.g.dart', - 'lib/**.freezed.dart', - 'lib/**.mocks.dart', - ], - rules: _parseRules(rules), - architecture: _parseArchitecture(arch), - stateManagement: _parseStateManagement(stateManagement), - ); - } - - static ScanConfig _defaultConfig() => ScanConfig( - include: ['lib/**'], - exclude: [ - 'lib/generated/**', - 'lib/**.g.dart', - 'lib/**.freezed.dart', - 'lib/**.mocks.dart', - ], - rules: ( - largeFile: (enabled: true, maxLines: 500), - largeClass: (enabled: true, maxLines: 300), - largeBuildMethod: (enabled: true, maxLines: 80), - lifecycleResource: (enabled: true), - missingConstConstructor: (enabled: true), - deviceLifecycle: (enabled: true), - mqttConnection: (enabled: true), - bleScanning: (enabled: true, maxScanDurationMs: 10000), - iotSecurity: (enabled: true, requireTls: true), - pubspecSecurity: (enabled: true), - sideEffectInBuild: _defaultStateRule(RiskLevel.high), - stateManagerCreatedInBuild: _defaultStateRule(RiskLevel.high), - mutableStateExposed: _defaultStateRule(RiskLevel.medium), - stateLayerUiDependency: _defaultStateRule(RiskLevel.high), - stateDependencyCycle: _defaultStateRule(RiskLevel.high), - riverpodReadUsedForRender: _defaultStateRule(RiskLevel.medium), - riverpodWatchInCallback: _defaultStateRule(RiskLevel.medium), - blocEquatablePropsIncomplete: _defaultStateRule(RiskLevel.medium), - providerValueLifecycleMisuse: _defaultStateRule(RiskLevel.medium), - notifyListenersInLoop: _defaultStateRule(RiskLevel.medium), - ), - architecture: ( - layers: [], - modules: [], - detectCycles: false, - layerViolationEnabled: true, - moduleViolationEnabled: true, - ), - stateManagement: ( - enabled: true, - frameworkAutoDetect: true, - confidenceThreshold: RuleConfidence.certain, - ), - ); - - static const _stateRuleIds = [ - 'side_effect_in_build', - 'state_manager_created_in_build', - 'mutable_state_exposed', - 'state_layer_ui_dependency', - 'state_dependency_cycle', - 'riverpod_read_used_for_render', - 'riverpod_watch_in_callback', - 'bloc_equatable_props_incomplete', - 'provider_value_lifecycle_misuse', - 'notify_listeners_in_loop', - ]; - - static StateRuleConfig _defaultStateRule(RiskLevel severity) => ( - enabled: true, - severity: severity, - allowlist: const [], - ignorePaths: const [], - ); - - static RulesConfig _parseRules(YamlMap rules) { - final largeFile = _optionalMap(rules['large_file'], 'rules.large_file'); - final largeClass = _optionalMap(rules['large_class'], 'rules.large_class'); - final largeBuildMethod = _optionalMap( - rules['large_build_method'], - 'rules.large_build_method', - ); - final lifecycleResource = _optionalMap( - rules['lifecycle_resource'], - 'rules.lifecycle_resource', - ); - final missingConstConstructor = _optionalMap( - rules['missing_const_constructor'], - 'rules.missing_const_constructor', - ); - final deviceLifecycle = _optionalMap( - rules['device_lifecycle'], - 'rules.device_lifecycle', - ); - final mqttConnection = _optionalMap( - rules['mqtt_connection'], - 'rules.mqtt_connection', - ); - final bleScanning = _optionalMap( - rules['ble_scanning'], - 'rules.ble_scanning', - ); - final iotSecurity = _optionalMap( - rules['iot_security'], - 'rules.iot_security', - ); - final pubspecSecurity = _optionalMap( - rules['pubspec_security'], - 'rules.pubspec_security', - ); - StateRuleConfig stateRule(String id, RiskLevel severity) => - _parseStateRule(_optionalMap(rules[id], 'rules.$id'), severity); - - return ( - largeFile: ( - enabled: _boolValue(largeFile, 'enabled', true), - maxLines: _intValue(largeFile, 'maxLines', 500), - ), - largeClass: ( - enabled: _boolValue(largeClass, 'enabled', true), - maxLines: _intValue(largeClass, 'maxLines', 300), - ), - largeBuildMethod: ( - enabled: _boolValue(largeBuildMethod, 'enabled', true), - maxLines: _intValue(largeBuildMethod, 'maxLines', 80), - ), - lifecycleResource: ( - enabled: _boolValue(lifecycleResource, 'enabled', true), - ), - missingConstConstructor: ( - enabled: _boolValue(missingConstConstructor, 'enabled', true), - ), - deviceLifecycle: (enabled: _boolValue(deviceLifecycle, 'enabled', true),), - mqttConnection: (enabled: _boolValue(mqttConnection, 'enabled', true),), - bleScanning: ( - enabled: _boolValue(bleScanning, 'enabled', true), - maxScanDurationMs: _intValue(bleScanning, 'maxScanDurationMs', 10000), - ), - iotSecurity: ( - enabled: _boolValue(iotSecurity, 'enabled', true), - requireTls: _boolValue(iotSecurity, 'requireTls', true), - ), - pubspecSecurity: (enabled: _boolValue(pubspecSecurity, 'enabled', true),), - sideEffectInBuild: stateRule('side_effect_in_build', RiskLevel.high), - stateManagerCreatedInBuild: - stateRule('state_manager_created_in_build', RiskLevel.high), - mutableStateExposed: stateRule('mutable_state_exposed', RiskLevel.medium), - stateLayerUiDependency: - stateRule('state_layer_ui_dependency', RiskLevel.high), - stateDependencyCycle: stateRule('state_dependency_cycle', RiskLevel.high), - riverpodReadUsedForRender: - stateRule('riverpod_read_used_for_render', RiskLevel.medium), - riverpodWatchInCallback: - stateRule('riverpod_watch_in_callback', RiskLevel.medium), - blocEquatablePropsIncomplete: - stateRule('bloc_equatable_props_incomplete', RiskLevel.medium), - providerValueLifecycleMisuse: - stateRule('provider_value_lifecycle_misuse', RiskLevel.medium), - notifyListenersInLoop: - stateRule('notify_listeners_in_loop', RiskLevel.medium), - ); - } - - static StateRuleConfig _parseStateRule( - YamlMap map, - RiskLevel defaultSeverity, - ) => - ( - enabled: _boolValue(map, 'enabled', true), - severity: _riskLevelValue(map, 'severity', defaultSeverity), - allowlist: _parseStringList(map['allowlist']) ?? const [], - ignorePaths: _parseStringList(map['ignore_paths']) ?? const [], - ); - - static StateManagementConfig _parseStateManagement(YamlMap map) => ( - enabled: _boolValue(map, 'enabled', true), - frameworkAutoDetect: _boolValue(map, 'framework_auto_detect', true), - confidenceThreshold: _confidenceValue( - map, - 'confidence_threshold', - RuleConfidence.certain, - ), - ); - - static ArchitectureConfig _parseArchitecture(YamlMap arch) { - final layers = []; - if (arch['layers'] is YamlList) { - for (final l in arch['layers'] as YamlList) { - if (l is! YamlMap) { - throw const FormatException('Each architecture layer must be a map.'); - } - layers.add(( - name: _requiredString(l, 'name', 'architecture.layers'), - path: _requiredString(l, 'path', 'architecture.layers'), - allowedDeps: _parseStringList(l['allowed_deps']) ?? [], - )); - } - } - - final modules = []; - if (arch['modules'] is YamlList) { - for (final m in arch['modules'] as YamlList) { - if (m is! YamlMap) { - throw const FormatException( - 'Each architecture module must be a map.'); - } - modules.add(( - name: _requiredString(m, 'name', 'architecture.modules'), - path: _requiredString(m, 'path', 'architecture.modules'), - allowedDeps: _parseStringList(m['allowed_deps']) ?? [], - )); - } - } - - final layerViolation = _optionalMap( - arch['layer_violation'], - 'architecture.layer_violation', - ); - final moduleViolation = _optionalMap( - arch['module_violation'], - 'architecture.module_violation', - ); - - return ( - layers: layers, - modules: modules, - detectCycles: _boolValue(arch, 'detect_cycles', false), - layerViolationEnabled: _boolValue(layerViolation, 'enabled', true), - moduleViolationEnabled: _boolValue(moduleViolation, 'enabled', true), - ); - } - - static void _warnUnknownKeys(YamlMap map, Set known, String context) { - for (final key in map.keys) { - final keyStr = key.toString(); - if (!known.contains(keyStr)) { - stderr.writeln('Warning: Unknown $context key "$keyStr"'); - } - } - } - - static List? _parseStringList(dynamic value) { - if (value is YamlList) { - if (value.any((element) => element is! String)) { - throw const FormatException('Expected a YAML list of strings.'); - } - return value.cast().toList(); - } - if (value != null) { - throw const FormatException('Expected a YAML list of strings.'); - } - return null; - } - - static YamlMap _optionalMap(dynamic value, String path) { - if (value == null) return YamlMap(); - if (value is YamlMap) return value; - throw FormatException('$path must be a YAML map.'); - } - - static bool _boolValue(YamlMap map, String key, bool defaultValue) { - final value = map[key]; - if (value == null) return defaultValue; - if (value is bool) return value; - throw FormatException('$key must be a boolean.'); - } - - static int _intValue(YamlMap map, String key, int defaultValue) { - final value = map[key]; - if (value == null) return defaultValue; - if (value is int) return value; - throw FormatException('$key must be an integer.'); - } - - static RiskLevel _riskLevelValue( - YamlMap map, - String key, - RiskLevel defaultValue, - ) { - final value = map[key]; - if (value == null) return defaultValue; - if (value is String) { - for (final level in RiskLevel.values) { - if (level.name == value) return level; - } - } - throw FormatException('$key must be one of: high, medium, low.'); - } - - static RuleConfidence _confidenceValue( - YamlMap map, - String key, - RuleConfidence defaultValue, - ) { - final value = map[key]; - if (value == null) return defaultValue; - if (value is String) { - for (final confidence in RuleConfidence.values) { - if (confidence.name == value) return confidence; - } - } - throw FormatException( - '$key must be one of: certain, probable, informational.', - ); - } - - static String _requiredString(YamlMap map, String key, String path) { - final value = map[key]; - if (value is String && value.isNotEmpty) return value; - throw FormatException('$path.$key must be a non-empty string.'); - } -} diff --git a/packages/flutterguard_cli/lib/src/config_tools.dart b/packages/flutterguard_cli/lib/src/config_tools.dart deleted file mode 100644 index 0d1fe2d..0000000 --- a/packages/flutterguard_cli/lib/src/config_tools.dart +++ /dev/null @@ -1,775 +0,0 @@ -import 'dart:io'; - -import 'package:path/path.dart' as p; - -import 'config_loader.dart'; -import 'file_collector.dart'; -import 'path_utils.dart'; -import 'project_resolver.dart'; - -enum DoctorSeverity { info, warning, error } - -class DoctorMessage { - final DoctorSeverity severity; - final String message; - - const DoctorMessage(this.severity, this.message); -} - -class DoctorResult { - final String projectPath; - final String configPath; - final bool configExists; - final int fileCount; - final List messages; - - const DoctorResult({ - required this.projectPath, - required this.configPath, - required this.configExists, - required this.fileCount, - required this.messages, - }); - - bool get hasErrors => - messages.any((message) => message.severity == DoctorSeverity.error); -} - -class ConfigTools { - static const profiles = { - 'recommended', - 'strict', - 'migration', - 'iot-security', - 'architecture-only', - 'performance-only', - }; - - static const minimalConfig = ''' -include: - - lib/** - -exclude: - - lib/generated/** - - lib/**.g.dart - - lib/**.freezed.dart - - lib/**.mocks.dart - -rules: - large_file: - enabled: true - maxLines: 500 - large_class: - enabled: true - maxLines: 300 - large_build_method: - enabled: true - maxLines: 80 - lifecycle_resource: - enabled: true - missing_const_constructor: - enabled: true - device_lifecycle: - enabled: true - mqtt_connection: - enabled: true - ble_scanning: - enabled: true - maxScanDurationMs: 10000 - iot_security: - enabled: true - requireTls: true - pubspec_security: - enabled: true -'''; - - static const architectureBlock = ''' - -architecture: - layers: - - name: presentation - path: lib/presentation/** - allowed_deps: [domain, core] - - name: domain - path: lib/domain/** - allowed_deps: [core] - - name: data - path: lib/data/** - allowed_deps: [domain, core] - - name: core - path: lib/core/** - allowed_deps: [] - - modules: - - name: mqtt_feature - path: lib/features/mqtt/** - allowed_deps: [shared] - - name: ble_feature - path: lib/features/ble/** - allowed_deps: [shared] - - name: shared - path: lib/shared/** - allowed_deps: [] - - detect_cycles: true - layer_violation: - enabled: true - module_violation: - enabled: true -'''; - - static String initTemplate({ - required bool withArchitecture, - String profile = 'recommended', - }) { - if (!profiles.contains(profile)) { - throw FormatException( - 'Unknown profile "$profile". Allowed: ${profiles.join(", ")}.', - ); - } - - final config = switch (profile) { - 'strict' => _strictConfig, - 'migration' => _migrationConfig, - 'iot-security' => _iotSecurityConfig, - 'architecture-only' => _architectureOnlyConfig, - 'performance-only' => _performanceOnlyConfig, - _ => minimalConfig, - }; - final withStateManagement = '$config${_stateRulesForProfile(profile)}'; - return withArchitecture - ? '$withStateManagement$architectureBlock' - : withStateManagement; - } - - static String writeInitConfig({ - required String projectPath, - required String configPath, - required bool withArchitecture, - required bool force, - String profile = 'recommended', - }) { - final resolvedProjectPath = ProjectResolver.resolveProjectPath(projectPath); - final outputPath = p.isAbsolute(configPath) - ? configPath - : p.join(resolvedProjectPath, configPath); - final file = File(outputPath); - if (file.existsSync() && !force) { - throw StateError( - 'Config file already exists at "$outputPath". Use --force to overwrite.', - ); - } - file.parent.createSync(recursive: true); - file.writeAsStringSync(initTemplate( - withArchitecture: withArchitecture, - profile: profile, - )); - return outputPath; - } - - static const _strictConfig = ''' -include: - - lib/** - -exclude: - - lib/generated/** - - lib/**.g.dart - - lib/**.freezed.dart - - lib/**.mocks.dart - -rules: - large_file: - enabled: true - maxLines: 400 - large_class: - enabled: true - maxLines: 240 - large_build_method: - enabled: true - maxLines: 60 - lifecycle_resource: - enabled: true - missing_const_constructor: - enabled: true - device_lifecycle: - enabled: true - mqtt_connection: - enabled: true - ble_scanning: - enabled: true - maxScanDurationMs: 10000 - iot_security: - enabled: true - requireTls: true - pubspec_security: - enabled: true -'''; - - static const _migrationConfig = ''' -include: - - lib/** - -exclude: - - lib/generated/** - - lib/**.g.dart - - lib/**.freezed.dart - - lib/**.mocks.dart - - test/** - - example/** - -rules: - large_file: - enabled: true - maxLines: 800 - large_class: - enabled: true - maxLines: 500 - large_build_method: - enabled: true - maxLines: 120 - lifecycle_resource: - enabled: true - missing_const_constructor: - enabled: false - device_lifecycle: - enabled: true - mqtt_connection: - enabled: true - ble_scanning: - enabled: true - maxScanDurationMs: 15000 - iot_security: - enabled: true - requireTls: true - pubspec_security: - enabled: true -'''; - - static const _iotSecurityConfig = ''' -include: - - lib/** - -exclude: - - lib/generated/** - - lib/**.g.dart - - lib/**.freezed.dart - - lib/**.mocks.dart - - test/** - - example/** - -rules: - large_file: - enabled: false - maxLines: 500 - large_class: - enabled: false - maxLines: 300 - large_build_method: - enabled: false - maxLines: 80 - lifecycle_resource: - enabled: true - missing_const_constructor: - enabled: false - device_lifecycle: - enabled: true - mqtt_connection: - enabled: true - ble_scanning: - enabled: true - maxScanDurationMs: 10000 - iot_security: - enabled: true - requireTls: true - pubspec_security: - enabled: true -'''; - - static const _architectureOnlyConfig = ''' -include: - - lib/** - -exclude: - - lib/generated/** - - lib/**.g.dart - - lib/**.freezed.dart - - lib/**.mocks.dart - -rules: - large_file: - enabled: false - maxLines: 500 - large_class: - enabled: false - maxLines: 300 - large_build_method: - enabled: false - maxLines: 80 - lifecycle_resource: - enabled: false - missing_const_constructor: - enabled: false - device_lifecycle: - enabled: false - mqtt_connection: - enabled: false - ble_scanning: - enabled: false - maxScanDurationMs: 10000 - iot_security: - enabled: false - requireTls: true - pubspec_security: - enabled: false -'''; - - static const _performanceOnlyConfig = ''' -include: - - lib/** - -exclude: - - lib/generated/** - - lib/**.g.dart - - lib/**.freezed.dart - - lib/**.mocks.dart - -rules: - large_file: - enabled: true - maxLines: 500 - large_class: - enabled: true - maxLines: 300 - large_build_method: - enabled: true - maxLines: 80 - lifecycle_resource: - enabled: true - missing_const_constructor: - enabled: false - device_lifecycle: - enabled: false - mqtt_connection: - enabled: false - ble_scanning: - enabled: false - maxScanDurationMs: 10000 - iot_security: - enabled: false - requireTls: true - pubspec_security: - enabled: false -'''; - - static String resolveConfigPathForProject({ - required String projectPath, - String? configPath, - }) { - return ProjectResolver.resolveConfigPath( - projectPath: projectPath, - explicitConfig: configPath, - ); - } - - static String effectiveYaml(ScanConfig config) { - final buffer = StringBuffer() - ..writeln('include:') - ..write(_stringList(config.include, indent: 2)) - ..writeln() - ..writeln('exclude:') - ..write(_stringList(config.exclude, indent: 2)) - ..writeln() - ..writeln('rules:') - ..write(_ruleWithMaxLines('large_file', config.rules.largeFile)) - ..write(_ruleWithMaxLines('large_class', config.rules.largeClass)) - ..write(_ruleWithMaxLines( - 'large_build_method', - config.rules.largeBuildMethod, - )) - ..write(_enabledRule( - 'lifecycle_resource', - config.rules.lifecycleResource.enabled, - )) - ..write(_enabledRule( - 'missing_const_constructor', - config.rules.missingConstConstructor.enabled, - )) - ..write(_enabledRule( - 'device_lifecycle', - config.rules.deviceLifecycle.enabled, - )) - ..write(_enabledRule( - 'mqtt_connection', - config.rules.mqttConnection.enabled, - )) - ..writeln(' ble_scanning:') - ..writeln(' enabled: ${config.rules.bleScanning.enabled}') - ..writeln( - ' maxScanDurationMs: ${config.rules.bleScanning.maxScanDurationMs}', - ) - ..writeln(' iot_security:') - ..writeln(' enabled: ${config.rules.iotSecurity.enabled}') - ..writeln(' requireTls: ${config.rules.iotSecurity.requireTls}') - ..write(_enabledRule( - 'pubspec_security', - config.rules.pubspecSecurity.enabled, - )) - ..write(_stateRuleYaml( - 'side_effect_in_build', - config.rules.sideEffectInBuild, - )) - ..write(_stateRuleYaml( - 'state_manager_created_in_build', - config.rules.stateManagerCreatedInBuild, - )) - ..write(_stateRuleYaml( - 'mutable_state_exposed', - config.rules.mutableStateExposed, - )) - ..write(_stateRuleYaml( - 'state_layer_ui_dependency', - config.rules.stateLayerUiDependency, - )) - ..write(_stateRuleYaml( - 'state_dependency_cycle', - config.rules.stateDependencyCycle, - )) - ..write(_stateRuleYaml( - 'riverpod_read_used_for_render', - config.rules.riverpodReadUsedForRender, - )) - ..write(_stateRuleYaml( - 'riverpod_watch_in_callback', - config.rules.riverpodWatchInCallback, - )) - ..write(_stateRuleYaml( - 'bloc_equatable_props_incomplete', - config.rules.blocEquatablePropsIncomplete, - )) - ..write(_stateRuleYaml( - 'provider_value_lifecycle_misuse', - config.rules.providerValueLifecycleMisuse, - )) - ..write(_stateRuleYaml( - 'notify_listeners_in_loop', - config.rules.notifyListenersInLoop, - )) - ..writeln() - ..writeln('state_management:') - ..writeln(' enabled: ${config.stateManagement.enabled}') - ..writeln( - ' framework_auto_detect: ' - '${config.stateManagement.frameworkAutoDetect}', - ) - ..writeln( - ' confidence_threshold: ' - '${config.stateManagement.confidenceThreshold.name}', - ) - ..writeln() - ..writeln('architecture:') - ..write(_boundaryList('layers', config.architecture.layers)) - ..write(_boundaryList('modules', config.architecture.modules)) - ..writeln(' detect_cycles: ${config.architecture.detectCycles}') - ..writeln(' layer_violation:') - ..writeln(' enabled: ${config.architecture.layerViolationEnabled}') - ..writeln(' module_violation:') - ..writeln(' enabled: ${config.architecture.moduleViolationEnabled}'); - - return buffer.toString(); - } - - static DoctorResult doctor({ - required String projectPath, - String? configPath, - }) { - final resolvedProjectPath = ProjectResolver.resolveProjectPath(projectPath); - if (!Directory(resolvedProjectPath).existsSync()) { - throw StateError('Project path "$resolvedProjectPath" does not exist.'); - } - - final resolvedConfigPath = resolveConfigPathForProject( - projectPath: resolvedProjectPath, - configPath: configPath, - ); - final configExists = File(resolvedConfigPath).existsSync(); - final config = ScanConfig.fromFile( - resolvedConfigPath, - requireFile: configPath != null, - ); - final files = FileCollector.collect(resolvedProjectPath, config); - - final messages = []; - if (!configExists) { - messages.add(DoctorMessage( - DoctorSeverity.info, - 'No config file found. Built-in defaults are being used.', - )); - } - if (files.isEmpty) { - messages.add(DoctorMessage( - DoctorSeverity.error, - 'No Dart files matched include/exclude patterns.', - )); - } - - _checkBackslashes(config, messages); - _checkBoundaries( - kind: 'layer', - boundaries: config.architecture.layers, - enabled: config.architecture.layerViolationEnabled, - files: files, - projectPath: resolvedProjectPath, - messages: messages, - ); - _checkBoundaries( - kind: 'module', - boundaries: config.architecture.modules, - enabled: config.architecture.moduleViolationEnabled, - files: files, - projectPath: resolvedProjectPath, - messages: messages, - ); - - if (config.architecture.detectCycles && files.isEmpty) { - messages.add(DoctorMessage( - DoctorSeverity.warning, - 'Circular dependency detection is enabled, but no files matched.', - )); - } - - return DoctorResult( - projectPath: resolvedProjectPath, - configPath: resolvedConfigPath, - configExists: configExists, - fileCount: files.length, - messages: messages, - ); - } - - static String formatDoctorResult(DoctorResult result) { - final buffer = StringBuffer() - ..writeln('FlutterGuard config doctor') - ..writeln('Project: ${result.projectPath}') - ..writeln('Config: ${result.configPath}') - ..writeln('Config exists: ${result.configExists}') - ..writeln('Matched Dart files: ${result.fileCount}'); - - if (result.messages.isEmpty) { - buffer.writeln('Status: OK'); - return buffer.toString(); - } - - buffer.writeln(); - for (final message in result.messages) { - buffer.writeln( - '${_severityLabel(message.severity)}: ${message.message}', - ); - } - return buffer.toString(); - } - - static String _stringList(List values, {required int indent}) { - final spaces = ' ' * indent; - return values.map((value) => '$spaces- $value\n').join(); - } - - static String _ruleWithMaxLines( - String name, - ({bool enabled, int maxLines}) config, - ) { - return ''' - $name: - enabled: ${config.enabled} - maxLines: ${config.maxLines} -'''; - } - - static String _enabledRule(String name, bool enabled) { - return ''' - $name: - enabled: $enabled -'''; - } - - static String _stateRuleYaml(String name, StateRuleConfig config) { - return ''' - $name: - enabled: ${config.enabled} - severity: ${config.severity.name} - allowlist: [${config.allowlist.join(', ')}] - ignore_paths: [${config.ignorePaths.join(', ')}] -'''; - } - - static String _stateRulesForProfile(String profile) { - final disabled = - profile == 'iot-security' || profile == 'architecture-only'; - final performanceOnly = profile == 'performance-only'; - bool enabled(String id) { - if (disabled) return false; - if (!performanceOnly) return true; - return const { - 'side_effect_in_build', - 'state_manager_created_in_build', - 'provider_value_lifecycle_misuse', - 'notify_listeners_in_loop', - }.contains(id); - } - - const severities = { - 'side_effect_in_build': 'high', - 'state_manager_created_in_build': 'high', - 'mutable_state_exposed': 'medium', - 'state_layer_ui_dependency': 'high', - 'state_dependency_cycle': 'high', - 'riverpod_read_used_for_render': 'medium', - 'riverpod_watch_in_callback': 'medium', - 'bloc_equatable_props_incomplete': 'medium', - 'provider_value_lifecycle_misuse': 'medium', - 'notify_listeners_in_loop': 'medium', - }; - final buffer = StringBuffer(); - for (final entry in severities.entries) { - buffer - ..writeln(' ${entry.key}:') - ..writeln(' enabled: ${enabled(entry.key)}') - ..writeln(' severity: ${entry.value}') - ..writeln(' allowlist: []') - ..writeln(' ignore_paths: []'); - } - buffer - ..writeln() - ..writeln('state_management:') - ..writeln(' enabled: true') - ..writeln(' framework_auto_detect: true') - ..writeln(' confidence_threshold: certain'); - return buffer.toString(); - } - - static String _boundaryList( - String key, - List<({String name, String path, List allowedDeps})> boundaries, - ) { - if (boundaries.isEmpty) return ' $key: []\n'; - final buffer = StringBuffer(); - buffer.writeln(' $key:'); - for (final boundary in boundaries) { - buffer - ..writeln(' - name: ${boundary.name}') - ..writeln(' path: ${boundary.path}') - ..writeln(' allowed_deps: [${boundary.allowedDeps.join(', ')}]'); - } - return buffer.toString(); - } - - static void _checkBackslashes( - ScanConfig config, - List messages, - ) { - for (final pattern in [...config.include, ...config.exclude]) { - if (pattern.contains(r'\')) { - messages.add(DoctorMessage( - DoctorSeverity.warning, - 'Pattern "$pattern" contains backslashes. Use forward slashes in YAML.', - )); - } - } - for (final boundary in [ - ...config.architecture.layers, - ...config.architecture.modules, - ]) { - if (boundary.path.contains(r'\')) { - messages.add(DoctorMessage( - DoctorSeverity.warning, - 'Architecture path "${boundary.path}" contains backslashes. Use forward slashes in YAML.', - )); - } - } - } - - static void _checkBoundaries({ - required String kind, - required List<({String name, String path, List allowedDeps})> - boundaries, - required bool enabled, - required List files, - required String projectPath, - required List messages, - }) { - if (enabled && boundaries.isEmpty) { - messages.add(DoctorMessage( - DoctorSeverity.info, - 'No architecture ${kind}s declared. ${kind}_violation will not report boundary issues.', - )); - return; - } - - final names = boundaries.map((boundary) => boundary.name).toSet(); - if (names.length != boundaries.length) { - messages.add(DoctorMessage( - DoctorSeverity.error, - 'Duplicate architecture $kind names found.', - )); - } - - for (final boundary in boundaries) { - for (final dep in boundary.allowedDeps) { - if (!names.contains(dep)) { - messages.add(DoctorMessage( - DoctorSeverity.error, - 'Architecture $kind "${boundary.name}" allows unknown dependency "$dep".', - )); - } - } - } - - final matchesByName = >{}; - for (final boundary in boundaries) { - final matches = files - .where((file) => _matchesBoundary(file, boundary.path, projectPath)) - .toSet(); - matchesByName[boundary.name] = matches; - if (matches.isEmpty) { - messages.add(DoctorMessage( - DoctorSeverity.warning, - 'Architecture $kind "${boundary.name}" path "${boundary.path}" matched no files.', - )); - } - } - - for (var i = 0; i < boundaries.length; i++) { - for (var j = i + 1; j < boundaries.length; j++) { - final left = boundaries[i]; - final right = boundaries[j]; - final overlap = matchesByName[left.name]!.intersection( - matchesByName[right.name]!, - ); - if (overlap.isNotEmpty) { - messages.add(DoctorMessage( - DoctorSeverity.warning, - 'Architecture $kind paths "${left.name}" and "${right.name}" overlap on ${overlap.length} file(s).', - )); - } - } - } - } - - static bool _matchesBoundary( - String file, - String pattern, - String projectPath, - ) { - return matchesProjectGlob(file, pattern, projectPath); - } - - static String _severityLabel(DoctorSeverity severity) { - switch (severity) { - case DoctorSeverity.info: - return 'INFO'; - case DoctorSeverity.warning: - return 'WARN'; - case DoctorSeverity.error: - return 'ERROR'; - } - } -} diff --git a/packages/flutterguard_cli/lib/src/domain.dart b/packages/flutterguard_cli/lib/src/domain.dart deleted file mode 100644 index ba8c057..0000000 --- a/packages/flutterguard_cli/lib/src/domain.dart +++ /dev/null @@ -1,5 +0,0 @@ -enum IssueDomain { - architecture, - performance, - standards, -} diff --git a/packages/flutterguard_cli/lib/src/file_collector.dart b/packages/flutterguard_cli/lib/src/file_collector.dart deleted file mode 100644 index bbf439a..0000000 --- a/packages/flutterguard_cli/lib/src/file_collector.dart +++ /dev/null @@ -1,149 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:glob/glob.dart'; -import 'package:glob/list_local_fs.dart'; -import 'package:path/path.dart' as p; - -import 'config_loader.dart'; -import 'path_utils.dart'; - -class ChangedFilesException implements Exception { - final String message; - - const ChangedFilesException(this.message); - - @override - String toString() => message; -} - -class FileCollector { - static List collect(String projectPath, ScanConfig config) { - final context = projectPathContext(projectPath); - final allFiles = {}; - - for (final pattern in config.include) { - final glob = Glob(pattern.replaceAll('\\', '/'), context: context); - for (final entity in glob.listSync(root: context.current)) { - if (entity is File && entity.path.endsWith('.dart')) { - allFiles.add(normalizePath(entity.path, context: context)); - } - } - } - - for (final pattern in config.exclude) { - final glob = Glob(pattern.replaceAll('\\', '/'), context: context); - for (final entity in glob.listSync(root: context.current)) { - allFiles.remove(normalizePath(entity.path, context: context)); - } - allFiles.removeWhere((f) => glob.matches(f)); - } - - return allFiles.toList()..sort(); - } - - static Set? getChangedFiles(String projectPath, String base) { - try { - final rootResult = Process.runSync( - 'git', - ['rev-parse', '--show-toplevel'], - workingDirectory: projectPath, - stdoutEncoding: utf8, - ); - if (rootResult.exitCode != 0) return null; - - final gitRoot = (rootResult.stdout as String).trim(); - final canonicalGitRoot = Directory(gitRoot).resolveSymbolicLinksSync(); - final canonicalProjectPath = - Directory(projectPath).resolveSymbolicLinksSync(); - if (base.startsWith('-')) { - throw ChangedFilesException('Invalid Git base "$base".'); - } - final refResult = Process.runSync( - 'git', - ['rev-parse', '--verify', '$base^{commit}'], - workingDirectory: gitRoot, - stdoutEncoding: utf8, - ); - if (refResult.exitCode != 0) { - throw ChangedFilesException( - _gitFailureMessage('Invalid Git base "$base"', refResult.stderr), - ); - } - final verifiedRef = (refResult.stdout as String).trim(); - final result = Process.runSync( - 'git', - [ - 'diff', - '--name-only', - '--diff-filter=ACMR', - '-z', - verifiedRef, - '--', - ], - workingDirectory: gitRoot, - stdoutEncoding: utf8, - ); - if (result.exitCode != 0) { - throw ChangedFilesException( - _gitFailureMessage('git diff', result.stderr), - ); - } - final untracked = Process.runSync( - 'git', - ['ls-files', '--others', '--exclude-standard', '-z'], - workingDirectory: gitRoot, - stdoutEncoding: utf8, - ); - if (untracked.exitCode != 0) { - throw ChangedFilesException( - _gitFailureMessage('git ls-files', untracked.stderr), - ); - } - - final changed = {}; - for (final path in (result.stdout as String).split('\x00')) { - if (path.isNotEmpty) { - changed.add(_projectAnchoredGitPath( - projectPath: projectPath, - canonicalProjectPath: canonicalProjectPath, - canonicalGitRoot: canonicalGitRoot, - gitRelativePath: path, - )); - } - } - for (final path in (untracked.stdout as String).split('\x00')) { - if (path.isNotEmpty) { - changed.add(_projectAnchoredGitPath( - projectPath: projectPath, - canonicalProjectPath: canonicalProjectPath, - canonicalGitRoot: canonicalGitRoot, - gitRelativePath: path, - )); - } - } - return changed; - } on ProcessException { - return null; - } - } - - static String _gitFailureMessage(String command, Object stderr) { - final detail = stderr.toString().trim(); - return detail.isEmpty ? '$command failed.' : '$command failed: $detail'; - } - - static String _projectAnchoredGitPath({ - required String projectPath, - required String canonicalProjectPath, - required String canonicalGitRoot, - required String gitRelativePath, - }) { - final canonicalPath = p.join(canonicalGitRoot, gitRelativePath); - final projectRelativePath = p.relative( - canonicalPath, - from: canonicalProjectPath, - ); - return p.normalize(p.join(projectPath, projectRelativePath)); - } -} diff --git a/packages/flutterguard_cli/lib/src/import_graph.dart b/packages/flutterguard_cli/lib/src/import_graph.dart deleted file mode 100644 index 640a374..0000000 --- a/packages/flutterguard_cli/lib/src/import_graph.dart +++ /dev/null @@ -1,74 +0,0 @@ -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/import_utils.dart b/packages/flutterguard_cli/lib/src/import_utils.dart deleted file mode 100644 index d0cf539..0000000 --- a/packages/flutterguard_cli/lib/src/import_utils.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:path/path.dart' as p; - -import 'path_utils.dart'; - -String? resolveImport( - String sourceFile, - String importStr, - Set fileSet, { - String? projectPath, - p.Context? context, -}) { - context ??= p.context; - final source = normalizePath(sourceFile, context: context); - final normalizedFiles = { - for (final file in fileSet) normalizePath(file, context: context), - }; - - if (importStr.startsWith('package:')) { - final packageRelative = - importStr.replaceFirst(RegExp(r'^package:[^/]+/'), ''); - return _resolvePackageImport( - packageRelative, - normalizedFiles, - projectPath: projectPath, - context: context, - ); - } - - final sourceDir = context.dirname(source); - final resolved = context.normalize(context.join(sourceDir, importStr)); - if (normalizedFiles.contains(resolved)) return resolved; - final withExt = resolved.endsWith('.dart') ? resolved : '$resolved.dart'; - if (normalizedFiles.contains(withExt)) return withExt; - return null; -} - -String? _resolvePackageImport( - String packageRelative, - Set fileSet, { - String? projectPath, - required p.Context context, -}) { - final relativeWithExt = packageRelative.endsWith('.dart') - ? packageRelative - : '$packageRelative.dart'; - final normalizedRelative = context.normalize(relativeWithExt); - - if (projectPath != null) { - final projectContext = projectPathContext(projectPath, context: context); - final candidate = projectContext.normalize( - projectContext.join(projectContext.current, 'lib', normalizedRelative), - ); - if (fileSet.contains(candidate)) return candidate; - } - - for (final file in fileSet) { - final libRelative = _relativeToLib(file, context); - if (libRelative == normalizedRelative) return file; - } - - return null; -} - -String? _relativeToLib(String file, p.Context context) { - final parts = context.split(context.normalize(file)); - final libIndex = parts.lastIndexOf('lib'); - if (libIndex == -1 || libIndex == parts.length - 1) return null; - return context.joinAll(parts.skip(libIndex + 1)); -} diff --git a/packages/flutterguard_cli/lib/src/install_doctor.dart b/packages/flutterguard_cli/lib/src/install_doctor.dart deleted file mode 100644 index 1739121..0000000 --- a/packages/flutterguard_cli/lib/src/install_doctor.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'dart:io'; - -class InstallDoctor { - static String generate({required String version}) { - final buffer = StringBuffer() - ..writeln('FlutterGuard install doctor') - ..writeln('Version: $version') - ..writeln('Dart executable: ${Platform.resolvedExecutable}') - ..writeln('Entrypoint: ${Platform.script}') - ..writeln('Operating system: ${Platform.operatingSystem}') - ..writeln('PATH entries named flutterguard:'); - - final matches = _findFlutterGuardCommands(); - if (matches.isEmpty) { - buffer.writeln(' none found'); - } else { - for (final match in matches) { - buffer.writeln(' $match'); - } - } - - buffer - ..writeln() - ..writeln('Recommended checks:') - ..writeln(' flutterguard --version') - ..writeln(' flutterguard --help') - ..writeln() - ..writeln( - 'If the version is stale, reinstall or use the source launcher:') - ..writeln(' dart pub global activate flutterguard_cli') - ..writeln(' ./scripts/flutterguard-dev --version'); - - return buffer.toString(); - } - - static List _findFlutterGuardCommands() { - final path = Platform.environment['PATH']; - if (path == null || path.isEmpty) return const []; - - final names = Platform.isWindows - ? const ['flutterguard.exe', 'flutterguard.bat', 'flutterguard.cmd'] - : const ['flutterguard']; - final matches = []; - - for (final dir in path.split(Platform.isWindows ? ';' : ':')) { - if (dir.isEmpty) continue; - for (final name in names) { - final candidate = File(_joinPath(dir, name)); - if (candidate.existsSync()) { - matches.add(candidate.path); - } - } - } - return matches; - } - - static String _joinPath(String left, String right) { - final separator = Platform.isWindows ? r'\' : '/'; - if (left.endsWith(separator)) return '$left$right'; - return '$left$separator$right'; - } -} diff --git a/packages/flutterguard_cli/lib/src/issue_export.dart b/packages/flutterguard_cli/lib/src/issue_export.dart deleted file mode 100644 index 643e445..0000000 --- a/packages/flutterguard_cli/lib/src/issue_export.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:path/path.dart' as p; - -import 'baseline.dart'; -import 'rules/registry.dart'; -import 'source_workspace.dart'; -import 'static_issue.dart'; - -class IssueExporter { - static String export({ - required String projectPath, - required List issues, - String? ruleId, - String? filePath, - int? line, - int contextLines = 2, - SourceWorkspace? workspace, - }) { - final issue = _findIssue( - projectPath: projectPath, - issues: issues, - ruleId: ruleId, - filePath: filePath, - line: line, - ); - if (issue == null) { - throw const FormatException('No matching issue found to export.'); - } - - final relativePath = _relativePath(issue.file, projectPath); - final meta = RuleRegistry.find(issue.id); - final payload = { - 'version': '1.0.0', - 'generatedAt': DateTime.now().toIso8601String(), - 'projectPath': projectPath, - 'fingerprint': Baseline.fingerprint(issue, projectPath), - 'issue': issue.toJson()..['file'] = relativePath, - 'rule': meta?.toJson(), - 'context': _context(issue, contextLines, workspace: workspace), - 'feedbackTemplate': { - 'whyFalsePositiveOrFalseNegative': '', - 'expectedBehavior': '', - 'sanitizedSnippetConfirmed': false, - }, - }; - - return const JsonEncoder.withIndent(' ').convert(payload); - } - - static StaticIssue? _findIssue({ - required String projectPath, - required List issues, - String? ruleId, - String? filePath, - int? line, - }) { - final normalizedFile = filePath == null - ? null - : p.normalize( - p.isAbsolute(filePath) ? filePath : p.join(projectPath, filePath)); - - for (final issue in issues) { - if (ruleId != null && issue.id != ruleId) continue; - if (line != null && issue.line != line) continue; - if (normalizedFile != null && p.normalize(issue.file) != normalizedFile) { - continue; - } - return issue; - } - return null; - } - - static Map _context( - StaticIssue issue, - int contextLines, { - SourceWorkspace? workspace, - }) { - final line = issue.line; - final file = File(issue.file); - if (line == null || !file.existsSync()) { - return { - 'available': false, - 'lines': const [], - }; - } - - final lines = - workspace?.source(issue.file)?.lines ?? file.readAsLinesSync(); - final start = (line - contextLines).clamp(1, lines.length); - final end = (line + contextLines).clamp(1, lines.length); - return { - 'available': true, - 'startLine': start, - 'endLine': end, - 'lines': [ - for (var i = start; i <= end; i++) - { - 'line': i, - 'text': lines[i - 1], - } - ], - }; - } - - static String _relativePath(String filePath, String projectPath) { - if (p.isWithin(projectPath, filePath) || p.equals(projectPath, filePath)) { - return p.relative(filePath, from: projectPath).replaceAll('\\', '/'); - } - return filePath.replaceAll('\\', '/'); - } -} diff --git a/packages/flutterguard_cli/lib/src/path_utils.dart b/packages/flutterguard_cli/lib/src/path_utils.dart deleted file mode 100644 index 473ffc6..0000000 --- a/packages/flutterguard_cli/lib/src/path_utils.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:glob/glob.dart'; -import 'package:path/path.dart' as p; - -p.Context projectPathContext(String projectPath, {p.Context? context}) { - context ??= p.context; - final absoluteRoot = context.normalize(context.absolute(projectPath)); - return p.Context(style: context.style, current: absoluteRoot); -} - -String normalizePath( - String path, { - p.Context? context, - String? basePath, -}) { - context ??= p.context; - if (basePath != null) { - context = projectPathContext(basePath, context: context); - } - final absolute = context.isAbsolute(path) ? path : context.absolute(path); - return context.normalize(absolute); -} - -bool matchesProjectGlob( - String filePath, - String pattern, - String projectPath, { - p.Context? context, -}) { - final projectContext = projectPathContext(projectPath, context: context); - final normalizedFile = normalizePath(filePath, context: projectContext); - final normalizedPattern = pattern.replaceAll('\\', '/'); - return Glob(normalizedPattern, context: projectContext) - .matches(normalizedFile); -} - -String projectRelativePath( - String filePath, - String projectPath, { - p.Context? context, -}) { - final projectContext = projectPathContext(projectPath, context: context); - return projectContext.relative( - normalizePath(filePath, context: projectContext), - from: projectContext.current, - ); -} diff --git a/packages/flutterguard_cli/lib/src/priority.dart b/packages/flutterguard_cli/lib/src/priority.dart deleted file mode 100644 index 77cedd1..0000000 --- a/packages/flutterguard_cli/lib/src/priority.dart +++ /dev/null @@ -1 +0,0 @@ -enum Priority { p0, p1, p2 } diff --git a/packages/flutterguard_cli/lib/src/project_resolver.dart b/packages/flutterguard_cli/lib/src/project_resolver.dart deleted file mode 100644 index 3b0388c..0000000 --- a/packages/flutterguard_cli/lib/src/project_resolver.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'dart:io'; - -import 'package:path/path.dart' as p; - -class ProjectResolver { - static const defaultConfigPath = 'flutterguard.yaml'; - - static const _discoveryMarkers = [ - defaultConfigPath, - 'pubspec.yaml', - ]; - - static String resolveProjectPath(String? explicitPath) { - if (explicitPath != null && explicitPath != '.') { - return p.normalize(p.absolute(explicitPath)); - } - final discovered = _walkUpFind(Directory.current.path); - return discovered ?? Directory.current.path; - } - - static String resolveConfigPath({ - required String projectPath, - required String? explicitConfig, - }) { - 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.'); - } - - return resolvedPath; - } - - static String? _walkUpFind(String startPath) { - var dir = Directory(startPath); - while (true) { - for (final marker in _discoveryMarkers) { - final candidate = p.join(dir.path, marker); - if (File(candidate).existsSync()) return dir.path; - } - final libCandidate = p.join(dir.path, 'lib'); - if (Directory(libCandidate).existsSync()) return dir.path; - - final parent = dir.parent.path; - if (parent == dir.path) break; - dir = dir.parent; - } - return null; - } -} diff --git a/packages/flutterguard_cli/lib/src/report_generator.dart b/packages/flutterguard_cli/lib/src/report_generator.dart deleted file mode 100644 index b42011a..0000000 --- a/packages/flutterguard_cli/lib/src/report_generator.dart +++ /dev/null @@ -1,308 +0,0 @@ -import 'dart:convert'; - -import 'package:path/path.dart' as p; - -import 'domain.dart'; -import 'priority.dart'; -import 'source_workspace.dart'; -import 'static_issue.dart'; - -class _Ansi { - static const reset = '\x1b[0m'; - static const red = '\x1b[31m'; - static const green = '\x1b[32m'; - static const yellow = '\x1b[33m'; - static const gray = '\x1b[90m'; - static const bold = '\x1b[1m'; - static const dim = '\x1b[2m'; - static const orange = '\x1b[38;5;208m'; -} - -const _domainLabels = { - IssueDomain.architecture: '架构违规', - IssueDomain.performance: '性能问题', - IssueDomain.standards: '代码规范', -}; - -const _domainAnsi = { - IssueDomain.architecture: _Ansi.red, - IssueDomain.performance: _Ansi.yellow, - IssueDomain.standards: _Ansi.gray, -}; - -const _levelLabel = { - RiskLevel.high: 'HIGH', - RiskLevel.medium: 'MED ', - RiskLevel.low: 'LOW ', -}; - -const _levelAnsi = { - RiskLevel.high: _Ansi.red, - RiskLevel.medium: _Ansi.yellow, - RiskLevel.low: _Ansi.gray, -}; - -const _priorityLabel = { - Priority.p0: 'P0 优先', - Priority.p1: 'P1 关注', - Priority.p2: 'P2 可选', -}; - -const _priorityAnsi = { - Priority.p0: _Ansi.red, - Priority.p1: _Ansi.yellow, - Priority.p2: _Ansi.gray, -}; - -class ReportGenerator { - static String generateJson({ - required String projectPath, - required List issues, - String scanMode = 'full', - int suppressedCount = 0, - int suppressedByBaselineCount = 0, - List diagnostics = const [], - }) { - final byDomain = _buildSummaryByDomain(issues); - final score = calculateScore(issues); - - final payload = { - 'version': '1.0.0', - 'generatedAt': DateTime.now().toIso8601String(), - 'projectPath': projectPath, - 'scanMode': scanMode, - 'score': score, - 'summary': { - 'total': issues.length, - 'high': issues.where((i) => i.level == RiskLevel.high).length, - 'medium': issues.where((i) => i.level == RiskLevel.medium).length, - 'low': issues.where((i) => i.level == RiskLevel.low).length, - 'suppressed': suppressedCount, - 'suppressedByBaseline': suppressedByBaselineCount, - 'diagnostics': diagnostics.length, - 'byDomain': byDomain, - }, - 'issues': issues.map((i) => i.toJson()).toList(), - 'diagnostics': - diagnostics.map((diagnostic) => diagnostic.toJson()).toList(), - }; - - return const JsonEncoder.withIndent(' ').convert(payload); - } - - static String generateStdout({ - required String projectPath, - required List issues, - int? scannedFileCount, - bool verbose = false, - bool noColor = false, - }) { - final buf = StringBuffer(); - final score = calculateScore(issues); - final projectName = p.basename(projectPath); - - _writeHeader( - buf, - projectName, - score, - issues, - scannedFileCount ?? issues.map((i) => i.file).toSet().length, - noColor: noColor, - ); - - if (issues.isEmpty) { - final msg = noColor - ? '未发现问题,代码质量良好。' - : '${_Ansi.green}未发现问题,代码质量良好。${_Ansi.reset}'; - buf.writeln(' $msg'); - return buf.toString(); - } - - _writeDomainSummaryBar(buf, issues, noColor: noColor); - - for (final domain in IssueDomain.values) { - final domainIssues = issues.where((i) => i.domain == domain).toList(); - if (domainIssues.isEmpty) continue; - _writeDomainSection( - buf, - projectPath, - domain, - domainIssues, - verbose, - noColor: noColor, - ); - } - - return buf.toString(); - } - - static void _writeHeader( - StringBuffer buf, - String projectName, - int score, - List issues, - int scannedFileCount, { - bool noColor = false, - }) { - final scoreAnsi = noColor ? '' : _scoreAnsi(score); - final reset = noColor ? '' : _Ansi.reset; - final gray = noColor ? '' : _Ansi.gray; - final bold = noColor ? '' : _Ansi.bold; - final scoreLabel = score >= 80 - ? '优秀' - : score >= 50 - ? '需关注' - : '需整改'; - buf.writeln( - ' ${bold}FlutterGuard Report$reset $gray─$reset $projectName'); - buf.writeln( - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - buf.write(' 总评分: $scoreAnsi$score/100$reset $scoreAnsi$scoreLabel$reset'); - buf.writeln( - ' $bold扫描文件: $scannedFileCount$reset 问题总数: $bold${issues.length}$reset'); - buf.writeln( - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - buf.writeln(); - } - - static void _writeDomainSummaryBar( - StringBuffer buf, - List issues, { - bool noColor = false, - }) { - final reset = noColor ? '' : _Ansi.reset; - final gray = noColor ? '' : _Ansi.gray; - for (final domain in IssueDomain.values) { - final domainIssues = issues.where((i) => i.domain == domain).toList(); - if (domainIssues.isEmpty) continue; - - final count = domainIssues.length; - final maxLevel = domainIssues - .map((i) => i.level) - .reduce((a, b) => a.index > b.index ? a : b); - final levelLabel = _levelLabel[maxLevel]!; - final domainAnsi = noColor ? '' : _domainAnsi[domain]!; - final levelAnsi = noColor ? '' : _levelAnsi[maxLevel]!; - - buf.write(' $domainAnsi${_domainLabels[domain]}$reset'); - buf.write(' $count items '); - buf.write('$gray▰$reset '); - buf.writeln('$levelAnsi$levelLabel$reset'); - } - buf.writeln( - '────────────────────────────────────────────────────────────────────────────────────'); - buf.writeln(); - } - - static void _writeDomainSection( - StringBuffer buf, - String projectPath, - IssueDomain domain, - List issues, - bool verbose, { - bool noColor = false, - }) { - final sorted = [...issues]..sort((a, b) { - final order = { - RiskLevel.high: 0, - RiskLevel.medium: 1, - RiskLevel.low: 2, - }; - return order[a.level]!.compareTo(order[b.level]!); - }); - - final reset = noColor ? '' : _Ansi.reset; - final gray = noColor ? '' : _Ansi.gray; - final bold = noColor ? '' : _Ansi.bold; - final dim = noColor ? '' : _Ansi.dim; - final green = noColor ? '' : _Ansi.green; - - for (final issue in sorted) { - final lvlAnsi = noColor ? '' : _levelAnsi[issue.level]!; - final lvlLabel = _levelLabel[issue.level]!; - final priAnsi = noColor ? '' : _priorityAnsi[issue.priority]!; - final priLabel = _priorityLabel[issue.priority]!; - final displayPath = _displayPath(issue.file, projectPath); - final lineInfo = issue.line != null ? ':${issue.line}' : ''; - - buf.writeln( - ' $lvlAnsi$lvlLabel$reset $priAnsi$priLabel$reset ' - '${issue.framework.name}/${issue.confidence.name}', - ); - 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'); - } - } - if (verbose && issue.evidence.isNotEmpty) { - for (final evidence in issue.evidence.take(5)) { - buf.writeln(' $dim证据: $evidence$reset'); - } - } - buf.writeln(' 修复: $green${issue.suggestion}$reset'); - buf.writeln(); - } - buf.writeln( - ' ────────────────────────────────────────────────────────────────────────────────'); - buf.writeln(); - } - - static String _displayPath(String filePath, String projectPath) { - if (p.isWithin(projectPath, filePath) || p.equals(projectPath, filePath)) { - return p.relative(filePath, from: projectPath); - } - return filePath; - } - - static Map> _buildSummaryByDomain( - List issues) { - final byDomain = >{}; - for (final domain in IssueDomain.values) { - final domainIssues = issues.where((i) => i.domain == domain).toList(); - if (domainIssues.isEmpty) continue; - byDomain[domain.name] = { - 'high': domainIssues.where((i) => i.level == RiskLevel.high).length, - 'medium': domainIssues.where((i) => i.level == RiskLevel.medium).length, - 'low': domainIssues.where((i) => i.level == RiskLevel.low).length, - 'total': domainIssues.length, - }; - } - return byDomain; - } - - static int calculateScore(List issues) { - final high = issues.where((i) => i.level == RiskLevel.high).length; - final medium = issues.where((i) => i.level == RiskLevel.medium).length; - final low = issues.where((i) => i.level == RiskLevel.low).length; - final score = 100 - high * 10 - medium * 4 - low * 1; - return score.clamp(0, 100); - } - - static bool shouldFail(List issues, String failOn) { - final high = issues.where((i) => i.level == RiskLevel.high).length; - final medium = issues.where((i) => i.level == RiskLevel.medium).length; - final low = issues.where((i) => i.level == RiskLevel.low).length; - - switch (failOn) { - case 'high': - return high > 0; - case 'medium': - return high > 0 || medium > 0; - case 'low': - return high > 0 || medium > 0 || low > 0; - default: - return false; - } - } - - static String _scoreAnsi(int score) { - if (score >= 80) return _Ansi.green; - if (score >= 50) return _Ansi.orange; - return _Ansi.red; - } -} diff --git a/packages/flutterguard_cli/lib/src/rule_meta.dart b/packages/flutterguard_cli/lib/src/rule_meta.dart deleted file mode 100644 index cf89beb..0000000 --- a/packages/flutterguard_cli/lib/src/rule_meta.dart +++ /dev/null @@ -1,47 +0,0 @@ -class RuleMeta { - final String id; - final String name; - final String domain; - final String riskLevel; - final String priority; - final String purpose; - final String riskReason; - final String badExample; - final String fixSuggestion; - final List configKeys; - final bool cicdSafe; - final String framework; - final String confidence; - - 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, - this.framework = 'generic', - this.confidence = 'certain', - }); - - Map toJson() => { - 'id': id, - 'name': name, - 'domain': domain, - 'riskLevel': riskLevel, - 'priority': priority, - 'purpose': purpose, - 'riskReason': riskReason, - 'badExample': badExample, - 'fixSuggestion': fixSuggestion, - 'configKeys': configKeys, - 'cicdSafe': cicdSafe, - 'framework': framework, - 'confidence': confidence, - }; -} diff --git a/packages/flutterguard_cli/lib/src/rules/AGENTS.md b/packages/flutterguard_cli/lib/src/rules/AGENTS.md deleted file mode 100644 index 40fa7c1..0000000 --- a/packages/flutterguard_cli/lib/src/rules/AGENTS.md +++ /dev/null @@ -1,42 +0,0 @@ -# Rule Layer - -## Responsibility -Each file implements one rule family and returns `List`. - -## Existing Rule Families -- `large_units.dart`: `large_file`, `large_class`, `large_build_method` -- `lifecycle_resource.dart`: undisposed controllers, streams, timers, MQTT/BLE resources -- `layer_violation.dart`: configured layer dependency breaches -- `module_violation.dart`: configured module dependency breaches -- `circular_dependency.dart`: file-level import cycles -- `missing_const_constructor.dart`: widget classes missing const constructors -- `device_lifecycle.dart`: balanced init/teardown pairs (initState↔dispose, connect↔disconnect, startScan↔stopScan, etc.) -- `mqtt_connection.dart`: MQTT connect/disconnect pairing, subscribe/unsubscribe, hardcoded broker URLs -- `ble_scanning.dart`: BLE startScan/stopScan pairing, connect/disconnect, scan timeout configuration -- `iot_security.dart`: hardcoded credentials, cleartext MQTT (port 1883), cleartext HTTP, insecure BLE -- `pubspec_security.dart`: unbounded deps, deprecated packages (flutter_blue→flutter_blue_plus), outdated IoT dependencies -- `generic_state_management.dart`: build side effects/creation, mutable state, and state-to-UI dependencies -- `state_dependency_cycle.dart`: project-wide provider/state/service SCC detection -- `riverpod_state_management.dart`: render-time read and callback watch checks -- `bloc_state_management.dart`: Equatable props completeness -- `provider_state_management.dart`: Provider ownership and loop notification checks -- `state_management_utils.dart`: shared AST/import/owner/build/callback helpers - -## Rule Contract -- Constructor receives typed config or explicit parameters. -- Public API is `analyze(List files, {SourceWorkspace? workspace})` for source rules; direct calls may omit the workspace. -- Scanner execution and metadata are explicitly wired in `catalog.dart`. -- Return no issues when disabled or not configured. -- Catch per-file parse/read failures so one bad file does not abort the full scan. -- Use `StaticIssue` with domain, priority, suggestion, and metadata. -- Use line numbers, not analyzer character offsets. -- IoT rules use string/pattern matching on file contents (consistent with lifecycle_resource approach). - -## New Rule Checklist -1. Add spec entry in `docs/FLUTTERGUARD_SPEC.md`. -2. Add typed config in `config_loader.dart` if configurable. -3. Implement the rule class here. -4. Add fixture(s) under `test/fixtures/`. -5. Add tests in `test/scanner_test.dart`. -6. Wire the rule and metadata in `catalog.dart`. -7. Export through `lib/flutterguard_cli.dart` if needed. diff --git a/packages/flutterguard_cli/lib/src/rules/ble_scanning.dart b/packages/flutterguard_cli/lib/src/rules/ble_scanning.dart deleted file mode 100644 index 47ce38d..0000000 --- a/packages/flutterguard_cli/lib/src/rules/ble_scanning.dart +++ /dev/null @@ -1,169 +0,0 @@ -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/source/line_info.dart'; - -import '../config_loader.dart'; -import '../domain.dart'; -import '../priority.dart'; -import '../rule_meta.dart'; -import '../source_utils.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; - -const _bleTypePatterns = ['Ble', 'Ble', 'BluetoothDevice', 'Bluetooth']; - -class BleScanningRule { - final BleScanningRuleConfig config; - - const BleScanningRule(this.config); - - List analyze( - List files, { - SourceWorkspace? workspace, - }) { - if (!config.enabled) return []; - - final issues = []; - final sources = workspace ?? SourceWorkspace(); - - for (final file in files) { - final source = sources.source(file); - if (source == null) continue; - issues.addAll( - _checkFile(file, source.content, source.unit, source.lineInfo), - ); - } - - return issues; - } - - List _checkFile( - String file, - String rawContent, - CompilationUnit unit, - LineInfo lineInfo, - ) { - final issues = []; - - for (final cls in unit.declarations.whereType()) { - final hasBleField = cls.members.whereType().where((f) { - final type = f.fields.type?.toString() ?? ''; - return _bleTypePatterns.any((t) => type.contains(t)); - }).isNotEmpty; - - final hasBleRef = cls.members.whereType().any((m) { - final body = m.toString().toLowerCase(); - return _bleTypePatterns.any((t) => body.contains(t.toLowerCase())); - }); - - if (!hasBleField && !hasBleRef) continue; - - final methods = cls.members.whereType().toList(); - final methodNames = methods.map((m) => m.name.lexeme).toSet(); - - if (methodNames.contains('startScan') && - !methodNames.contains('stopScan')) { - final startScanMethod = - methods.firstWhere((m) => m.name.lexeme == 'startScan'); - final line = lineNumberForOffset(lineInfo, startScanMethod.name.offset); - issues.add(StaticIssue( - id: 'ble_scanning', - title: 'BLE 扫描未停止', - file: file, - line: line, - level: RiskLevel.medium, - domain: IssueDomain.architecture, - priority: Priority.p1, - message: '类 "${cls.name.lexeme}" 中有 startScan() 调用但缺少 stopScan()', - detail: '类: ${cls.name.lexeme}\n' - 'BLE 扫描应在不需要时停止以节省电量', - suggestion: '在类中添加 stopScan() 方法并在 dispose 中调用', - metadata: { - 'className': cls.name.lexeme, - 'check': 'startScan_without_stopScan', - }, - )); - } - - if (methodNames.contains('connect') && - !methodNames.contains('disconnect')) { - final connectMethod = - methods.firstWhere((m) => m.name.lexeme == 'connect'); - final line = lineNumberForOffset(lineInfo, connectMethod.name.offset); - issues.add(StaticIssue( - id: 'ble_scanning', - title: 'BLE 连接未断开', - file: file, - line: line, - level: RiskLevel.medium, - domain: IssueDomain.architecture, - priority: Priority.p1, - message: '类 "${cls.name.lexeme}" 中有 BLE connect() 调用但缺少 disconnect()', - detail: '类: ${cls.name.lexeme}\n' - 'BLE 连接应在不需要时断开以节省电量', - suggestion: '在类中添加 disconnect() 方法并在 dispose 中调用', - metadata: { - 'className': cls.name.lexeme, - 'check': 'ble_connect_without_disconnect', - }, - )); - } - - _checkScanTimeout(file, - startScanMethod: methods, lineInfo: lineInfo, issues: issues); - } - - return issues; - } - - void _checkScanTimeout( - String file, { - required List startScanMethod, - required LineInfo lineInfo, - required List issues, - }) { - for (final method in startScanMethod) { - if (method.name.lexeme != 'startScan') continue; - - final body = method.toString().toLowerCase(); - final hasTimeout = body.contains('timeout') || - body.contains('duration') || - body.contains('maxscanduration'); - - if (!hasTimeout) { - final line = lineNumberForOffset(lineInfo, method.name.offset); - issues.add(StaticIssue( - id: 'ble_scanning', - title: 'BLE 扫描缺少超时配置', - file: file, - line: line, - level: RiskLevel.low, - domain: IssueDomain.architecture, - priority: Priority.p1, - message: 'startScan() 调用未配置超时参数', - detail: '方法: ${method.name.lexeme}\n' - 'BLE 扫描应设置超时以限制扫描时间,避免过度耗电', - suggestion: - '为 startScan() 添加超时参数 (推荐 < ${config.maxScanDurationMs}ms)', - metadata: { - 'check': 'scan_without_timeout', - 'maxScanDurationMs': config.maxScanDurationMs, - }, - )); - } - } - } - - static RuleMeta describe() => const RuleMeta( - id: 'ble_scanning', - name: 'BLE 扫描管理异常', - domain: 'architecture', - riskLevel: 'medium', - priority: 'p1', - purpose: '检测 BLE startScan/stopScan、connect/disconnect 配对及扫描超时配置', - riskReason: '未停止的 BLE 扫描持续消耗电量;缺少超时导致扫描无限进行', - badExample: 'startScan() 调用后无 stopScan() 和超时参数', - fixSuggestion: '在 dispose() 中添加 stopScan();为 startScan 添加超时参数', - configKeys: ['rules.ble_scanning.maxScanDurationMs'], - cicdSafe: true, - ); -} diff --git a/packages/flutterguard_cli/lib/src/rules/bloc_state_management.dart b/packages/flutterguard_cli/lib/src/rules/bloc_state_management.dart deleted file mode 100644 index c1f0d75..0000000 --- a/packages/flutterguard_cli/lib/src/rules/bloc_state_management.dart +++ /dev/null @@ -1,115 +0,0 @@ -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/dart/ast/visitor.dart'; - -import '../config_loader.dart'; -import '../domain.dart'; -import '../rule_meta.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; -import 'state_management_utils.dart'; - -class BlocEquatablePropsIncompleteRule { - final StateRuleConfig config; - final StateManagementConfig stateManagement; - final String projectPath; - - const BlocEquatablePropsIncompleteRule( - this.config, - this.stateManagement, { - required this.projectPath, - }); - - List analyze(List files, {SourceWorkspace? workspace}) { - if (!stateRuleEnabled(config, stateManagement)) return []; - final sources = workspace ?? SourceWorkspace(); - final issues = []; - for (final file in files) { - if (shouldIgnoreStateFile(file, projectPath, config)) continue; - final source = sources.source(file); - if (source == null) continue; - if (stateManagement.frameworkAutoDetect && - !hasEquatableImport(source.unit)) { - continue; - } - if (!frameworkAllowed( - source.unit, - StateManagementFramework.bloc, - stateManagement, - )) { - continue; - } - for (final cls - in source.unit.declarations.whereType()) { - if (!_inheritsEquatable(cls)) continue; - final fields = {}; - for (final field in cls.members.whereType()) { - if (field.isStatic || !field.fields.isFinal) continue; - for (final variable in field.fields.variables) { - final name = variable.name.lexeme; - if (config.allowlist.contains('${cls.name.lexeme}.$name')) continue; - fields.add(name); - } - } - if (fields.isEmpty) continue; - final props = cls.members.whereType().where( - (method) => method.isGetter && method.name.lexeme == 'props', - ); - final referenced = {}; - for (final getter in props) { - final visitor = _IdentifierCollector(); - getter.body.accept(visitor); - referenced.addAll(visitor.names); - } - final missing = fields.difference(referenced).toList()..sort(); - if (missing.isEmpty) continue; - issues.add(StaticIssue( - id: 'bloc_equatable_props_incomplete', - title: 'Equatable props 不完整', - file: file, - line: sourceLine(source, cls), - level: config.severity, - domain: IssueDomain.standards, - priority: priorityForSeverity(config.severity), - message: '${cls.name.lexeme}.props 缺少字段: ${missing.join(', ')}', - suggestion: '将所有参与值相等判断的 final instance 字段加入 props', - framework: StateManagementFramework.bloc, - evidence: missing.map((field) => 'missing: $field').take(5).toList(), - metadata: {'className': cls.name.lexeme, 'missingFields': missing}, - )); - } - } - return issues; - } - - static bool _inheritsEquatable(ClassDeclaration cls) { - final superclass = cls.extendsClause?.superclass.toSource() ?? ''; - final mixins = - cls.withClause?.mixinTypes.map((type) => type.toSource()).toList() ?? - const []; - return superclass == 'Equatable' || mixins.contains('EquatableMixin'); - } - - static RuleMeta describe() => const RuleMeta( - id: 'bloc_equatable_props_incomplete', - name: 'Equatable props 不完整', - domain: 'standards', - riskLevel: 'medium', - priority: 'p1', - purpose: '比较 Equatable 类的 final instance 字段与 props 字段引用', - riskReason: '遗漏字段会让不同状态被误判为相等,阻止 Bloc UI 更新', - badExample: 'final a; final b; List get props => [a];', - fixSuggestion: '把遗漏的值字段加入 props', - configKeys: ['enabled', 'severity', 'allowlist', 'ignore_paths'], - framework: 'bloc', - ); -} - -class _IdentifierCollector extends RecursiveAstVisitor { - final names = {}; - - @override - void visitSimpleIdentifier(SimpleIdentifier node) { - names.add(node.name); - super.visitSimpleIdentifier(node); - } -} diff --git a/packages/flutterguard_cli/lib/src/rules/catalog.dart b/packages/flutterguard_cli/lib/src/rules/catalog.dart deleted file mode 100644 index 9746249..0000000 --- a/packages/flutterguard_cli/lib/src/rules/catalog.dart +++ /dev/null @@ -1,262 +0,0 @@ -import 'package:path/path.dart' as p; - -import '../import_graph.dart'; -import '../path_utils.dart'; -import '../rule_meta.dart'; -import '../scan_context.dart'; -import '../static_issue.dart'; -import 'ble_scanning.dart'; -import 'bloc_state_management.dart'; -import 'circular_dependency.dart'; -import 'device_lifecycle.dart'; -import 'iot_security.dart'; -import 'generic_state_management.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'; -import 'provider_state_management.dart'; -import 'riverpod_state_management.dart'; -import 'state_dependency_cycle.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, - ); - }, - ), - RuleRegistration( - metadata: [SideEffectInBuildRule.describe()], - execute: (context, _) => SideEffectInBuildRule( - context.config.rules.sideEffectInBuild, - context.config.stateManagement, - projectPath: context.projectPath, - ).analyze(context.targetFiles, workspace: context.sources), - ), - RuleRegistration( - metadata: [StateManagerCreatedInBuildRule.describe()], - execute: (context, _) => StateManagerCreatedInBuildRule( - context.config.rules.stateManagerCreatedInBuild, - context.config.stateManagement, - projectPath: context.projectPath, - ).analyze(context.targetFiles, workspace: context.sources), - ), - RuleRegistration( - metadata: [MutableStateExposedRule.describe()], - execute: (context, _) => MutableStateExposedRule( - context.config.rules.mutableStateExposed, - context.config.stateManagement, - projectPath: context.projectPath, - ).analyze(context.targetFiles, workspace: context.sources), - ), - RuleRegistration( - metadata: [StateLayerUiDependencyRule.describe()], - execute: (context, _) => StateLayerUiDependencyRule( - context.config.rules.stateLayerUiDependency, - context.config.stateManagement, - projectPath: context.projectPath, - ).analyze(context.targetFiles, workspace: context.sources), - ), - RuleRegistration( - metadata: [StateDependencyCycleRule.describe()], - execute: (context, _) => StateDependencyCycleRule( - context.config.rules.stateDependencyCycle, - context.config.stateManagement, - projectPath: context.projectPath, - ).analyze( - context.allFiles, - targetFiles: context.targetFiles, - changedOnly: context.isChanged, - workspace: context.sources, - ), - ), - RuleRegistration( - metadata: [RiverpodReadUsedForRenderRule.describe()], - execute: (context, _) => RiverpodReadUsedForRenderRule( - context.config.rules.riverpodReadUsedForRender, - context.config.stateManagement, - projectPath: context.projectPath, - ).analyze(context.targetFiles, workspace: context.sources), - ), - RuleRegistration( - metadata: [RiverpodWatchInCallbackRule.describe()], - execute: (context, _) => RiverpodWatchInCallbackRule( - context.config.rules.riverpodWatchInCallback, - context.config.stateManagement, - projectPath: context.projectPath, - ).analyze(context.targetFiles, workspace: context.sources), - ), - RuleRegistration( - metadata: [BlocEquatablePropsIncompleteRule.describe()], - execute: (context, _) => BlocEquatablePropsIncompleteRule( - context.config.rules.blocEquatablePropsIncomplete, - context.config.stateManagement, - projectPath: context.projectPath, - ).analyze(context.targetFiles, workspace: context.sources), - ), - RuleRegistration( - metadata: [ProviderValueLifecycleMisuseRule.describe()], - execute: (context, _) => ProviderValueLifecycleMisuseRule( - context.config.rules.providerValueLifecycleMisuse, - context.config.stateManagement, - projectPath: context.projectPath, - ).analyze(context.targetFiles, workspace: context.sources), - ), - RuleRegistration( - metadata: [NotifyListenersInLoopRule.describe()], - execute: (context, _) => NotifyListenersInLoopRule( - context.config.rules.notifyListenersInLoop, - context.config.stateManagement, - projectPath: context.projectPath, - ).analyze(context.targetFiles, 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 deleted file mode 100644 index 09e7725..0000000 --- a/packages/flutterguard_cli/lib/src/rules/circular_dependency.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:path/path.dart' as p; - -import '../domain.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 } - -class CircularDependencyRule { - final bool enabled; - final String? projectPath; - - const CircularDependencyRule({this.enabled = true, this.projectPath}); - - List analyze( - List files, { - SourceWorkspace? workspace, - ImportGraph? importGraph, - }) { - if (!enabled || files.length < 2) return []; - - 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), - }; - return _findCycles(graph); - } - - List _findCycles( - Map> graph, - ) { - final issues = []; - final color = {}; - final parent = {}; - - _Color getColor(String node) => color[node] ?? _Color.white; - - void dfs(String node) { - color[node] = _Color.gray; - - for (final neighbor in graph[node] ?? {}) { - if (!graph.containsKey(neighbor)) continue; - - if (getColor(neighbor) == _Color.gray) { - final cycle = _reconstructCycle(node, neighbor, parent, graph); - if (cycle.length >= 2) { - issues.add(_buildCycleIssue(cycle)); - } - } else if (getColor(neighbor) == _Color.white) { - parent[neighbor] = node; - dfs(neighbor); - } - } - - color[node] = _Color.black; - } - - for (final node in graph.keys) { - if (getColor(node) == _Color.white) { - parent[node] = null; - dfs(node); - } - } - - return issues; - } - - List _reconstructCycle( - String start, - String end, - Map parent, - Map> graph, - ) { - final cycle = [start]; - var current = start; - - while (current != end) { - final prev = parent[current]; - if (prev == null || prev == current) break; - cycle.add(prev); - current = prev; - } - cycle.add(end); - - return cycle.reversed.toList(); - } - - StaticIssue _buildCycleIssue(List cycle) { - final cycleStr = cycle.map((f) => p.basename(f)).join(' → '); - - return StaticIssue( - id: 'circular_dependency', - title: '循环依赖', - file: cycle.first, - line: null, - level: RiskLevel.medium, - domain: IssueDomain.architecture, - priority: Priority.p1, - message: '检测到循环依赖: $cycleStr', - detail: '依赖链:\n${cycle.map((f) => ' $f').join('\n')}', - suggestion: '将循环中公共的依赖提取到 core/ 层,或使用依赖反转(接口抽象)打破循环', - metadata: { - 'cycle': cycle, - }, - ); - } - - static RuleMeta describe() => const RuleMeta( - id: 'circular_dependency', - name: '循环依赖', - domain: 'architecture', - riskLevel: 'medium', - priority: 'p1', - purpose: '检测文件级别的循环 import', - riskReason: '循环依赖导致编译耦合、无法单独测试和复用', - badExample: 'a.dart → b.dart → c.dart → a.dart', - fixSuggestion: '将公共依赖提取到 core 层,或使用接口反转打破循环', - cicdSafe: false, - ); -} diff --git a/packages/flutterguard_cli/lib/src/rules/device_lifecycle.dart b/packages/flutterguard_cli/lib/src/rules/device_lifecycle.dart deleted file mode 100644 index c48cab5..0000000 --- a/packages/flutterguard_cli/lib/src/rules/device_lifecycle.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/source/line_info.dart'; - -import '../config_loader.dart'; -import '../domain.dart'; -import '../priority.dart'; -import '../rule_meta.dart'; -import '../source_utils.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; - -const _lifecyclePairs = { - 'initState': 'dispose', - 'connect': 'disconnect', - 'startScan': 'stopScan', - 'start': 'stop', - 'listen': 'cancel', - 'subscribe': 'unsubscribe', -}; - -class DeviceLifecycleRule { - final DeviceLifecycleRuleConfig config; - - const DeviceLifecycleRule(this.config); - - List analyze( - List files, { - SourceWorkspace? workspace, - }) { - if (!config.enabled) return []; - - final issues = []; - final sources = workspace ?? SourceWorkspace(); - - for (final file in files) { - final source = sources.source(file); - if (source == null) continue; - issues.addAll(_checkFile(file, source.unit, source.lineInfo)); - } - - return issues; - } - - List _checkFile( - String file, - CompilationUnit unit, - LineInfo lineInfo, - ) { - final issues = []; - - for (final cls in unit.declarations.whereType()) { - final methods = cls.members.whereType().toList(); - final methodNames = methods.map((m) => m.name.lexeme).toSet(); - - for (final initMethod in _lifecyclePairs.keys) { - if (!methodNames.contains(initMethod)) continue; - - final teardownMethod = _lifecyclePairs[initMethod]!; - if (!methodNames.contains(teardownMethod)) { - final initDecl = - methods.firstWhere((m) => m.name.lexeme == initMethod); - final line = lineNumberForOffset(lineInfo, initDecl.name.offset); - - issues.add(StaticIssue( - id: 'device_lifecycle', - title: '设备生命周期不完整', - file: file, - line: line, - level: RiskLevel.high, - domain: IssueDomain.architecture, - priority: Priority.p0, - message: - '类 "${cls.name.lexeme}" 中存在 "$initMethod" 但缺少对应的 "$teardownMethod"', - detail: '类: ${cls.name.lexeme}\n' - '存在方法: $initMethod\n' - '缺少方法: $teardownMethod\n' - '设备生命周期方法应成对出现 (init/teardown)', - suggestion: '在类 "${cls.name.lexeme}" 中添加 "$teardownMethod" 方法', - metadata: { - 'className': cls.name.lexeme, - 'initMethod': initMethod, - 'teardownMethod': teardownMethod, - }, - )); - } - } - } - - return issues; - } - - static RuleMeta describe() => const RuleMeta( - id: 'device_lifecycle', - name: '设备生命周期不匹配', - domain: 'architecture', - riskLevel: 'high', - priority: 'p0', - purpose: '检测 initState/connect 等初始化方法是否有对应的 dispose/disconnect 销毁方法', - riskReason: '不匹配的生命周期导致资源泄漏和设备连接未正常关闭', - badExample: 'connect() 调用存在但 disconnect() 不存在', - fixSuggestion: '确保每个初始化方法有对应的销毁方法', - cicdSafe: true, - ); -} diff --git a/packages/flutterguard_cli/lib/src/rules/generic_state_management.dart b/packages/flutterguard_cli/lib/src/rules/generic_state_management.dart deleted file mode 100644 index 36c15e8..0000000 --- a/packages/flutterguard_cli/lib/src/rules/generic_state_management.dart +++ /dev/null @@ -1,506 +0,0 @@ -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/dart/ast/visitor.dart'; - -import '../config_loader.dart'; -import '../domain.dart'; -import '../rule_meta.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; -import 'state_management_utils.dart'; - -class SideEffectInBuildRule { - final StateRuleConfig config; - final StateManagementConfig stateManagement; - final String projectPath; - - const SideEffectInBuildRule( - this.config, - this.stateManagement, { - required this.projectPath, - }); - - List analyze(List files, {SourceWorkspace? workspace}) { - if (!stateRuleEnabled(config, stateManagement)) return []; - final sources = workspace ?? SourceWorkspace(); - final issues = []; - for (final file in files) { - if (shouldIgnoreStateFile(file, projectPath, config)) continue; - final source = sources.source(file); - if (source == null) continue; - for (final root in buildRoots(source.unit)) { - final visitor = _SideEffectVisitor(config.allowlist); - root.body.accept(visitor); - final evidence = limitedEvidence(visitor.evidence); - if (evidence.isEmpty) continue; - issues.add(StaticIssue( - id: 'side_effect_in_build', - title: 'build 中执行副作用', - file: file, - line: sourceLine(source, root.anchor), - level: config.severity, - domain: IssueDomain.performance, - priority: priorityForSeverity(config.severity), - message: '${root.label} 在渲染阶段执行了状态或资源副作用', - detail: 'build 必须保持幂等;框架可能在一次界面更新中重复调用它。', - suggestion: '将副作用移至生命周期、listener 或用户事件回调中', - evidence: evidence, - metadata: {'buildRoot': root.label}, - )); - } - } - return issues; - } - - static RuleMeta describe() => const RuleMeta( - id: 'side_effect_in_build', - name: 'build 中的副作用', - domain: 'performance', - riskLevel: 'high', - priority: 'p0', - purpose: '检测 Widget build 与 Consumer builder 中直接执行的状态和资源副作用', - riskReason: 'build 可被重复调用,副作用会造成重复连接、通知或状态更新', - badExample: 'Widget build(...) { ref.read(x.notifier).refresh(); }', - fixSuggestion: '把副作用移到生命周期、listener 或事件回调', - configKeys: ['enabled', 'severity', 'allowlist', 'ignore_paths'], - ); -} - -class _SideEffectVisitor extends RecursiveAstVisitor { - final List allowlist; - final evidence = []; - - _SideEffectVisitor(this.allowlist); - - @override - void visitFunctionExpression(FunctionExpression node) { - // A nested closure is an event/listener/timer boundary. Consumer builder - // closures are scanned separately as build roots. - } - - @override - void visitInstanceCreationExpression(InstanceCreationExpression node) { - final type = simpleTypeName(node.constructorName.type.toSource()); - if (type == 'Timer' && !allowlist.contains(type)) { - evidence.add(compactEvidence(node)); - } - super.visitInstanceCreationExpression(node); - } - - @override - void visitMethodInvocation(MethodInvocation node) { - final name = node.methodName.name; - final target = node.target?.toSource() ?? ''; - const directEffects = { - 'notifyListeners', - 'emit', - 'setState', - 'connect', - 'disconnect', - 'startScan', - }; - final managerAdd = name == 'add' && - (target.toLowerCase().contains('bloc') || - target.toLowerCase().contains('cubit') || - target.toLowerCase().contains('notifier') || - target.contains('context.read') || - target.contains('ref.read')); - final notifierCommand = target.contains('ref.read') && - target.contains('.notifier') && - name != 'read'; - final timerPeriodic = target == 'Timer' && name == 'periodic'; - final timerCreation = target.isEmpty && name == 'Timer'; - if ((directEffects.contains(name) || - managerAdd || - notifierCommand || - timerPeriodic || - timerCreation) && - !allowlist.contains(name)) { - evidence.add(compactEvidence(node)); - } - super.visitMethodInvocation(node); - } -} - -class StateManagerCreatedInBuildRule { - final StateRuleConfig config; - final StateManagementConfig stateManagement; - final String projectPath; - - const StateManagerCreatedInBuildRule( - this.config, - this.stateManagement, { - required this.projectPath, - }); - - List analyze(List files, {SourceWorkspace? workspace}) { - if (!stateRuleEnabled(config, stateManagement)) return []; - final sources = workspace ?? SourceWorkspace(); - final issues = []; - for (final file in files) { - if (shouldIgnoreStateFile(file, projectPath, config)) continue; - final source = sources.source(file); - if (source == null) continue; - for (final root in buildRoots(source.unit)) { - final visitor = _StateManagerCreationVisitor(config.allowlist); - root.body.accept(visitor); - for (final creation in visitor.creations) { - final type = creation.type; - issues.add(StaticIssue( - id: 'state_manager_created_in_build', - title: 'build 中创建状态管理对象', - file: file, - line: sourceLine(source, creation.node), - level: config.severity, - domain: IssueDomain.performance, - priority: priorityForSeverity(config.severity), - message: '$type 在 ${root.label} 中被重复创建', - suggestion: '把对象交给 State 生命周期或 Provider/BlocProvider 的 create 管理', - evidence: [compactEvidence(creation.node)], - metadata: {'type': type, 'buildRoot': root.label}, - )); - } - } - } - return issues; - } - - static RuleMeta describe() => const RuleMeta( - id: 'state_manager_created_in_build', - name: 'build 中创建状态管理对象', - domain: 'performance', - riskLevel: 'high', - priority: 'p0', - purpose: '检测 build 中创建 Controller、Bloc、Cubit、Notifier 等持有型对象', - riskReason: '重复构建会重建对象并丢失状态或泄漏资源', - badExample: 'Widget build(...) { final c = DeviceController(); }', - fixSuggestion: '提升到生命周期字段或框架所有权 create 回调', - configKeys: ['enabled', 'severity', 'allowlist', 'ignore_paths'], - ); -} - -class _StateManagerCreationVisitor extends RecursiveAstVisitor { - final List allowlist; - final creations = <({AstNode node, String type})>[]; - - _StateManagerCreationVisitor(this.allowlist); - - static const flutterControllers = { - 'AnimationController', - 'TextEditingController', - 'ScrollController', - 'PageController', - 'TabController', - 'FocusNode', - }; - - @override - void visitFunctionExpression(FunctionExpression node) {} - - @override - void visitInstanceCreationExpression(InstanceCreationExpression node) { - final type = simpleTypeName(node.constructorName.type.toSource()); - _record(node, type); - super.visitInstanceCreationExpression(node); - } - - @override - void visitMethodInvocation(MethodInvocation node) { - if (node.target == null && - node.methodName.name.startsWith(RegExp('[A-Z]'))) { - _record(node, simpleTypeName(node.methodName.name)); - } - super.visitMethodInvocation(node); - } - - void _record(AstNode node, String type) { - final owned = stateOwnerSuffixes.any(type.endsWith) || - flutterControllers.contains(type); - if (owned && !allowlist.contains(type)) { - creations.add((node: node, type: type)); - } - } -} - -class MutableStateExposedRule { - final StateRuleConfig config; - final StateManagementConfig stateManagement; - final String projectPath; - - const MutableStateExposedRule( - this.config, - this.stateManagement, { - required this.projectPath, - }); - - List analyze(List files, {SourceWorkspace? workspace}) { - if (!stateRuleEnabled(config, stateManagement)) return []; - final sources = workspace ?? SourceWorkspace(); - final issues = []; - for (final file in files) { - if (shouldIgnoreStateFile(file, projectPath, config)) continue; - final source = sources.source(file); - if (source == null) continue; - for (final cls - in source.unit.declarations.whereType()) { - if (!isStateOwnerClass(cls)) continue; - issues.addAll(_inspectClass(file, source, cls)); - } - } - return issues; - } - - List _inspectClass( - String file, - SourceUnit source, - ClassDeclaration cls, - ) { - final findings = <({AstNode node, String key, String evidence})>[]; - final privateCollections = {}; - for (final field in cls.members.whereType()) { - if (field.isStatic) continue; - final type = typeName(field.fields.type); - for (final variable in field.fields.variables) { - final name = variable.name.lexeme; - final allowed = config.allowlist.contains('${cls.name.lexeme}.$name'); - if (allowed) continue; - if (!isPublicName(name) && isCollectionType(type)) { - privateCollections.add(name); - } - if (!isPublicName(name)) continue; - if (!field.fields.isFinal) { - findings.add(( - node: variable, - key: 'field:$name:non_final', - evidence: '$type $name is public and non-final', - )); - } else if (isCollectionType(type) && - !isUnmodifiableExpression(variable.initializer)) { - findings.add(( - node: variable, - key: 'field:$name:collection', - evidence: '$type $name exposes a mutable collection reference', - )); - } - } - } - - for (final method in cls.members.whereType()) { - final name = method.name.lexeme; - if (!method.isGetter || !isPublicName(name)) continue; - if (config.allowlist.contains('${cls.name.lexeme}.$name')) continue; - final returned = _returnedIdentifier(method.body); - if (returned != null && privateCollections.contains(returned)) { - findings.add(( - node: method, - key: 'getter:$name:$returned', - evidence: 'getter $name returns mutable $returned directly', - )); - } - } - - final mutationVisitor = _StateCollectionMutationVisitor(); - cls.accept(mutationVisitor); - for (final mutation in mutationVisitor.mutations) { - findings.add(( - node: mutation, - key: 'state_mutation:${mutation.offset}', - evidence: compactEvidence(mutation), - )); - } - - final seen = {}; - return [ - for (final finding in findings) - if (seen.add(finding.key)) - StaticIssue( - id: 'mutable_state_exposed', - title: '可变状态被公开', - file: file, - line: sourceLine(source, finding.node), - level: config.severity, - domain: IssueDomain.architecture, - priority: priorityForSeverity(config.severity), - message: '${cls.name.lexeme} 暴露或原地修改了可变状态', - suggestion: '公开不可变视图/副本,并用 copyWith 或新集合更新状态', - evidence: [finding.evidence], - metadata: {'className': cls.name.lexeme, 'rootCause': finding.key}, - ), - ]; - } - - String? _returnedIdentifier(FunctionBody body) { - if (body is ExpressionFunctionBody) { - final expression = body.expression; - if (expression is SimpleIdentifier) return expression.name; - } - if (body is BlockFunctionBody) { - for (final statement - in body.block.statements.whereType()) { - final expression = statement.expression; - if (expression is SimpleIdentifier) return expression.name; - } - } - return null; - } - - static RuleMeta describe() => const RuleMeta( - id: 'mutable_state_exposed', - name: '公开可变状态', - domain: 'architecture', - riskLevel: 'medium', - priority: 'p1', - purpose: '检测状态 owner 的公开可变字段、集合 getter 与原地 state 集合修改', - riskReason: '外部调用方可绕过状态通知并破坏单向数据流', - badExample: 'final List items = [];', - fixSuggestion: '返回不可变视图或副本,并替换整个状态值', - configKeys: ['enabled', 'severity', 'allowlist', 'ignore_paths'], - ); -} - -class _StateCollectionMutationVisitor extends RecursiveAstVisitor { - final mutations = []; - - @override - void visitMethodInvocation(MethodInvocation node) { - final target = node.target?.toSource() ?? ''; - const mutators = { - 'add', - 'addAll', - 'remove', - 'removeWhere', - 'clear', - 'sort' - }; - if ((target == 'state' || target.startsWith('state.')) && - mutators.contains(node.methodName.name)) { - mutations.add(node); - } - super.visitMethodInvocation(node); - } - - @override - void visitAssignmentExpression(AssignmentExpression node) { - final left = node.leftHandSide.toSource(); - if (left.startsWith('state[') || left.startsWith('state.')) { - mutations.add(node); - } - super.visitAssignmentExpression(node); - } -} - -class StateLayerUiDependencyRule { - final StateRuleConfig config; - final StateManagementConfig stateManagement; - final String projectPath; - - const StateLayerUiDependencyRule( - this.config, - this.stateManagement, { - required this.projectPath, - }); - - List analyze(List files, {SourceWorkspace? workspace}) { - if (!stateRuleEnabled(config, stateManagement)) return []; - final sources = workspace ?? SourceWorkspace(); - final issues = []; - for (final file in files) { - if (shouldIgnoreStateFile(file, projectPath, config)) continue; - final source = sources.source(file); - if (source == null) continue; - for (final cls - in source.unit.declarations.whereType()) { - if (!isStateOwnerClass(cls) || - config.allowlist.contains(cls.name.lexeme)) { - continue; - } - final visitor = _UiDependencyVisitor( - cls.name.lexeme, - config.allowlist, - ); - cls.accept(visitor); - final evidence = limitedEvidence(visitor.evidence); - if (evidence.isEmpty) continue; - issues.add(StaticIssue( - id: 'state_layer_ui_dependency', - title: '状态层依赖 UI API', - file: file, - line: sourceLine(source, cls), - level: config.severity, - domain: IssueDomain.architecture, - priority: priorityForSeverity(config.severity), - message: '${cls.name.lexeme} 直接依赖 Flutter UI 上下文或导航 API', - suggestion: '从状态层输出事件/数据,由 Widget 层执行导航、弹窗和主题访问', - evidence: evidence, - metadata: {'className': cls.name.lexeme}, - )); - } - } - return issues; - } - - static RuleMeta describe() => const RuleMeta( - id: 'state_layer_ui_dependency', - name: '状态层依赖 UI', - domain: 'architecture', - riskLevel: 'high', - priority: 'p0', - purpose: - '检测状态 owner 对 BuildContext、Widget、Navigator、Theme 等 UI API 的依赖', - riskReason: '状态逻辑与 UI 生命周期耦合,难以测试和复用', - badExample: - 'class DeviceController { void open(BuildContext context) { ... } }', - fixSuggestion: '输出状态或事件,并在 Widget 层处理 UI 行为', - configKeys: ['enabled', 'severity', 'allowlist', 'ignore_paths'], - ); -} - -class _UiDependencyVisitor extends RecursiveAstVisitor { - final String className; - final List allowlist; - final evidence = []; - - _UiDependencyVisitor(this.className, this.allowlist); - - static const uiTypes = {'BuildContext', 'Widget'}; - static const uiCalls = { - 'showDialog', - 'showModalBottomSheet', - 'maybePop', - 'push', - 'pushNamed', - 'of', - }; - - @override - void visitNamedType(NamedType node) { - final type = node.toSource().replaceAll('?', ''); - final simple = simpleTypeName(type); - final nestedTypeOnly = node.parent is TypeArgumentList; - if (!nestedTypeOnly && - uiTypes.contains(simple) && - !allowlist.contains('$className.$simple')) { - evidence.add('type dependency: $type'); - } - super.visitNamedType(node); - } - - @override - void visitMethodInvocation(MethodInvocation node) { - final target = node.target?.toSource() ?? ''; - final name = node.methodName.name; - final uiTarget = const { - 'Navigator', - 'ScaffoldMessenger', - 'MediaQuery', - 'Theme', - }.any((value) => target.contains(value)); - if ((uiTarget && uiCalls.contains(name)) || - name == 'showDialog' || - name == 'showModalBottomSheet') { - if (!allowlist.contains('$className.$name')) { - evidence.add(compactEvidence(node)); - } - } - super.visitMethodInvocation(node); - } -} diff --git a/packages/flutterguard_cli/lib/src/rules/iot_security.dart b/packages/flutterguard_cli/lib/src/rules/iot_security.dart deleted file mode 100644 index eca57a0..0000000 --- a/packages/flutterguard_cli/lib/src/rules/iot_security.dart +++ /dev/null @@ -1,190 +0,0 @@ -import '../config_loader.dart'; -import '../domain.dart'; -import '../priority.dart'; -import '../rule_meta.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; - -final _secretPattern = RegExp( - r"""(password|token|secret|api[_]?key)\s*[:=]\s*["']""", - caseSensitive: false, -); -const _cleartextMqttPatterns = ['tcp://', 'port: 1883', 'port:1883']; -const _insecureBleKeywords = ['withoutBonding', 'withoutPairing']; -final _httpUrlPattern = RegExp(r"""['"]http://[^'"]+['"]"""); - -class IotSecurityRule { - final IotSecurityRuleConfig config; - - const IotSecurityRule(this.config); - - List analyze( - List files, { - SourceWorkspace? workspace, - }) { - if (!config.enabled) return []; - - final issues = []; - final sources = workspace ?? SourceWorkspace(); - - for (final file in files) { - final source = sources.source(file); - if (source == null) continue; - issues.addAll(_checkFile(file, source.content)); - } - - return issues; - } - - List _checkFile(String file, String content) { - final issues = []; - final lines = content.split('\n'); - - _checkHardcodedSecrets(file, lines, issues); - - if (config.requireTls) { - _checkCleartextMqtt(file, lines, issues); - _checkCleartextHttp(file, lines, issues); - } - - _checkInsecureBle(file, lines, issues); - - return issues; - } - - void _checkHardcodedSecrets( - String file, - List lines, - List issues, - ) { - for (var i = 0; i < lines.length; i++) { - if (_secretPattern.hasMatch(lines[i])) { - issues.add(StaticIssue( - id: 'iot_security', - title: 'IoT 安全 — 硬编码凭证', - file: file, - line: i + 1, - level: RiskLevel.high, - domain: IssueDomain.architecture, - priority: Priority.p0, - message: '检测到可疑的硬编码凭证', - detail: '行 ${i + 1}: ${lines[i].trim()}\n' - '硬编码凭证可能导致安全泄露,应使用环境变量或安全存储', - suggestion: '使用环境变量或安全存储方案替代硬编码凭证', - metadata: { - 'securityCheck': 'hardcoded_secret', - 'line': i + 1, - }, - )); - } - } - } - - void _checkCleartextMqtt( - String file, - List lines, - List issues, - ) { - for (var i = 0; i < lines.length; i++) { - final lower = lines[i].toLowerCase(); - for (final pattern in _cleartextMqttPatterns) { - if (lower.contains(pattern)) { - issues.add(StaticIssue( - id: 'iot_security', - title: 'IoT 安全 — 明文 MQTT 连接', - file: file, - line: i + 1, - level: RiskLevel.high, - domain: IssueDomain.architecture, - priority: Priority.p0, - message: '检测到明文 MQTT 连接配置: "$pattern"', - detail: '行 ${i + 1}: ${lines[i].trim()}\n' - '明文 MQTT 连接不安全,应使用 mqtts:// (TLS) 或端口 8883', - suggestion: '将 MQTT 连接升级为 mqtts:// 并使用端口 8883', - metadata: { - 'securityCheck': 'cleartext_mqtt', - 'pattern': pattern, - 'line': i + 1, - }, - )); - } - } - } - } - - void _checkCleartextHttp( - String file, - List lines, - List issues, - ) { - for (var i = 0; i < lines.length; i++) { - for (final match in _httpUrlPattern.allMatches(lines[i])) { - final url = match.group(0) ?? ''; - if (url.contains('localhost') || url.contains('127.0.0.1')) continue; - - issues.add(StaticIssue( - id: 'iot_security', - title: 'IoT 安全 — 明文 HTTP 连接', - file: file, - line: i + 1, - level: RiskLevel.medium, - domain: IssueDomain.architecture, - priority: Priority.p0, - message: '检测到明文 HTTP URL: $url', - detail: '行 ${i + 1}: ${lines[i].trim()}\n明文 HTTP 不安全,应使用 HTTPS', - suggestion: '将 HTTP 连接升级为 HTTPS', - metadata: { - 'securityCheck': 'cleartext_http', - 'url': url, - }, - )); - } - } - } - - void _checkInsecureBle( - String file, - List lines, - List issues, - ) { - for (var i = 0; i < lines.length; i++) { - for (final keyword in _insecureBleKeywords) { - if (lines[i].contains(keyword)) { - issues.add(StaticIssue( - id: 'iot_security', - title: 'IoT 安全 — 不安全 BLE 配置', - file: file, - line: i + 1, - level: RiskLevel.medium, - domain: IssueDomain.architecture, - priority: Priority.p0, - message: '检测到不安全的 BLE 连接配置: "$keyword"', - detail: '行 ${i + 1}: ${lines[i].trim()}\n' - 'BLE 连接应启用配对和加密 (bond / pair)', - suggestion: '启用 BLE 配对和加密配置', - metadata: { - 'securityCheck': 'insecure_ble', - 'keyword': keyword, - 'line': i + 1, - }, - )); - } - } - } - } - - static RuleMeta describe() => const RuleMeta( - id: 'iot_security', - name: 'IoT 安全风险', - domain: 'architecture', - riskLevel: 'high', - priority: 'p0', - purpose: '检测硬编码凭据、明文 MQTT/HTTP、不安全的 BLE 配置', - riskReason: '硬编码凭据泄露导致设备被入侵;明文传输导致数据窃听', - badExample: - 'password: "123456";tcp://broker:1883;BLE 使用 withoutBonding', - fixSuggestion: '使用环境变量或安全存储管理凭据;使用 TLS 加密通信', - configKeys: ['rules.iot_security.requireTls'], - cicdSafe: true, - ); -} diff --git a/packages/flutterguard_cli/lib/src/rules/large_units.dart b/packages/flutterguard_cli/lib/src/rules/large_units.dart deleted file mode 100644 index fce94a0..0000000 --- a/packages/flutterguard_cli/lib/src/rules/large_units.dart +++ /dev/null @@ -1,195 +0,0 @@ -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:path/path.dart' as p; - -import '../config_loader.dart'; -import '../domain.dart'; -import '../priority.dart'; -import '../rule_meta.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; - -class LargeUnitsRule { - final LargeFileRuleConfig largeFileConfig; - final LargeClassRuleConfig largeClassConfig; - final LargeBuildMethodRuleConfig largeBuildMethodConfig; - - const LargeUnitsRule({ - required this.largeFileConfig, - required this.largeClassConfig, - required this.largeBuildMethodConfig, - }); - - List analyze( - List files, { - SourceWorkspace? workspace, - }) { - final issues = []; - final sources = workspace ?? SourceWorkspace(); - - for (final file in files) { - final source = sources.source(file); - if (source == null) continue; - if (largeFileConfig.enabled) { - issues.addAll(_checkLargeFile(file, source.lines.length)); - } - - if (largeClassConfig.enabled || largeBuildMethodConfig.enabled) { - if (largeClassConfig.enabled) { - issues.addAll(_checkLargeClass(file, source.content, source.unit)); - } - if (largeBuildMethodConfig.enabled) { - issues.addAll(_checkLargeBuild(file, source.content, source.unit)); - } - } - } - - return issues; - } - - List _checkLargeFile(String file, int lineCount) { - if (lineCount > largeFileConfig.maxLines) { - return [ - StaticIssue( - id: 'large_file', - title: '文件过大', - file: file, - line: null, - level: RiskLevel.low, - domain: IssueDomain.standards, - priority: Priority.p2, - message: '文件 $lineCount 行(阈值: ${largeFileConfig.maxLines} 行)', - detail: '', - suggestion: '建议将 ${p.basename(file)} 拆分为更小的模块文件', - metadata: { - 'actual': lineCount, - 'threshold': largeFileConfig.maxLines, - }, - ), - ]; - } - return []; - } - - List _checkLargeClass( - String file, - String content, - CompilationUnit unit, - ) { - final issues = []; - final classes = unit.declarations.whereType(); - - for (final cls in classes) { - final startLine = content.substring(0, cls.offset).split('\n').length; - final endLine = content.substring(0, cls.end).split('\n').length; - final lineCount = endLine - startLine + 1; - - if (lineCount > largeClassConfig.maxLines) { - issues.add(StaticIssue( - id: 'large_class', - title: '类过大', - file: file, - line: startLine, - level: RiskLevel.low, - domain: IssueDomain.standards, - priority: Priority.p2, - message: - '类 "${cls.name.lexeme}" $lineCount 行(阈值: ${largeClassConfig.maxLines} 行)', - detail: '', - suggestion: '建议将 "${cls.name.lexeme}" 的职责提取到更小的类中', - metadata: { - 'actual': lineCount, - 'threshold': largeClassConfig.maxLines, - 'className': cls.name.lexeme, - }, - )); - } - } - return issues; - } - - List _checkLargeBuild( - String file, - String content, - CompilationUnit unit, - ) { - final issues = []; - final classes = unit.declarations.whereType(); - - for (final cls in classes) { - for (final member in cls.members) { - if (member is MethodDeclaration && - member.name.lexeme == 'build' && - member.returnType?.toString() == 'Widget') { - final startLine = - content.substring(0, member.offset).split('\n').length; - final endLine = content.substring(0, member.end).split('\n').length; - final lineCount = endLine - startLine + 1; - - if (lineCount > largeBuildMethodConfig.maxLines) { - issues.add(StaticIssue( - id: 'large_build_method', - title: '构建方法过长', - file: file, - line: startLine, - level: RiskLevel.medium, - domain: IssueDomain.performance, - priority: Priority.p1, - message: - '${cls.name.lexeme}.build() $lineCount 行(阈值: ${largeBuildMethodConfig.maxLines} 行)', - detail: '', - suggestion: '将 build 方法中的部分提取为更小的子组件或方法', - metadata: { - 'actual': lineCount, - 'threshold': largeBuildMethodConfig.maxLines, - 'className': cls.name.lexeme, - }, - )); - } - } - } - } - return issues; - } - - static RuleMeta describeLargeFile() => const RuleMeta( - id: 'large_file', - name: '文件过大', - domain: 'standards', - riskLevel: 'low', - priority: 'p2', - purpose: '检测单文件行数超过阈值', - riskReason: '文件过大增加认知负载,难以维护和审查', - badExample: '500+ 行的单文件包含多个不相关类', - fixSuggestion: '将逻辑独立的类或函数提取到单独文件', - configKeys: ['rules.large_file.maxLines'], - cicdSafe: true, - ); - - static RuleMeta describeLargeClass() => const RuleMeta( - id: 'large_class', - name: '类过大', - domain: 'standards', - riskLevel: 'low', - priority: 'p2', - purpose: '检测类声明行数超过阈值', - riskReason: '大类违反单一职责原则,降低可测试性', - badExample: '300+ 行的类处理多种不相关逻辑', - fixSuggestion: '将类按职责拆分为多个更小的类', - configKeys: ['rules.large_class.maxLines'], - cicdSafe: true, - ); - - static RuleMeta describeLargeBuildMethod() => const RuleMeta( - id: 'large_build_method', - name: '构建方法过长', - domain: 'performance', - riskLevel: 'medium', - priority: 'p1', - purpose: '检测 Widget build 方法行数超过阈值', - riskReason: '过长的 build 方法难以维护,且可能包含重复渲染逻辑', - badExample: '80+ 行的 build 方法包含内联样式和嵌套 widget', - fixSuggestion: '将 build 中的子 UI 提取为独立 widget 或方法', - configKeys: ['rules.large_build_method.maxLines'], - cicdSafe: true, - ); -} diff --git a/packages/flutterguard_cli/lib/src/rules/layer_violation.dart b/packages/flutterguard_cli/lib/src/rules/layer_violation.dart deleted file mode 100644 index 2f9109f..0000000 --- a/packages/flutterguard_cli/lib/src/rules/layer_violation.dart +++ /dev/null @@ -1,90 +0,0 @@ -import '../boundary_engine.dart'; -import '../config_loader.dart'; -import '../domain.dart'; -import '../import_graph.dart'; -import '../priority.dart'; -import '../rule_meta.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; - -class LayerViolationRule { - final List layers; - final String? projectPath; - - const LayerViolationRule(this.layers, {this.projectPath}); - - List analyze( - List files, { - List? allFiles, - SourceWorkspace? workspace, - ImportGraph? importGraph, - }) { - if (layers.isEmpty) return []; - - final sources = workspace ?? SourceWorkspace(); - final availableFiles = allFiles ?? files; - final graph = importGraph ?? - ImportGraph.build( - files: availableFiles, - sourceFiles: files, - workspace: sources, - projectPath: projectPath, - ); - final boundaries = [ - for (final layer in layers) - BoundaryDefinition( - name: layer.name, - path: layer.path, - allowedDeps: layer.allowedDeps, - ), - ]; - final violations = DependencyBoundaryEngine.analyze( - sourceFiles: files, - allFiles: availableFiles, - boundaries: boundaries, - graph: graph, - workspace: sources, - projectPath: projectPath, - ); - - return [ - for (final violation in violations) - StaticIssue( - id: 'layer_violation', - title: '层间依赖违规', - file: violation.edge.source, - line: violation.edge.line, - level: RiskLevel.high, - domain: IssueDomain.architecture, - priority: Priority.p0, - message: '${violation.source.name} 层不可依赖 ${violation.target.name} 层', - detail: '导入: ${violation.edge.uri}\n' - '源层: ${violation.source.name} (${violation.source.path})\n' - '目标层: ${violation.target.name} (${violation.target.path})\n' - '允许依赖: ${violation.source.allowedDeps.isEmpty ? '无' : violation.source.allowedDeps.join(', ')}', - suggestion: - '将导入的内容移至 ${violation.source.allowedDeps.isEmpty ? 'core 或更抽象层' : '${violation.source.allowedDeps.join(' 或 ')}层'}', - metadata: { - 'sourceLayer': violation.source.name, - 'targetLayer': violation.target.name, - 'imported': violation.edge.uri, - 'allowedDeps': violation.source.allowedDeps, - }, - ), - ]; - } - - static RuleMeta describe() => const RuleMeta( - id: 'layer_violation', - name: '层间依赖违规', - domain: 'architecture', - riskLevel: 'high', - priority: 'p0', - purpose: '检测架构层之间的非法依赖', - riskReason: '违反分层架构导致耦合增加,难以维护和替换', - badExample: 'presentation 层直接导入 data 层的实现', - fixSuggestion: '将跨层调用通过抽象接口进行,或调整 allowed_deps 配置', - configKeys: ['architecture.layers'], - cicdSafe: true, - ); -} diff --git a/packages/flutterguard_cli/lib/src/rules/lifecycle_resource.dart b/packages/flutterguard_cli/lib/src/rules/lifecycle_resource.dart deleted file mode 100644 index 62be681..0000000 --- a/packages/flutterguard_cli/lib/src/rules/lifecycle_resource.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/source/line_info.dart'; - -import '../config_loader.dart'; -import '../domain.dart'; -import '../priority.dart'; -import '../rule_meta.dart'; -import '../source_utils.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; - -const _resourceTypes = { - 'StreamSubscription': 'cancel', - 'Timer': 'cancel', - 'AnimationController': 'dispose', - 'TextEditingController': 'dispose', - 'ScrollController': 'dispose', - 'FocusNode': 'dispose', - 'MqttClient': 'disconnect', - 'BluetoothDevice': 'disconnect', - 'StreamController': 'close', -}; - -class LifecycleResourceRule { - final LifecycleResourceRuleConfig config; - - const LifecycleResourceRule(this.config); - - List analyze( - List files, { - SourceWorkspace? workspace, - }) { - if (!config.enabled) return []; - - final issues = []; - final sources = workspace ?? SourceWorkspace(); - - for (final file in files) { - final source = sources.source(file); - if (source == null) continue; - issues.addAll(_checkFile(file, source.unit, source.lineInfo)); - } - - return issues; - } - - List _checkFile( - String file, - CompilationUnit unit, - LineInfo lineInfo, - ) { - final issues = []; - final classes = unit.declarations.whereType(); - - for (final cls in classes) { - final fieldDeclarations = cls.members - .whereType() - .where((f) => !f.isStatic) - .toList(); - - if (fieldDeclarations.isEmpty) continue; - - final disposeMethod = cls.members - .whereType() - .where((m) => m.name.lexeme == 'dispose') - .firstOrNull; - - for (final field in fieldDeclarations) { - final type = field.fields.type; - if (type == null) continue; - - final typeStr = type.toString(); - for (final resourceType in _resourceTypes.keys) { - if (typeStr == resourceType || typeStr.endsWith('<$resourceType>')) { - final fieldName = field.fields.variables.first.name.lexeme; - final expectedCall = _resourceTypes[resourceType]!; - - bool isDisposed = false; - if (disposeMethod != null) { - final disposeBody = disposeMethod.toString(); - isDisposed = disposeBody.contains('$fieldName.$expectedCall'); - } - - if (!isDisposed) { - final line = lineNumberForOffset( - lineInfo, - field.fields.variables.first.name.offset, - ); - - issues.add(StaticIssue( - id: 'lifecycle_resource_not_disposed', - title: '资源未释放', - file: file, - line: line, - level: RiskLevel.medium, - domain: IssueDomain.performance, - priority: Priority.p1, - message: - '$resourceType 类型字段 "$fieldName" 在 "${cls.name.lexeme}" 中未在 dispose() 中释放', - detail: '字段: $fieldName ($resourceType)\n' - '类: ${cls.name.lexeme}\n' - '预期释放调用: $fieldName.$expectedCall()', - suggestion: '在 dispose() 方法中添加 "$fieldName.$expectedCall()" 调用', - metadata: { - 'className': cls.name.lexeme, - 'resourceType': resourceType, - 'fieldName': fieldName, - 'expectedDisposeCall': expectedCall, - }, - )); - } - } - } - } - } - - 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 deleted file mode 100644 index a126a0e..0000000 --- a/packages/flutterguard_cli/lib/src/rules/missing_const_constructor.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/source/line_info.dart'; - -import '../config_loader.dart'; -import '../domain.dart'; -import '../priority.dart'; -import '../rule_meta.dart'; -import '../source_utils.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; - -final _widgetTypes = {'StatelessWidget', 'StatefulWidget'}; - -class MissingConstConstructorRule { - final MissingConstConstructorRuleConfig config; - - const MissingConstConstructorRule(this.config); - - List analyze( - List files, { - SourceWorkspace? workspace, - }) { - if (!config.enabled) return []; - - final issues = []; - final sources = workspace ?? SourceWorkspace(); - - for (final file in files) { - final source = sources.source(file); - if (source == null) continue; - issues.addAll(_checkFile(file, source.unit, source.lineInfo)); - } - - return issues; - } - - List _checkFile( - String file, - CompilationUnit unit, - LineInfo lineInfo, - ) { - final issues = []; - final classes = unit.declarations.whereType(); - - for (final cls in classes) { - final extendsClause = cls.extendsClause; - if (extendsClause == null) continue; - - final superClassName = extendsClause.superclass.name2.lexeme; - if (!_widgetTypes.contains(superClassName)) { - continue; - } - - final hasConstConstructor = cls.members.any((m) { - if (m is ConstructorDeclaration) { - return m.constKeyword != null; - } - return false; - }); - - if (hasConstConstructor) continue; - - final line = lineNumberForOffset(lineInfo, cls.name.offset); - issues.add(StaticIssue( - id: 'missing_const_constructor', - title: 'Widget 缺少 const 构造函数', - file: file, - line: line, - level: RiskLevel.low, - domain: IssueDomain.standards, - priority: Priority.p2, - message: '"${cls.name.lexeme}" 是 $superClassName 子类但缺少 const 构造函数', - detail: '类: ${cls.name.lexeme}\n超类: $superClassName', - suggestion: '添加 const 构造函数: "const ${cls.name.lexeme}({super.key});"', - metadata: { - 'className': cls.name.lexeme, - 'superClass': superClassName, - }, - )); - } - - return issues; - } - - static RuleMeta describe() => const RuleMeta( - id: 'missing_const_constructor', - name: '缺少 const 构造函数', - domain: 'standards', - riskLevel: 'low', - priority: 'p2', - purpose: '检测 Widget 子类是否缺少 const 构造函数', - riskReason: '缺少 const 构造函数导致 widget 无法复用实例,影响渲染性能', - badExample: - 'class MyWidget extends StatelessWidget { ... } 无 const 构造函数', - fixSuggestion: '为 Widget 类添加 const 构造函数', - cicdSafe: true, - ); -} diff --git a/packages/flutterguard_cli/lib/src/rules/module_violation.dart b/packages/flutterguard_cli/lib/src/rules/module_violation.dart deleted file mode 100644 index 8a89f24..0000000 --- a/packages/flutterguard_cli/lib/src/rules/module_violation.dart +++ /dev/null @@ -1,90 +0,0 @@ -import '../boundary_engine.dart'; -import '../config_loader.dart'; -import '../domain.dart'; -import '../import_graph.dart'; -import '../priority.dart'; -import '../rule_meta.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; - -class ModuleViolationRule { - final List modules; - final String? projectPath; - - const ModuleViolationRule(this.modules, {this.projectPath}); - - List analyze( - List files, { - List? allFiles, - SourceWorkspace? workspace, - ImportGraph? importGraph, - }) { - if (modules.isEmpty) return []; - final sources = workspace ?? SourceWorkspace(); - final availableFiles = allFiles ?? files; - final graph = importGraph ?? - ImportGraph.build( - files: availableFiles, - sourceFiles: files, - workspace: sources, - projectPath: projectPath, - ); - final boundaries = [ - for (final module in modules) - BoundaryDefinition( - name: module.name, - path: module.path, - allowedDeps: module.allowedDeps, - ), - ]; - final violations = DependencyBoundaryEngine.analyze( - sourceFiles: files, - allFiles: availableFiles, - boundaries: boundaries, - graph: graph, - workspace: sources, - projectPath: projectPath, - ); - - return [ - for (final violation in violations) - StaticIssue( - id: 'module_violation', - title: '模块间非法依赖', - file: violation.edge.source, - line: violation.edge.line, - level: RiskLevel.high, - domain: IssueDomain.architecture, - priority: Priority.p0, - message: - '模块 ${violation.source.name} 不可依赖模块 ${violation.target.name}', - detail: '导入: ${violation.edge.uri}\n' - '源模块: ${violation.source.name} (${violation.source.path})\n' - '目标模块: ${violation.target.name} (${violation.target.path})\n' - '允许依赖: ${violation.source.allowedDeps.isEmpty ? '无' : violation.source.allowedDeps.join(', ')}', - suggestion: - '通过 ${violation.source.allowedDeps.isEmpty ? 'core 层共享接口解耦' : '${violation.source.allowedDeps.join(' 或 ')}解耦'}', - metadata: { - 'sourceModule': violation.source.name, - 'targetModule': violation.target.name, - 'imported': violation.edge.uri, - 'allowedDeps': violation.source.allowedDeps, - }, - ), - ]; - } - - static RuleMeta describe() => const RuleMeta( - id: 'module_violation', - name: '模块间依赖违规', - domain: 'architecture', - riskLevel: 'high', - priority: 'p0', - purpose: '检测业务模块之间的非法依赖', - riskReason: '模块间非法依赖破坏隔离性,导致耦合和回归风险', - badExample: 'mqtt 模块直接导入 ble 模块的类', - fixSuggestion: '提取公共依赖到 shared 模块,或通过事件总线解耦', - configKeys: ['architecture.modules'], - cicdSafe: true, - ); -} diff --git a/packages/flutterguard_cli/lib/src/rules/mqtt_connection.dart b/packages/flutterguard_cli/lib/src/rules/mqtt_connection.dart deleted file mode 100644 index cf84788..0000000 --- a/packages/flutterguard_cli/lib/src/rules/mqtt_connection.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/source/line_info.dart'; - -import '../config_loader.dart'; -import '../domain.dart'; -import '../priority.dart'; -import '../rule_meta.dart'; -import '../source_utils.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; - -const _mqttClientTypes = ['MqttClient', 'MQTT', 'MqttConnect']; -const _brokerUrlPrefixes = ['tcp://', 'mqtt://', 'mqtts://']; - -class MqttConnectionRule { - final MqttConnectionRuleConfig config; - - const MqttConnectionRule(this.config); - - List analyze( - List files, { - SourceWorkspace? workspace, - }) { - if (!config.enabled) return []; - - final issues = []; - final sources = workspace ?? SourceWorkspace(); - - for (final file in files) { - final source = sources.source(file); - if (source == null) continue; - issues.addAll( - _checkFile(file, source.content, source.unit, source.lineInfo), - ); - } - - return issues; - } - - List _checkFile( - String file, - String rawContent, - CompilationUnit unit, - LineInfo lineInfo, - ) { - final issues = []; - - _checkHardcodedBroker(file, rawContent, lineInfo, issues); - - for (final cls in unit.declarations.whereType()) { - final hasMqttField = cls.members.whereType().where((f) { - final type = f.fields.type?.toString() ?? ''; - return _mqttClientTypes.any((t) => type.contains(t)); - }).isNotEmpty; - - if (!hasMqttField) continue; - - final methods = cls.members.whereType().toList(); - final methodNames = methods.map((m) => m.name.lexeme).toSet(); - - if (methodNames.contains('connect') && - !methodNames.contains('disconnect')) { - final connectMethod = - methods.firstWhere((m) => m.name.lexeme == 'connect'); - final line = lineNumberForOffset(lineInfo, connectMethod.name.offset); - issues.add(StaticIssue( - id: 'mqtt_connection', - title: 'MQTT 连接未断开', - file: file, - line: line, - level: RiskLevel.high, - domain: IssueDomain.architecture, - priority: Priority.p0, - message: - '类 "${cls.name.lexeme}" 包含 MQTT connect() 调用但缺少 disconnect()', - detail: '类: ${cls.name.lexeme}\n' - 'MqttClient 需要连接与断开配对', - suggestion: '在类中添加 disconnect() 方法并在 dispose 中调用', - metadata: { - 'className': cls.name.lexeme, - 'check': 'connect_without_disconnect', - }, - )); - } - - if (methodNames.contains('subscribe') && - !methodNames.contains('unsubscribe')) { - final subscribeMethod = - methods.firstWhere((m) => m.name.lexeme == 'subscribe'); - final line = lineNumberForOffset(lineInfo, subscribeMethod.name.offset); - issues.add(StaticIssue( - id: 'mqtt_connection', - title: 'MQTT 订阅未取消', - file: file, - line: line, - level: RiskLevel.medium, - domain: IssueDomain.architecture, - priority: Priority.p0, - message: - '类 "${cls.name.lexeme}" 包含 MQTT subscribe() 调用但缺少 unsubscribe()', - detail: '类: ${cls.name.lexeme}\n' - 'MQTT 订阅应在不需要时取消', - suggestion: '在类中添加 unsubscribe() 方法并在 dispose 中调用', - metadata: { - 'className': cls.name.lexeme, - 'check': 'subscribe_without_unsubscribe', - }, - )); - } - } - - return issues; - } - - void _checkHardcodedBroker( - String file, - String content, - LineInfo lineInfo, - List issues, - ) { - final lines = content.split('\n'); - for (var i = 0; i < lines.length; i++) { - for (final prefix in _brokerUrlPrefixes) { - if (lines[i].contains(prefix)) { - issues.add(StaticIssue( - id: 'mqtt_connection', - title: 'MQTT — 硬编码 Broker URL', - file: file, - line: i + 1, - level: RiskLevel.medium, - domain: IssueDomain.architecture, - priority: Priority.p0, - message: '检测到硬编码的 MQTT broker URL', - detail: '行 ${i + 1}: ${lines[i].trim()}\n' - '硬编码 broker URL 降低灵活性和可维护性', - suggestion: '将 MQTT broker URL 移至配置文件中', - metadata: { - 'check': 'hardcoded_broker_url', - 'line': i + 1, - }, - )); - } - } - } - } - - static RuleMeta describe() => const RuleMeta( - id: 'mqtt_connection', - name: 'MQTT 连接管理异常', - domain: 'architecture', - riskLevel: 'high', - priority: 'p0', - purpose: - '检测 MQTT connect/disconnect、subscribe/unsubscribe 配对以及硬编码 broker URL', - riskReason: '未配对的 MQTT 调用导致连接泄漏;硬编码 URL 导致安全风险和配置问题', - badExample: - '调用 connect() 后无 disconnect();URL 直接写 tcp://mqtt.example.com:1883', - fixSuggestion: '在 dispose 中添加 disconnect();将 broker URL 提取到配置文件', - cicdSafe: true, - ); -} diff --git a/packages/flutterguard_cli/lib/src/rules/provider_state_management.dart b/packages/flutterguard_cli/lib/src/rules/provider_state_management.dart deleted file mode 100644 index 715fbd4..0000000 --- a/packages/flutterguard_cli/lib/src/rules/provider_state_management.dart +++ /dev/null @@ -1,348 +0,0 @@ -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/dart/ast/visitor.dart'; - -import '../config_loader.dart'; -import '../domain.dart'; -import '../rule_meta.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; -import 'state_management_utils.dart'; - -class ProviderValueLifecycleMisuseRule { - final StateRuleConfig config; - final StateManagementConfig stateManagement; - final String projectPath; - - const ProviderValueLifecycleMisuseRule( - this.config, - this.stateManagement, { - required this.projectPath, - }); - - List analyze(List files, {SourceWorkspace? workspace}) { - if (!stateRuleEnabled(config, stateManagement)) return []; - final sources = workspace ?? SourceWorkspace(); - final issues = []; - for (final file in files) { - if (shouldIgnoreStateFile(file, projectPath, config)) continue; - final source = sources.source(file); - if (source == null || - !frameworkAllowed( - source.unit, - StateManagementFramework.provider, - stateManagement, - )) { - continue; - } - final visitor = _ProviderOwnershipVisitor(config.allowlist); - source.unit.accept(visitor); - for (final finding in visitor.findings) { - issues.add(StaticIssue( - id: 'provider_value_lifecycle_misuse', - title: 'Provider 所有权模式误用', - file: file, - line: sourceLine(source, finding.node), - level: config.severity, - domain: IssueDomain.performance, - priority: priorityForSeverity(config.severity), - message: finding.message, - suggestion: finding.kind == 'value_creates' - ? '新对象使用 create 构造;.value 只传入已有实例' - : '已有实例使用 .value,避免 Provider 错误释放或忽略所有权', - framework: StateManagementFramework.provider, - evidence: [compactEvidence(finding.node)], - metadata: {'ownershipError': finding.kind}, - )); - } - } - return issues; - } - - static RuleMeta describe() => const RuleMeta( - id: 'provider_value_lifecycle_misuse', - name: 'Provider 所有权误用', - domain: 'performance', - riskLevel: 'medium', - priority: 'p1', - purpose: '检测 .value 创建新对象和 create 返回已有对象的反向所有权用法', - riskReason: '错误的 Provider 构造方式会导致对象未释放、提前释放或状态复用错误', - badExample: 'ChangeNotifierProvider.value(value: DeviceController())', - fixSuggestion: '新对象用 create,已有对象用 .value', - configKeys: ['enabled', 'severity', 'allowlist', 'ignore_paths'], - framework: 'provider', - ); -} - -class _ProviderOwnershipFinding { - final AstNode node; - final String kind; - final String message; - - const _ProviderOwnershipFinding(this.node, this.kind, this.message); -} - -class _ProviderOwnershipVisitor extends RecursiveAstVisitor { - final List allowlist; - final findings = <_ProviderOwnershipFinding>[]; - - _ProviderOwnershipVisitor(this.allowlist); - - @override - void visitInstanceCreationExpression(InstanceCreationExpression node) { - _inspectCall( - node: node, - providerType: simpleTypeName(node.constructorName.type.toSource()), - constructor: node.constructorName.name?.name, - arguments: node.argumentList, - ); - super.visitInstanceCreationExpression(node); - } - - @override - void visitMethodInvocation(MethodInvocation node) { - final target = node.target?.toSource(); - final targetType = target == null ? null : simpleTypeName(target); - final targetIsProvider = targetType?.endsWith('Provider') ?? false; - final providerType = - targetIsProvider ? targetType! : simpleTypeName(node.methodName.name); - final constructor = targetIsProvider ? node.methodName.name : null; - _inspectCall( - node: node, - providerType: providerType, - constructor: constructor, - arguments: node.argumentList, - ); - super.visitMethodInvocation(node); - } - - void _inspectCall({ - required AstNode node, - required String providerType, - required String? constructor, - required ArgumentList arguments, - }) { - if (!providerType.endsWith('Provider')) return; - if (constructor == 'value') { - final value = _argument(arguments, 'value'); - final createdType = _createdType(value); - final isConst = value is InstanceCreationExpression && value.isConst; - if (createdType != null && !isConst && !_isImmutableType(createdType)) { - findings.add(_ProviderOwnershipFinding( - node, - 'value_creates', - '$providerType.value 创建了新对象,Provider 不会按 create 所有权管理它', - )); - } - } - - final create = _argument(arguments, 'create') ?? - (constructor == 'create' ? _firstPositional(arguments) : null); - if (create is FunctionExpression) { - final returned = _returnedExpression(create.body); - if (returned != null && _isExistingReference(returned)) { - findings.add(_ProviderOwnershipFinding( - node, - 'create_reuses', - '$providerType.create 返回已有实例,可能错误取得其释放所有权', - )); - } - } - } - - static String? _createdType(Expression? expression) { - if (expression is InstanceCreationExpression) { - return simpleTypeName(expression.constructorName.type.toSource()); - } - if (expression is MethodInvocation && - expression.target == null && - expression.methodName.name.startsWith(RegExp('[A-Z]'))) { - return simpleTypeName(expression.methodName.name); - } - return null; - } - - bool _isImmutableType(String type) { - if (allowlist.contains(type)) return true; - return type.endsWith('Value') || - type.endsWith('Data') || - type.endsWith('Dto') || - type == 'String' || - type == 'int' || - type == 'double' || - type == 'bool'; - } - - static Expression? _argument(ArgumentList list, String name) { - for (final argument in list.arguments) { - if (argument is NamedExpression && argument.name.label.name == name) { - return argument.expression; - } - } - return null; - } - - static Expression? _firstPositional(ArgumentList list) { - for (final argument in list.arguments) { - if (argument is! NamedExpression) return argument; - } - return null; - } - - static Expression? _returnedExpression(FunctionBody body) { - if (body is ExpressionFunctionBody) return body.expression; - if (body is BlockFunctionBody) { - for (final statement in body.block.statements) { - if (statement is ReturnStatement) return statement.expression; - } - } - return null; - } - - static bool _isExistingReference(Expression expression) => - expression is SimpleIdentifier || - expression is PrefixedIdentifier || - expression is PropertyAccess; -} - -class NotifyListenersInLoopRule { - final StateRuleConfig config; - final StateManagementConfig stateManagement; - final String projectPath; - - const NotifyListenersInLoopRule( - this.config, - this.stateManagement, { - required this.projectPath, - }); - - List analyze(List files, {SourceWorkspace? workspace}) { - if (!stateRuleEnabled(config, stateManagement)) return []; - if (config.allowlist.contains('notifyListeners')) return []; - final sources = workspace ?? SourceWorkspace(); - final issues = []; - for (final file in files) { - if (shouldIgnoreStateFile(file, projectPath, config)) continue; - final source = sources.source(file); - if (source == null || - !frameworkAllowed( - source.unit, - StateManagementFramework.provider, - stateManagement, - )) { - continue; - } - final visitor = _NotifyLoopVisitor(); - source.unit.accept(visitor); - for (final finding in visitor.findings) { - issues.add(StaticIssue( - id: 'notify_listeners_in_loop', - title: '循环中调用 notifyListeners', - file: file, - line: sourceLine(source, finding.root), - level: config.severity, - domain: IssueDomain.performance, - priority: priorityForSeverity(config.severity), - message: '循环每次迭代都可能触发监听者重建', - suggestion: '在循环内完成批量修改后,只调用一次 notifyListeners()', - framework: StateManagementFramework.provider, - evidence: limitedEvidence(finding.notifications.map(compactEvidence)), - )); - } - } - return issues; - } - - static RuleMeta describe() => const RuleMeta( - id: 'notify_listeners_in_loop', - name: '循环中通知监听者', - domain: 'performance', - riskLevel: 'medium', - priority: 'p1', - purpose: '检测 for/while/do-while/forEach 中的 notifyListeners', - riskReason: '循环内通知会触发重复重建并暴露中间状态', - badExample: - 'for (final item in items) { update(item); notifyListeners(); }', - fixSuggestion: '循环结束后统一通知一次', - configKeys: ['enabled', 'severity', 'allowlist', 'ignore_paths'], - framework: 'provider', - ); -} - -class _NotifyLoopFinding { - final AstNode root; - final List notifications; - - const _NotifyLoopFinding(this.root, this.notifications); -} - -class _NotifyLoopVisitor extends RecursiveAstVisitor { - final findings = <_NotifyLoopFinding>[]; - - @override - void visitForStatement(ForStatement node) { - final parts = node.forLoopParts; - final provablyShort = _provablyShortFor(parts); - if (!provablyShort) _inspect(node, node.body); - super.visitForStatement(node); - } - - @override - void visitWhileStatement(WhileStatement node) { - _inspect(node, node.body); - super.visitWhileStatement(node); - } - - @override - void visitDoStatement(DoStatement node) { - _inspect(node, node.body); - super.visitDoStatement(node); - } - - @override - void visitMethodInvocation(MethodInvocation node) { - if (node.methodName.name == 'forEach') { - final callback = node.argumentList.arguments - .whereType() - .firstOrNull; - final target = node.target; - final provablyShort = - target is ListLiteral && target.elements.length <= 1 || - target is SetOrMapLiteral && target.elements.length <= 1; - if (callback != null && !provablyShort) _inspect(node, callback.body); - } - super.visitMethodInvocation(node); - } - - void _inspect(AstNode root, AstNode body) { - final visitor = _NotifyInvocationVisitor(); - body.accept(visitor); - if (visitor.nodes.isNotEmpty) { - findings.add(_NotifyLoopFinding(root, visitor.nodes)); - } - } - - bool _provablyShortFor(ForLoopParts parts) { - if (parts is ForEachParts) { - final iterable = parts.iterable; - return iterable is ListLiteral && iterable.elements.length <= 1 || - iterable is SetOrMapLiteral && iterable.elements.length <= 1; - } - final source = parts.toSource().replaceAll(RegExp(r'\s+'), ' '); - return RegExp(r'= 0; [A-Za-z_$][\w$]* < 1;').hasMatch(source) || - RegExp(r'= 0; [A-Za-z_$][\w$]* <= 0;').hasMatch(source); - } -} - -class _NotifyInvocationVisitor extends RecursiveAstVisitor { - final nodes = []; - - @override - void visitMethodInvocation(MethodInvocation node) { - if (node.methodName.name == 'notifyListeners') nodes.add(node); - super.visitMethodInvocation(node); - } -} - -extension _FirstOrNull on Iterable { - T? get firstOrNull => isEmpty ? null : first; -} diff --git a/packages/flutterguard_cli/lib/src/rules/pubspec_security.dart b/packages/flutterguard_cli/lib/src/rules/pubspec_security.dart deleted file mode 100644 index 152c6bc..0000000 --- a/packages/flutterguard_cli/lib/src/rules/pubspec_security.dart +++ /dev/null @@ -1,200 +0,0 @@ -import 'dart:io'; - -import 'package:path/path.dart' as p; -import 'package:yaml/yaml.dart'; - -import '../config_loader.dart'; -import '../domain.dart'; -import '../priority.dart'; -import '../rule_meta.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; - -const _vulnerableDeps = { - 'mqtt_client': '10.0.0', - 'http': '1.0.0', -}; - -const _deprecatedPackages = { - 'flutter_blue': 'flutter_blue_plus', -}; - -class PubspecSecurityRule { - final PubspecSecurityRuleConfig config; - - const PubspecSecurityRule(this.config); - - List analyze( - List files, { - String? projectPath, - SourceWorkspace? workspace, - }) { - if (!config.enabled) return []; - - final issues = []; - final candidateDirectories = projectPath == null - ? {for (final file in files) p.dirname(file)} - : {projectPath}; - - for (final directory in candidateDirectories) { - final pubspec = p.join(directory, 'pubspec.yaml'); - if (!File(pubspec).existsSync()) continue; - - try { - final content = File(pubspec).readAsStringSync(); - final yaml = loadYaml(content); - if (yaml is! YamlMap) continue; - issues.addAll(_checkPubspec(pubspec, yaml)); - } on Object catch (error) { - workspace?.addDiagnostic(ScanDiagnostic( - stage: 'pubspec_parse', - file: pubspec, - message: error.toString(), - severity: ScanDiagnosticSeverity.error, - )); - } - } - - return _deduplicate(issues); - } - - List _checkPubspec(String pubspecPath, YamlMap root) { - final issues = []; - - final dependencies = {}; - final devDependencies = {}; - - _collectDeps(root['dependencies'], dependencies); - _collectDeps(root['dev_dependencies'], devDependencies); - - for (final dep in {...dependencies.keys, ...devDependencies.keys}) { - final version = dependencies[dep] ?? devDependencies[dep] ?? ''; - - if (version.isEmpty || version == 'any') { - issues.add(StaticIssue( - id: 'pubspec_security', - title: '依赖安全 — 无界版本', - file: pubspecPath, - line: null, - level: RiskLevel.medium, - domain: IssueDomain.standards, - priority: Priority.p2, - message: '依赖 "$dep" 没有版本约束 ($version)', - detail: '包: $dep\n' - '版本: ${version.isEmpty ? "未指定" : version}\n' - '无界依赖可能导致不兼容的版本被引入', - suggestion: '为 "$dep" 添加具体的版本约束 (如 ^1.0.0)', - metadata: { - 'package': dep, - 'version': version, - 'check': 'unbounded_dependency', - }, - )); - } - - if (_deprecatedPackages.containsKey(dep)) { - final replacement = _deprecatedPackages[dep]!; - issues.add(StaticIssue( - id: 'pubspec_security', - title: '依赖安全 — 已废弃包', - file: pubspecPath, - line: null, - level: RiskLevel.high, - domain: IssueDomain.standards, - priority: Priority.p2, - message: '"$dep" 已废弃,应迁移至 "$replacement"', - detail: '包: $dep\n' - '替代: $replacement\n' - '$dep 已不再维护,存在安全风险', - suggestion: '将 "$dep" 替换为 "$replacement"', - metadata: { - 'package': dep, - 'replacement': replacement, - 'check': 'deprecated_package', - }, - )); - } - - if (_vulnerableDeps.containsKey(dep)) { - final minVersion = _vulnerableDeps[dep]!; - final currentVersion = _cleanVersion(version); - if (currentVersion.isNotEmpty && - _compareVersion(currentVersion, minVersion) < 0) { - issues.add(StaticIssue( - id: 'pubspec_security', - title: '依赖安全 — 过旧版本', - file: pubspecPath, - line: null, - level: RiskLevel.high, - domain: IssueDomain.standards, - priority: Priority.p2, - message: '"$dep" 版本 $currentVersion 低于推荐的最低版本 $minVersion', - detail: '包: $dep\n' - '当前: $currentVersion\n' - '最低推荐: $minVersion\n' - '旧版本可能包含已知安全漏洞', - suggestion: '将 "$dep" 升级至至少 $minVersion', - metadata: { - 'package': dep, - 'currentVersion': currentVersion, - 'minVersion': minVersion, - 'check': 'outdated_dependency', - }, - )); - } - } - } - - return issues; - } - - void _collectDeps(dynamic deps, Map target) { - if (deps is! YamlMap) return; - for (final entry in deps.entries) { - final name = entry.key.toString(); - final value = entry.value; - if (value is String) { - target[name] = value; - } else if (value is YamlMap) { - target[name] = value['version']?.toString() ?? ''; - } - } - } - - String _cleanVersion(String version) { - return version.replaceAll(RegExp(r'[\^~]'), '').trim(); - } - - int _compareVersion(String a, String b) { - final aParts = a.split('.').map((e) => int.tryParse(e) ?? 0).toList(); - final bParts = b.split('.').map((e) => int.tryParse(e) ?? 0).toList(); - for (var i = 0; i < aParts.length && i < bParts.length; i++) { - final cmp = aParts[i].compareTo(bParts[i]); - if (cmp != 0) return cmp; - } - return aParts.length.compareTo(bParts.length); - } - - List _deduplicate(List issues) { - final seen = {}; - return issues.where((i) { - final key = '${i.id}|${i.file}|${i.line}|${i.message}'; - if (seen.contains(key)) return false; - seen.add(key); - return true; - }).toList(); - } - - static RuleMeta describe() => const RuleMeta( - id: 'pubspec_security', - name: '依赖安全风险', - domain: 'standards', - riskLevel: 'medium', - priority: 'p2', - purpose: '检测依赖版本无界、已废弃包、过旧版本', - riskReason: '无界依赖引入不兼容更新;废弃包存在安全漏洞', - badExample: 'mqtt_client: ^9.0.0(低于 10.0.0);flutter_blue(已废弃)', - fixSuggestion: '固定大版本号;将 flutter_blue 迁移至 flutter_blue_plus', - cicdSafe: true, - ); -} diff --git a/packages/flutterguard_cli/lib/src/rules/registry.dart b/packages/flutterguard_cli/lib/src/rules/registry.dart deleted file mode 100644 index 30777d5..0000000 --- a/packages/flutterguard_cli/lib/src/rules/registry.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:flutterguard_cli/src/rule_meta.dart'; -import 'package:flutterguard_cli/src/rules/catalog.dart'; - -class RuleRegistry { - static final Map _registry = () { - final list = RuleCatalog.metadata(); - return {for (final m in list) m.id: m}; - }(); - - static List all() => _registry.values.toList(); - - static RuleMeta? find(String id) => _registry[id]; -} diff --git a/packages/flutterguard_cli/lib/src/rules/riverpod_state_management.dart b/packages/flutterguard_cli/lib/src/rules/riverpod_state_management.dart deleted file mode 100644 index faf1e86..0000000 --- a/packages/flutterguard_cli/lib/src/rules/riverpod_state_management.dart +++ /dev/null @@ -1,306 +0,0 @@ -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/dart/ast/visitor.dart'; - -import '../config_loader.dart'; -import '../domain.dart'; -import '../rule_meta.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; -import 'state_management_utils.dart'; - -class RiverpodReadUsedForRenderRule { - final StateRuleConfig config; - final StateManagementConfig stateManagement; - final String projectPath; - - const RiverpodReadUsedForRenderRule( - this.config, - this.stateManagement, { - required this.projectPath, - }); - - List analyze(List files, {SourceWorkspace? workspace}) { - if (!stateRuleEnabled(config, stateManagement)) return []; - final sources = workspace ?? SourceWorkspace(); - final issues = []; - for (final file in files) { - if (shouldIgnoreStateFile(file, projectPath, config)) continue; - final source = sources.source(file); - if (source == null || - !frameworkAllowed( - source.unit, - StateManagementFramework.riverpod, - stateManagement, - )) { - continue; - } - for (final root in buildRoots(source.unit)) { - final reads = _RiverpodReadCollector(root.body)..collect(); - for (final finding in reads.findings) { - if (config.allowlist.contains(finding.provider)) continue; - issues.add(StaticIssue( - id: 'riverpod_read_used_for_render', - title: '使用 ref.read 驱动渲染', - file: file, - line: sourceLine(source, finding.read), - level: config.severity, - domain: IssueDomain.performance, - priority: priorityForSeverity(config.severity), - message: 'ref.read 的结果进入了渲染路径,后续状态变化不会触发重建', - suggestion: '在渲染路径使用 ref.watch;命令调用继续使用 ref.read', - framework: StateManagementFramework.riverpod, - evidence: limitedEvidence(finding.sinks), - metadata: {'provider': finding.provider}, - )); - } - } - } - return issues; - } - - static RuleMeta describe() => const RuleMeta( - id: 'riverpod_read_used_for_render', - name: 'ref.read 驱动渲染', - domain: 'performance', - riskLevel: 'medium', - priority: 'p1', - purpose: '检测 build 中使用 ref.read 的值构造 Widget 或控制条件渲染', - riskReason: 'read 不订阅 Provider,状态变化后界面可能保持陈旧', - badExample: 'Text(ref.read(deviceProvider).name)', - fixSuggestion: '渲染数据改用 ref.watch', - configKeys: ['enabled', 'severity', 'allowlist', 'ignore_paths'], - framework: 'riverpod', - ); -} - -class _ReadFinding { - final MethodInvocation read; - final String provider; - final List sinks; - - const _ReadFinding(this.read, this.provider, this.sinks); -} - -class _RiverpodReadCollector { - final AstNode root; - final findings = <_ReadFinding>[]; - - _RiverpodReadCollector(this.root); - - void collect() { - final visitor = _ReadInvocationVisitor(root); - root.accept(visitor); - for (final read in visitor.reads) { - final provider = read.argumentList.arguments.isEmpty - ? '' - : read.argumentList.arguments.first.toSource(); - final sinks = []; - if (_isRenderSink(read, root)) { - sinks.add( - 'render sink: ${compactEvidence(_nearestRenderNode(read, root))}'); - } - final variable = _assignedVariable(read); - if (variable != null) { - final usages = _IdentifierUsageVisitor(variable, root)..scan(); - for (final usage in usages.renderUsages) { - sinks.add('render use of $variable: ${compactEvidence( - _nearestRenderNode(usage, root), - )}'); - } - } - if (sinks.isNotEmpty) findings.add(_ReadFinding(read, provider, sinks)); - } - } - - static String? _assignedVariable(MethodInvocation read) { - AstNode? current = read.parent; - while (current != null) { - if (current is VariableDeclaration && current.initializer != null) { - return current.name.lexeme; - } - if (current is Statement || current is FunctionBody) break; - current = current.parent; - } - return null; - } - - static bool _isRenderSink(AstNode node, AstNode root) { - AstNode? current = node; - while (current != null && current != root) { - if (current is FunctionExpression) return false; - if (current is ReturnStatement || - current is IfStatement || - current is ConditionalExpression || - current is IfElement || - current is ForElement) { - return true; - } - current = current.parent; - } - return false; - } - - static AstNode _nearestRenderNode(AstNode node, AstNode root) { - AstNode current = node; - while (current.parent != null && current.parent != root) { - final parent = current.parent!; - if (parent is ReturnStatement || - parent is IfStatement || - parent is ConditionalExpression || - parent is IfElement || - parent is ForElement) { - return parent; - } - current = parent; - } - return current; - } -} - -class _ReadInvocationVisitor extends RecursiveAstVisitor { - final AstNode root; - final reads = []; - - _ReadInvocationVisitor(this.root); - - @override - void visitFunctionExpression(FunctionExpression node) { - if (node == root) super.visitFunctionExpression(node); - } - - @override - void visitMethodInvocation(MethodInvocation node) { - if (node.methodName.name == 'read' && node.target?.toSource() == 'ref') { - reads.add(node); - } - super.visitMethodInvocation(node); - } -} - -class _IdentifierUsageVisitor extends RecursiveAstVisitor { - final String name; - final AstNode root; - final renderUsages = []; - - _IdentifierUsageVisitor(this.name, this.root); - - void scan() => root.accept(this); - - @override - void visitFunctionExpression(FunctionExpression node) {} - - @override - void visitSimpleIdentifier(SimpleIdentifier node) { - if (node.name == name && - node.parent is! VariableDeclaration && - _RiverpodReadCollector._isRenderSink(node, root)) { - renderUsages.add(node); - } - super.visitSimpleIdentifier(node); - } -} - -class RiverpodWatchInCallbackRule { - final StateRuleConfig config; - final StateManagementConfig stateManagement; - final String projectPath; - - const RiverpodWatchInCallbackRule( - this.config, - this.stateManagement, { - required this.projectPath, - }); - - List analyze(List files, {SourceWorkspace? workspace}) { - if (!stateRuleEnabled(config, stateManagement)) return []; - final sources = workspace ?? SourceWorkspace(); - final issues = []; - for (final file in files) { - if (shouldIgnoreStateFile(file, projectPath, config)) continue; - final source = sources.source(file); - if (source == null || - !frameworkAllowed( - source.unit, - StateManagementFramework.riverpod, - stateManagement, - )) { - continue; - } - final visitor = _WatchCallbackVisitor(); - source.unit.accept(visitor); - for (final callback in visitor.callbacks) { - final watches = callback.watches.where((watch) { - final provider = watch.argumentList.arguments.isEmpty - ? '' - : watch.argumentList.arguments.first.toSource(); - return !config.allowlist.contains(provider); - }).toList(); - if (watches.isEmpty) continue; - issues.add(StaticIssue( - id: 'riverpod_watch_in_callback', - title: '回调中调用 ref.watch', - file: file, - line: sourceLine(source, callback.function), - level: config.severity, - domain: IssueDomain.performance, - priority: priorityForSeverity(config.severity), - message: '事件或异步回调中调用 ref.watch,订阅不会形成有效渲染依赖', - suggestion: '回调中使用 ref.read;只在 build/provider 声明中使用 ref.watch', - framework: StateManagementFramework.riverpod, - evidence: limitedEvidence(watches.map(compactEvidence)), - )); - } - } - return issues; - } - - static RuleMeta describe() => const RuleMeta( - id: 'riverpod_watch_in_callback', - name: '回调中使用 ref.watch', - domain: 'performance', - riskLevel: 'medium', - priority: 'p1', - purpose: '检测事件、listener、timer 和异步回调中的 ref.watch', - riskReason: '回调不是响应式构建范围,watch 的订阅语义无效或误导', - badExample: 'onPressed: () => ref.watch(deviceProvider)', - fixSuggestion: '回调改用 ref.read', - configKeys: ['enabled', 'severity', 'allowlist', 'ignore_paths'], - framework: 'riverpod', - ); -} - -class _WatchCallbackFinding { - final FunctionExpression function; - final List watches; - - const _WatchCallbackFinding(this.function, this.watches); -} - -class _WatchCallbackVisitor extends RecursiveAstVisitor { - final callbacks = <_WatchCallbackFinding>[]; - - @override - void visitFunctionExpression(FunctionExpression node) { - if (isCallbackFunction(node)) { - final watches = _WatchInvocationVisitor(); - node.body.accept(watches); - if (watches.nodes.isNotEmpty) { - callbacks.add(_WatchCallbackFinding(node, watches.nodes)); - } - return; - } - super.visitFunctionExpression(node); - } -} - -class _WatchInvocationVisitor extends RecursiveAstVisitor { - final nodes = []; - - @override - void visitMethodInvocation(MethodInvocation node) { - if (node.methodName.name == 'watch' && node.target?.toSource() == 'ref') { - nodes.add(node); - } - super.visitMethodInvocation(node); - } -} diff --git a/packages/flutterguard_cli/lib/src/rules/state_dependency_cycle.dart b/packages/flutterguard_cli/lib/src/rules/state_dependency_cycle.dart deleted file mode 100644 index 7b90e5f..0000000 --- a/packages/flutterguard_cli/lib/src/rules/state_dependency_cycle.dart +++ /dev/null @@ -1,437 +0,0 @@ -import 'dart:collection'; - -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/dart/ast/visitor.dart'; - -import '../config_loader.dart'; -import '../domain.dart'; -import '../import_utils.dart'; -import '../path_utils.dart'; -import '../rule_meta.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; -import 'state_management_utils.dart'; - -class StateDependencyCycleRule { - final StateRuleConfig config; - final StateManagementConfig stateManagement; - final String projectPath; - - const StateDependencyCycleRule( - this.config, - this.stateManagement, { - required this.projectPath, - }); - - List analyze( - List allFiles, { - List? targetFiles, - bool changedOnly = false, - SourceWorkspace? workspace, - }) { - if (!stateRuleEnabled(config, stateManagement)) return []; - final sources = workspace ?? SourceWorkspace(); - final normalizedFiles = allFiles.map(normalizePath).toSet(); - final units = <({String file, SourceUnit source})>[]; - final nodes = {}; - final names = >{}; - - void register(_StateGraphNode node) { - nodes[node.id] = node; - names.putIfAbsent(node.name, () => []).add(node.id); - } - - for (final file in normalizedFiles) { - if (shouldIgnoreStateFile(file, projectPath, config)) continue; - final source = sources.source(file); - if (source == null) continue; - units.add((file: source.path, source: source)); - for (final cls - in source.unit.declarations.whereType()) { - register(_StateGraphNode( - id: _nodeId(source.path, 'class', cls.name.lexeme), - name: cls.name.lexeme, - file: source.path, - anchor: cls, - isState: isStateOwnerClass(cls), - )); - } - for (final declaration in source.unit.declarations - .whereType()) { - for (final variable in declaration.variables.variables) { - final initializer = variable.initializer; - if (initializer != null && _isProviderDeclaration(initializer)) { - register(_StateGraphNode( - id: _nodeId(source.path, 'provider', variable.name.lexeme), - name: variable.name.lexeme, - file: source.path, - anchor: variable, - isState: true, - )); - } - } - } - } - - for (final candidates in names.values) { - candidates.sort(); - } - final graph = >{ - for (final id in nodes.keys) id: {}, - }; - final knownNames = names.keys.toSet(); - - for (final item in units) { - final importedFiles = _resolvedImports( - item.source.unit, - item.file, - normalizedFiles, - ); - for (final cls - in item.source.unit.declarations.whereType()) { - final ownerId = _nodeId(item.file, 'class', cls.name.lexeme); - if (!nodes.containsKey(ownerId)) continue; - final collector = _DependencyCollector(knownNames); - cls.accept(collector); - _addResolvedEdges( - graph: graph, - ownerId: ownerId, - dependencyNames: collector.dependencies, - sourceFile: item.file, - importedFiles: importedFiles, - nodes: nodes, - names: names, - ); - } - for (final declaration in item.source.unit.declarations - .whereType()) { - for (final variable in declaration.variables.variables) { - final initializer = variable.initializer; - final ownerId = _nodeId( - item.file, - 'provider', - variable.name.lexeme, - ); - if (initializer == null || !nodes.containsKey(ownerId)) continue; - final collector = _DependencyCollector(knownNames); - initializer.accept(collector); - _addResolvedEdges( - graph: graph, - ownerId: ownerId, - dependencyNames: collector.dependencies, - sourceFile: item.file, - importedFiles: importedFiles, - nodes: nodes, - names: names, - ); - } - } - } - - _applyEdgeAllowlist(graph, nodes, names); - - final targetSet = (targetFiles ?? allFiles).map(normalizePath).toSet(); - final issues = []; - for (final component in _stronglyConnectedComponents(graph)) { - if (component.length < 2 || !component.any((id) => nodes[id]!.isState)) { - continue; - } - final changedNodes = component - .where((id) => targetSet.contains(nodes[id]!.file)) - .toList() - ..sort(); - if (changedOnly && changedNodes.isEmpty) continue; - final cycleIds = _shortestCycle(component, graph); - if (cycleIds.isEmpty) continue; - final anchorId = changedOnly ? changedNodes.first : cycleIds.first; - final anchor = nodes[anchorId]!; - final cycle = [ - for (final id in cycleIds) _displayName(nodes[id]!, names), - ]; - final componentNames = [ - for (final id in component) _displayName(nodes[id]!, names), - ]..sort(); - final source = sources.source(anchor.file); - if (source == null) continue; - issues.add(StaticIssue( - id: 'state_dependency_cycle', - title: '状态依赖环', - file: anchor.file, - line: sourceLine(source, anchor.anchor), - level: config.severity, - domain: IssueDomain.architecture, - priority: priorityForSeverity(config.severity), - message: '状态依赖形成环: ${cycle.join(' -> ')}', - suggestion: '提取单向协调器或接口,移除环中的一条状态依赖边', - evidence: [cycle.join(' -> ')], - metadata: {'cycle': cycle, 'nodes': componentNames}, - )); - } - return issues; - } - - static String _nodeId(String file, String kind, String name) => - '${normalizePath(file)}::$kind::$name'; - - Set _resolvedImports( - CompilationUnit unit, - String file, - Set allFiles, - ) { - final result = {}; - for (final directive in unit.directives.whereType()) { - final uri = directive.uri.stringValue; - if (uri == null) continue; - final resolved = resolveImport( - file, - uri, - allFiles, - projectPath: projectPath, - ); - if (resolved != null) result.add(normalizePath(resolved)); - } - return result; - } - - static void _addResolvedEdges({ - required Map> graph, - required String ownerId, - required Set dependencyNames, - required String sourceFile, - required Set importedFiles, - required Map nodes, - required Map> names, - }) { - for (final name in dependencyNames) { - final dependencyId = _resolveNode( - name: name, - sourceFile: sourceFile, - importedFiles: importedFiles, - nodes: nodes, - names: names, - ); - if (dependencyId != null && dependencyId != ownerId) { - graph[ownerId]!.add(dependencyId); - } - } - } - - static String? _resolveNode({ - required String name, - required String sourceFile, - required Set importedFiles, - required Map nodes, - required Map> names, - }) { - final candidates = names[name] ?? const []; - if (candidates.isEmpty) return null; - final normalizedSource = normalizePath(sourceFile); - final local = - candidates.where((id) => nodes[id]!.file == normalizedSource).toList(); - if (local.length == 1) return local.single; - final imported = candidates - .where((id) => importedFiles.contains(nodes[id]!.file)) - .toList(); - if (imported.length == 1) return imported.single; - return candidates.length == 1 ? candidates.single : null; - } - - void _applyEdgeAllowlist( - Map> graph, - Map nodes, - Map> names, - ) { - final allowed = <({String source, String target})>[]; - for (final entry in config.allowlist) { - final parts = entry.split('->'); - if (parts.length == 2) { - allowed.add((source: parts[0], target: parts[1])); - } - } - for (final edge in graph.entries) { - final source = nodes[edge.key]!; - edge.value.removeWhere((targetId) { - final target = nodes[targetId]!; - return allowed.any( - (entry) => - _matchesAllowlistName(entry.source, source, names) && - _matchesAllowlistName(entry.target, target, names), - ); - }); - } - } - - bool _matchesAllowlistName( - String configured, - _StateGraphNode node, - Map> names, - ) => - configured == node.name || configured == _displayName(node, names); - - String _displayName( - _StateGraphNode node, - Map> names, - ) { - if ((names[node.name]?.length ?? 0) <= 1) return node.name; - final path = - projectRelativePath(node.file, projectPath).replaceAll('\\', '/'); - return '$path::${node.name}'; - } - - static bool _isProviderDeclaration(Expression initializer) { - final source = initializer.toSource(); - return RegExp(r'(^|\.)[A-Za-z]*Provider(?:<[^>]+>)?\s*\(').hasMatch(source); - } - - static List> _stronglyConnectedComponents( - Map> graph, - ) { - var index = 0; - final indices = {}; - final lowlink = {}; - final stack = []; - final onStack = {}; - final result = >[]; - - void connect(String node) { - indices[node] = index; - lowlink[node] = index; - index++; - stack.add(node); - onStack.add(node); - final neighbors = (graph[node] ?? const {}).toList()..sort(); - for (final next in neighbors) { - if (!indices.containsKey(next)) { - connect(next); - lowlink[node] = - lowlink[node]! < lowlink[next]! ? lowlink[node]! : lowlink[next]!; - } else if (onStack.contains(next)) { - lowlink[node] = - lowlink[node]! < indices[next]! ? lowlink[node]! : indices[next]!; - } - } - if (lowlink[node] != indices[node]) return; - final component = {}; - while (stack.isNotEmpty) { - final current = stack.removeLast(); - onStack.remove(current); - component.add(current); - if (current == node) break; - } - result.add(component); - } - - final ids = graph.keys.toList()..sort(); - for (final id in ids) { - if (!indices.containsKey(id)) connect(id); - } - return result; - } - - static List _shortestCycle( - Set component, - Map> graph, - ) { - List? best; - final starts = component.toList()..sort(); - for (final start in starts) { - final queue = Queue>()..add([start]); - final shortestDepth = {start: 0}; - while (queue.isNotEmpty) { - final path = queue.removeFirst(); - final current = path.last; - final neighbors = (graph[current] ?? const {}) - .where(component.contains) - .toList() - ..sort(); - for (final next in neighbors) { - if (next == start && path.length > 1) { - final candidate = [...path, start]; - if (best == null || - candidate.length < best.length || - candidate.length == best.length && - candidate.join('\u0000').compareTo(best.join('\u0000')) < - 0) { - best = candidate; - } - continue; - } - if (path.contains(next)) continue; - final depth = path.length; - if ((shortestDepth[next] ?? 1 << 30) < depth) continue; - shortestDepth[next] = depth; - queue.add([...path, next]); - } - } - } - return best ?? const []; - } - - static RuleMeta describe() => const RuleMeta( - id: 'state_dependency_cycle', - name: '状态依赖环', - domain: 'architecture', - riskLevel: 'high', - priority: 'p0', - purpose: '检测 Provider、状态 owner 与其项目内依赖形成的强连通环', - riskReason: '依赖环导致初始化顺序不确定、递归更新和难以隔离的测试', - badExample: 'AuthController -> SessionProvider -> AuthController', - fixSuggestion: '提取协调器或接口,使依赖保持单向', - configKeys: ['enabled', 'severity', 'allowlist', 'ignore_paths'], - ); -} - -class _StateGraphNode { - final String id; - final String name; - final String file; - final AstNode anchor; - final bool isState; - - const _StateGraphNode({ - required this.id, - required this.name, - required this.file, - required this.anchor, - required this.isState, - }); -} - -class _DependencyCollector extends RecursiveAstVisitor { - final Set knownNames; - final dependencies = {}; - - _DependencyCollector(this.knownNames); - - @override - void visitNamedType(NamedType node) { - final type = simpleTypeName(node.toSource()); - if (knownNames.contains(type)) dependencies.add(type); - super.visitNamedType(node); - } - - @override - void visitInstanceCreationExpression(InstanceCreationExpression node) { - final type = simpleTypeName(node.constructorName.type.toSource()); - if (knownNames.contains(type)) dependencies.add(type); - super.visitInstanceCreationExpression(node); - } - - @override - void visitMethodInvocation(MethodInvocation node) { - final name = node.methodName.name; - if (name == 'watch' || name == 'read') { - if (node.argumentList.arguments.isNotEmpty) { - final dependency = node.argumentList.arguments.first.toSource(); - final simple = dependency.split('.').first; - if (knownNames.contains(simple)) dependencies.add(simple); - } - } - final typeArguments = node.typeArguments?.arguments ?? const []; - for (final argument in typeArguments) { - final type = simpleTypeName(argument.toSource()); - if (knownNames.contains(type)) dependencies.add(type); - } - super.visitMethodInvocation(node); - } -} diff --git a/packages/flutterguard_cli/lib/src/rules/state_management_utils.dart b/packages/flutterguard_cli/lib/src/rules/state_management_utils.dart deleted file mode 100644 index d2fe147..0000000 --- a/packages/flutterguard_cli/lib/src/rules/state_management_utils.dart +++ /dev/null @@ -1,236 +0,0 @@ -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/dart/ast/visitor.dart'; - -import '../config_loader.dart'; -import '../path_utils.dart'; -import '../priority.dart'; -import '../source_workspace.dart'; -import '../static_issue.dart'; - -const stateOwnerSuffixes = [ - 'State', - 'Notifier', - 'Controller', - 'Cubit', - 'Bloc', - 'ChangeNotifier', -]; - -Priority priorityForSeverity(RiskLevel severity) => switch (severity) { - RiskLevel.high => Priority.p0, - RiskLevel.medium => Priority.p1, - RiskLevel.low => Priority.p2, - }; - -bool stateRuleEnabled( - StateRuleConfig rule, - StateManagementConfig stateManagement, -) { - if (!stateManagement.enabled || !rule.enabled) return false; - // All state-management rules currently emit certain findings. The ordering - // keeps the threshold model forward compatible when lower-confidence rules - // are introduced. - return switch (stateManagement.confidenceThreshold) { - RuleConfidence.certain => true, - RuleConfidence.probable => true, - RuleConfidence.informational => true, - }; -} - -bool shouldIgnoreStateFile( - String file, - String projectPath, - StateRuleConfig config, -) => - config.ignorePaths.any( - (pattern) => matchesProjectGlob(file, pattern, projectPath), - ); - -bool hasFrameworkImport( - CompilationUnit unit, - StateManagementFramework framework, -) { - final imports = unit.directives - .whereType() - .map((directive) => directive.uri.stringValue ?? '') - .toList(); - return switch (framework) { - StateManagementFramework.riverpod => imports.any( - (uri) => uri.contains('riverpod') || uri.contains('hooks_riverpod'), - ), - StateManagementFramework.bloc => imports.any( - (uri) => - uri.contains('package:bloc/') || - uri.contains('package:flutter_bloc/'), - ), - StateManagementFramework.provider => imports.any( - (uri) => - uri.contains('package:provider/') || - uri.contains('package:flutter_bloc/'), - ), - StateManagementFramework.generic => true, - }; -} - -bool frameworkAllowed( - CompilationUnit unit, - StateManagementFramework framework, - StateManagementConfig config, -) => - !config.frameworkAutoDetect || hasFrameworkImport(unit, framework); - -bool isStateOwnerClass(ClassDeclaration declaration) { - final name = declaration.name.lexeme; - final supertype = declaration.extendsClause?.superclass.toSource() ?? ''; - final mixins = declaration.withClause?.mixinTypes - .map((type) => type.toSource()) - .join(' ') ?? - ''; - if (supertype.startsWith('State<')) return false; - return stateOwnerSuffixes.any(name.endsWith) || - stateOwnerSuffixes.any(supertype.endsWith) || - stateOwnerSuffixes.any(mixins.contains); -} - -String typeName(TypeAnnotation? type) => type?.toSource() ?? ''; - -String simpleTypeName(String type) { - final withoutGenerics = type.split('<').first; - return withoutGenerics.split('.').last.replaceAll('?', ''); -} - -bool isCollectionType(String type) { - final simple = simpleTypeName(type); - return simple == 'List' || - simple == 'Set' || - simple == 'Map' || - simple.startsWith('Iterable'); -} - -bool isUnmodifiableExpression(Expression? expression) { - if (expression == null) return false; - final source = expression.toSource(); - return source.contains('unmodifiable') || - source.contains('Unmodifiable') || - source.contains('List.unmodifiable') || - source.contains('Set.unmodifiable') || - source.contains('Map.unmodifiable'); -} - -bool isPublicName(String name) => !name.startsWith('_'); - -String compactEvidence(AstNode node) { - final value = node.toSource().replaceAll(RegExp(r'\s+'), ' ').trim(); - return value.length <= 140 ? value : '${value.substring(0, 137)}...'; -} - -List limitedEvidence(Iterable evidence) => - evidence.toSet().take(5).toList(); - -int sourceLine(SourceUnit source, AstNode node) => - source.lineInfo.getLocation(node.offset).lineNumber; - -class BuildRoot { - final AstNode body; - final AstNode anchor; - final String label; - - const BuildRoot(this.body, this.anchor, this.label); -} - -List buildRoots(CompilationUnit unit) { - final collector = _BuildRootCollector(); - unit.accept(collector); - final roots = []; - final seen = {}; - for (final root in collector.roots) { - if (seen.add(root.anchor.offset)) roots.add(root); - } - return roots; -} - -class _BuildRootCollector extends RecursiveAstVisitor { - final roots = []; - - @override - void visitMethodDeclaration(MethodDeclaration node) { - if (node.name.lexeme == 'build') { - roots.add(BuildRoot(node.body, node, 'build')); - } - super.visitMethodDeclaration(node); - } - - @override - void visitNamedExpression(NamedExpression node) { - if (node.name.label.name == 'builder' && - node.expression is FunctionExpression && - _isConsumerBuilder(node)) { - final function = node.expression as FunctionExpression; - roots.add(BuildRoot(function.body, function, 'Consumer.builder')); - } - super.visitNamedExpression(node); - } - - bool _isConsumerBuilder(NamedExpression node) { - final arguments = node.parent; - final call = arguments?.parent; - if (call is InstanceCreationExpression) { - return call.constructorName.type.toSource().contains('Consumer'); - } - if (call is MethodInvocation) { - return call.methodName.name.toLowerCase().contains('consumer'); - } - return false; - } -} - -bool isCallbackFunction(FunctionExpression node) { - final parent = node.parent; - if (parent is NamedExpression) { - const callbackNames = { - 'onPressed', - 'onTap', - 'onChanged', - 'onSubmitted', - 'listener', - 'listenWhen', - 'callback', - 'onData', - 'onError', - 'onDone', - 'timer', - }; - return callbackNames.contains(parent.name.label.name); - } - final arguments = parent is NamedExpression ? parent.parent : parent; - final invocation = arguments?.parent; - if (invocation is MethodInvocation) { - return const { - 'forEach', - 'listen', - 'then', - 'catchError', - 'whenComplete', - 'delayed', - 'microtask', - }.contains(invocation.methodName.name); - } - if (invocation is InstanceCreationExpression) { - final type = simpleTypeName(invocation.constructorName.type.toSource()); - return type == 'Timer' || type == 'Future'; - } - return false; -} - -bool isInsideNestedFunction(AstNode node, AstNode root) { - AstNode? current = node.parent; - while (current != null && current != root) { - if (current is FunctionExpression) return true; - current = current.parent; - } - return false; -} - -bool hasEquatableImport(CompilationUnit unit) => - unit.directives.whereType().any((directive) => - (directive.uri.stringValue ?? '').contains('package:equatable/')); diff --git a/packages/flutterguard_cli/lib/src/sarif_report.dart b/packages/flutterguard_cli/lib/src/sarif_report.dart deleted file mode 100644 index 0d73aef..0000000 --- a/packages/flutterguard_cli/lib/src/sarif_report.dart +++ /dev/null @@ -1,104 +0,0 @@ -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), - }, - 'properties': { - 'framework': rule.framework, - 'confidence': rule.confidence, - }, - } - ], - }, - }, - 'results': [ - for (final issue in issues) - { - 'ruleId': issue.id, - 'level': _level(issue.level), - 'message': {'text': issue.message}, - 'properties': { - 'framework': issue.framework.name, - 'confidence': issue.confidence.name, - 'evidence': issue.evidence.take(5).toList(), - }, - 'locations': [ - { - 'physicalLocation': { - 'artifactLocation': { - 'uri': _relativeUri(issue.file, projectPath), - }, - 'region': { - 'startLine': issue.line ?? 1, - }, - }, - } - ], - } - ], - } - ], - }; - - return const JsonEncoder.withIndent(' ').convert(payload); - } - - static String _relativeUri(String filePath, String projectPath) { - final relative = - p.isWithin(projectPath, filePath) || p.equals(projectPath, filePath) - ? p.relative(filePath, from: projectPath) - : filePath; - return relative.replaceAll('\\', '/'); - } - - static String _level(RiskLevel level) { - switch (level) { - case RiskLevel.high: - return 'error'; - case RiskLevel.medium: - return 'warning'; - case RiskLevel.low: - return 'note'; - } - } - - static String _levelFromRule(String riskLevel) { - switch (riskLevel.toLowerCase()) { - case 'high': - return 'error'; - case 'medium': - return 'warning'; - default: - return 'note'; - } - } -} diff --git a/packages/flutterguard_cli/lib/src/scan_context.dart b/packages/flutterguard_cli/lib/src/scan_context.dart deleted file mode 100644 index bf2abed..0000000 --- a/packages/flutterguard_cli/lib/src/scan_context.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'config_loader.dart'; -import 'source_workspace.dart'; - -enum ScanMode { full, changed } - -class ScanContext { - final String projectPath; - final ScanConfig config; - final List allFiles; - final List targetFiles; - final ScanMode mode; - final SourceWorkspace sources; - final Set changedFiles; - - const ScanContext({ - required this.projectPath, - required this.config, - required this.allFiles, - required this.targetFiles, - required this.mode, - required this.sources, - this.changedFiles = const {}, - }); - - bool get isChanged => mode == ScanMode.changed; -} diff --git a/packages/flutterguard_cli/lib/src/scanner.dart b/packages/flutterguard_cli/lib/src/scanner.dart deleted file mode 100644 index fe81963..0000000 --- a/packages/flutterguard_cli/lib/src/scanner.dart +++ /dev/null @@ -1,220 +0,0 @@ -import 'dart:io'; - -import 'package:path/path.dart' as p; - -import 'baseline.dart'; -import 'config_loader.dart'; -import 'file_collector.dart'; -import 'path_utils.dart'; -import 'project_resolver.dart'; -import 'report_generator.dart'; -import 'scan_context.dart'; -import 'rules/catalog.dart'; -import 'sarif_report.dart'; -import 'static_issue.dart'; -import 'source_workspace.dart'; -import 'suppression.dart'; - -class ScanException implements Exception { - final String message; - - const ScanException(this.message); - - @override - String toString() => message; -} - -class ScanResult { - final String projectPath; - final String reportDir; - final List files; - final List rawIssues; - final List issues; - final int suppressedCount; - final int suppressedByBaselineCount; - final int score; - final String scanMode; - final List diagnostics; - final SourceWorkspace sources; - - const ScanResult({ - required this.projectPath, - required this.reportDir, - required this.files, - required this.rawIssues, - required this.issues, - required this.suppressedCount, - required this.suppressedByBaselineCount, - required this.score, - required this.scanMode, - required this.sources, - this.diagnostics = const [], - }); -} - -class FlutterGuardScanner { - static ScanResult scan({ - required String projectPath, - String? configPath, - String outputDir = '.flutterguard', - bool writeJson = false, - bool writeSarif = false, - @Deprecated('Color is a presentation concern; pass it to ReportGenerator.') - bool noColor = false, - bool changedOnly = false, - String base = 'main', - String? baselinePath, - bool applySuppression = true, - }) { - final resolvedProjectPath = ProjectResolver.resolveProjectPath(projectPath); - if (!Directory(resolvedProjectPath).existsSync()) { - throw ScanException( - 'Project path "$resolvedProjectPath" does not exist.', - ); - } - - final resolvedConfigPath = ProjectResolver.resolveConfigPath( - projectPath: resolvedProjectPath, - explicitConfig: configPath, - ); - final config = ScanConfig.fromFile( - resolvedConfigPath, - requireFile: configPath != null, - ); - final files = FileCollector.collect(resolvedProjectPath, config); - if (files.isEmpty) { - throw const ScanException( - 'No Dart files matched the configured include/exclude patterns.', - ); - } - var scanMode = ScanMode.full; - var filesToScan = files; - var changedFiles = {}; - - if (changedOnly) { - try { - final changed = FileCollector.getChangedFiles( - resolvedProjectPath, - base, - ); - if (changed != null) { - changedFiles = changed.map(normalizePath).toSet(); - final changedDart = - changedFiles.where((f) => f.endsWith('.dart')).toSet(); - filesToScan = files.where((f) => changedDart.contains(f)).toList(); - scanMode = ScanMode.changed; - } - } on ChangedFilesException catch (e) { - throw ScanException(e.message); - } - } - - final reportDir = p.isAbsolute(outputDir) - ? outputDir - : p.join(resolvedProjectPath, outputDir); - - final sources = SourceWorkspace(); - final context = ScanContext( - projectPath: resolvedProjectPath, - config: config, - allFiles: files, - targetFiles: filesToScan, - mode: scanMode, - sources: sources, - changedFiles: changedFiles, - ); - final rawIssues = _analyze(context); - - var issues = rawIssues; - var suppressedCount = 0; - if (applySuppression) { - final suppression = SuppressionFilter( - filesToScan, - workspace: sources, - ); - final visible = []; - for (final issue in issues) { - if (suppression.isSuppressed(issue)) { - suppressedCount++; - } else { - visible.add(issue); - } - } - issues = visible; - } - - var suppressedByBaselineCount = 0; - if (baselinePath != null) { - final resolvedBaselinePath = p.isAbsolute(baselinePath) - ? baselinePath - : p.join(resolvedProjectPath, baselinePath); - final baseline = Baseline.load(resolvedBaselinePath); - final visible = []; - for (final issue in issues) { - if (baseline.contains(issue, resolvedProjectPath)) { - suppressedByBaselineCount++; - } else { - visible.add(issue); - } - } - issues = visible; - } - - final score = ReportGenerator.calculateScore(issues); - - if (writeJson || writeSarif) { - Directory(reportDir).createSync(recursive: true); - } - if (writeJson) { - final json = ReportGenerator.generateJson( - projectPath: resolvedProjectPath, - issues: issues, - scanMode: scanMode.name, - suppressedCount: suppressedCount, - suppressedByBaselineCount: suppressedByBaselineCount, - diagnostics: sources.diagnostics, - ); - File(p.join(reportDir, 'report.json')).writeAsStringSync(json); - } - if (writeSarif) { - final sarif = SarifReport.generate( - projectPath: resolvedProjectPath, - issues: issues, - ); - File(p.join(reportDir, 'report.sarif')).writeAsStringSync(sarif); - } - - return ScanResult( - projectPath: resolvedProjectPath, - reportDir: reportDir, - files: filesToScan, - rawIssues: rawIssues, - issues: issues, - suppressedCount: suppressedCount, - suppressedByBaselineCount: suppressedByBaselineCount, - score: score, - scanMode: scanMode.name, - sources: sources, - diagnostics: sources.diagnostics, - ); - } - - static List _analyze(ScanContext context) { - final allIssues = RuleCatalog.analyze(context); - - allIssues.sort((a, b) { - final levelOrder = { - RiskLevel.high: 0, - RiskLevel.medium: 1, - RiskLevel.low: 2, - }; - final levelCompare = levelOrder[a.level]!.compareTo(levelOrder[b.level]!); - if (levelCompare != 0) return levelCompare; - final fileCompare = a.file.compareTo(b.file); - if (fileCompare != 0) return fileCompare; - return (a.line ?? 0).compareTo(b.line ?? 0); - }); - - return allIssues; - } -} diff --git a/packages/flutterguard_cli/lib/src/source_utils.dart b/packages/flutterguard_cli/lib/src/source_utils.dart deleted file mode 100644 index 89d49e0..0000000 --- a/packages/flutterguard_cli/lib/src/source_utils.dart +++ /dev/null @@ -1,4 +0,0 @@ -import 'package:analyzer/source/line_info.dart'; - -int lineNumberForOffset(LineInfo lineInfo, int offset) => - lineInfo.getLocation(offset).lineNumber; diff --git a/packages/flutterguard_cli/lib/src/source_workspace.dart b/packages/flutterguard_cli/lib/src/source_workspace.dart deleted file mode 100644 index 7e8ca95..0000000 --- a/packages/flutterguard_cli/lib/src/source_workspace.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'dart:io'; -import 'dart:convert'; - -import 'package:analyzer/dart/analysis/utilities.dart'; -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/source/line_info.dart'; - -import 'path_utils.dart'; - -enum ScanDiagnosticSeverity { warning, error } - -class ScanDiagnostic { - final String stage; - final String message; - final String? file; - final ScanDiagnosticSeverity severity; - - const ScanDiagnostic({ - required this.stage, - required this.message, - this.file, - this.severity = ScanDiagnosticSeverity.warning, - }); - - Map toJson() => { - 'stage': stage, - 'message': message, - 'file': file, - 'severity': severity.name, - }; -} - -class SourceUnit { - final String path; - final String content; - final List lines; - final CompilationUnit unit; - final LineInfo lineInfo; - - const SourceUnit({ - required this.path, - required this.content, - required this.lines, - required this.unit, - required this.lineInfo, - }); -} - -/// Per-scan source cache shared by every rule. -/// -/// A source file is read and parsed at most once. Failures are retained as -/// diagnostics instead of being silently swallowed by each rule. -class SourceWorkspace { - final Map _sources = {}; - final List _diagnostics = []; - - List get diagnostics => List.unmodifiable(_diagnostics); - - SourceUnit? source(String filePath) { - final path = normalizePath(filePath); - if (_sources.containsKey(path)) return _sources[path]; - - try { - final content = File(path).readAsStringSync(); - final parsed = parseString(content: content, path: path); - final source = SourceUnit( - path: path, - content: content, - lines: const LineSplitter().convert(content), - unit: parsed.unit, - lineInfo: parsed.lineInfo, - ); - _sources[path] = source; - return source; - } on FileSystemException catch (error) { - _recordFailure(path, 'read', error.message); - } on Object catch (error) { - _recordFailure(path, 'parse', error.toString()); - } - - _sources[path] = null; - return null; - } - - void addDiagnostic(ScanDiagnostic diagnostic) { - _diagnostics.add(diagnostic); - } - - void _recordFailure(String path, String stage, String message) { - _diagnostics.add(ScanDiagnostic( - stage: stage, - file: path, - message: message, - severity: ScanDiagnosticSeverity.error, - )); - } -} diff --git a/packages/flutterguard_cli/lib/src/static_issue.dart b/packages/flutterguard_cli/lib/src/static_issue.dart deleted file mode 100644 index ea37df8..0000000 --- a/packages/flutterguard_cli/lib/src/static_issue.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'domain.dart'; -import 'priority.dart'; - -enum RiskLevel { low, medium, high } - -enum StateManagementFramework { riverpod, bloc, provider, generic } - -enum RuleConfidence { certain, probable, informational } - -class StaticIssue { - final String id; - final String title; - final String file; - final int? line; - final RiskLevel level; - final IssueDomain domain; - final Priority priority; - final String message; - final String detail; - final String suggestion; - final Map metadata; - final StateManagementFramework framework; - final RuleConfidence confidence; - final List evidence; - - const StaticIssue({ - required this.id, - required this.title, - required this.file, - this.line, - required this.level, - required this.domain, - required this.priority, - required this.message, - this.detail = '', - required this.suggestion, - this.metadata = const {}, - this.framework = StateManagementFramework.generic, - this.confidence = RuleConfidence.certain, - this.evidence = const [], - }); - - Map toJson() => { - 'id': id, - 'ruleId': id, - 'title': title, - 'file': file, - 'line': line, - 'level': level.name, - 'severity': level.name, - 'domain': domain.name, - 'priority': priority.name, - 'message': message, - 'detail': detail, - 'suggestion': suggestion, - 'metadata': metadata, - 'framework': framework.name, - 'confidence': confidence.name, - 'evidence': evidence.take(5).toList(), - }; -} diff --git a/packages/flutterguard_cli/lib/src/suppression.dart b/packages/flutterguard_cli/lib/src/suppression.dart deleted file mode 100644 index 9bb2c47..0000000 --- a/packages/flutterguard_cli/lib/src/suppression.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'dart:io'; - -import 'source_workspace.dart'; -import 'static_issue.dart'; - -class SuppressionFilter { - final Map>> _rulesByFileAndLine = {}; - - SuppressionFilter( - Iterable files, { - SourceWorkspace? workspace, - }) { - for (final file in files) { - _rulesByFileAndLine[file] = _parseFile(file, workspace: workspace); - } - } - - bool isSuppressed(StaticIssue issue) { - final line = issue.line; - if (line == null) return false; - final byLine = _rulesByFileAndLine[issue.file]; - if (byLine == null) return false; - final rules = byLine[line]; - if (rules == null) return false; - return rules.contains('all') || rules.contains(issue.id); - } - - static Map> _parseFile( - String path, { - SourceWorkspace? workspace, - }) { - final List lines; - if (workspace == null) { - final file = File(path); - if (!file.existsSync()) return const {}; - lines = file.readAsLinesSync(); - } else { - final source = workspace.source(path); - if (source == null) return const {}; - lines = source.lines; - } - - final result = >{}; - for (var index = 0; index < lines.length; index++) { - final parsed = _parseLine(lines[index]); - if (parsed == null) continue; - final lineNumber = index + 1; - result.putIfAbsent(lineNumber, () => {}).addAll(parsed); - result.putIfAbsent(lineNumber + 1, () => {}).addAll(parsed); - } - return result; - } - - static Set? _parseLine(String line) { - final match = RegExp(r'flutterguard:\s*ignore\s+([A-Za-z0-9_,\s-]+)') - .firstMatch(line); - if (match == null) return null; - final raw = match.group(1) ?? ''; - final ids = raw - .split(',') - .map((part) => part.trim()) - .where((part) => part.isNotEmpty) - .toSet(); - if (ids.isEmpty) return null; - return ids; - } -} diff --git a/packages/flutterguard_cli/pubspec.yaml b/packages/flutterguard_cli/pubspec.yaml deleted file mode 100644 index e29ec56..0000000 --- a/packages/flutterguard_cli/pubspec.yaml +++ /dev/null @@ -1,28 +0,0 @@ -name: flutterguard_cli -description: IoT Flutter static analysis CLI for architecture enforcement, code quality, and CI gating. Scans Dart source for layer violations, lifecycle resource leaks, circular dependencies, and more. -version: 0.6.0 -repository: https://github.com/lizy-coding/flutterguard -issue_tracker: https://github.com/lizy-coding/flutterguard/issues -topics: - - static-analysis - - flutter - - iot - - architecture - - cli - -environment: - sdk: ^3.11.5 - -dependencies: - args: ^2.5.0 - analyzer: ^7.3.0 - glob: ^2.1.2 - path: ^1.9.0 - yaml: ^3.1.2 - -executables: - flutterguard: flutterguard - -dev_dependencies: - test: ^1.25.8 - lints: ^3.0.0 diff --git a/packages/flutterguard_cli/test/AGENTS.md b/packages/flutterguard_cli/test/AGENTS.md deleted file mode 100644 index 2ef2f51..0000000 --- a/packages/flutterguard_cli/test/AGENTS.md +++ /dev/null @@ -1,31 +0,0 @@ -# Test Layer - -## Responsibility -Tests verify rule behavior, scanner orchestration, report generation, and cross-platform path handling. - -## Test Files -- `scanner_test.dart`: reusable scanner/rule integration suite (78 tests, 8 groups). -- `cli_test.dart`: process-level CLI exit and report behavior (5 tests). - -## Test Groups -| Group | Tests | Coverage | -|-------|-------|---------| -| Static Rules | 18 | 8 existing rules + 5 IoT rules + config parsing + wiring | -| Report Generation | 3 | JSON, stdout, and suppression summary output | -| Scanner Orchestration | 16 | Scan policy, root pubspec, diagnostics, suppression, baseline, SARIF, issue export | -| Changed-only | 7 | Git filtering, project rules, architecture target resolution, clean scans, invalid refs, non-Git fallback, cycle behavior | -| Registry | 3 | Rule metadata lookup | -| Config Tools | 7 | Init profiles, effective config, doctor, install diagnostics | -| Path Handling | 3 | Windows globs, package imports, cross-platform import resolution | -| CLI Process | 4 | Exit codes, config scoping/enforcement, empty changed JSON report | - -## Rules -- Add a fixture for every new rule or regression case. -- Keep fixtures small unless testing size thresholds. -- Test Windows path behavior using `package:path` contexts instead of requiring Windows. -- Prefer testing reusable `lib/src/` behavior directly; only shell out to the CLI when validating argument/exit behavior. -- Temporary files created by tests must be deleted with `addTearDown`. -- pubspec_security tests use `Directory.systemTemp.createTempSync()` for isolated pubspec.yaml. - -## Required Command -Run `dart run melos run test:cli` after test changes. diff --git a/packages/flutterguard_cli/test/cli_test.dart b/packages/flutterguard_cli/test/cli_test.dart deleted file mode 100644 index e08fa1c..0000000 --- a/packages/flutterguard_cli/test/cli_test.dart +++ /dev/null @@ -1,209 +0,0 @@ -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('CLI version matches package version', () { - final pubspec = - File(p.join(Directory.current.path, 'pubspec.yaml')).readAsStringSync(); - final version = RegExp(r'^version:\s*(\S+)', multiLine: true) - .firstMatch(pubspec)! - .group(1); - final entrypoint = p.join( - Directory.current.path, - 'bin', - 'flutterguard.dart', - ); - final result = Process.runSync( - Platform.resolvedExecutable, - [entrypoint, '--version'], - workingDirectory: Directory.current.path, - ); - - expect(result.exitCode, 0); - expect((result.stdout as String).trim(), 'flutterguard $version'); - }); - - test('scan exits with setup error when the project matches no Dart files', - () { - final project = Directory.systemTemp.createTempSync( - 'flutterguard_cli_empty_project_', - ); - addTearDown(() => project.deleteSync(recursive: true)); - - final entrypoint = p.join( - Directory.current.path, - 'bin', - 'flutterguard.dart', - ); - final result = Process.runSync( - Platform.resolvedExecutable, - [ - entrypoint, - 'scan', - project.path, - '--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/fixtures/AGENTS.md b/packages/flutterguard_cli/test/fixtures/AGENTS.md deleted file mode 100644 index 1b1df79..0000000 --- a/packages/flutterguard_cli/test/fixtures/AGENTS.md +++ /dev/null @@ -1,33 +0,0 @@ -# Fixture Layer - -## Responsibility -This directory contains intentionally imperfect Dart/YAML files used by CLI rule tests (21 fixture files). - -## Fixture Inventory -| File | Rule | -|------|------| -| `large_file.dart` | large_file | -| `large_class.dart` | large_class | -| `large_build.dart` | large_build_method | -| `lifecycle_issue.dart` | lifecycle_resource_not_disposed | -| `boundary_issue.dart` + `forbidden_file.dart` | layer_violation, module_violation | -| `cycle_a/b/c.dart` | circular_dependency | -| `missing_const.dart` | missing_const_constructor | -| `iot_security_issue.dart` | iot_security | -| `device_lifecycle_issue.dart` | device_lifecycle | -| `mqtt_connection_issue.dart` | mqtt_connection | -| `ble_scanning_issue.dart` | ble_scanning | -| `architecture_config.yaml` / `architecture_disabled.yaml` | architecture config parsing | -| `generic_state.dart` | Generic build, mutability, UI dependency, and state-cycle rules | -| `riverpod_state.dart` | Riverpod read/render and watch/callback rules | -| `bloc_state.dart` | Bloc Equatable props completeness | -| `provider_state.dart` | Provider ownership and loop notification rules | -| `state_suppression.dart` | All state rules through suppression and baseline pipelines | - -## Rules -- Fixtures may intentionally violate style or architecture rules. -- Keep fixture names tied to the rule or scenario they exercise. -- Do not import app dependencies; fixtures should remain plain Dart snippets where possible. -- When adding architecture fixtures, update or add a matching YAML config. -- Avoid broad fixture changes because many tests can depend on the same file. -- pubspec_security tests create isolated YAML fixtures in temp directories. diff --git a/packages/flutterguard_cli/test/fixtures/architecture_config.yaml b/packages/flutterguard_cli/test/fixtures/architecture_config.yaml deleted file mode 100644 index c74f5d5..0000000 --- a/packages/flutterguard_cli/test/fixtures/architecture_config.yaml +++ /dev/null @@ -1,31 +0,0 @@ -include: - - "**/*.dart" -exclude: [] -rules: - large_file: - enabled: true - maxLines: 500 - large_class: - enabled: true - maxLines: 300 - large_build_method: - enabled: true - maxLines: 80 - lifecycle_resource: - enabled: true -architecture: - layers: - - name: ui - path: "**/boundary_issue.dart" - allowed_deps: [] - - name: model - path: "**/forbidden_file.dart" - allowed_deps: [] - modules: - - name: feature_a - path: "**/boundary_issue.dart" - allowed_deps: [] - - name: feature_b - path: "**/forbidden_file.dart" - allowed_deps: [] - detect_cycles: false diff --git a/packages/flutterguard_cli/test/fixtures/architecture_disabled.yaml b/packages/flutterguard_cli/test/fixtures/architecture_disabled.yaml deleted file mode 100644 index 0681672..0000000 --- a/packages/flutterguard_cli/test/fixtures/architecture_disabled.yaml +++ /dev/null @@ -1,35 +0,0 @@ -include: - - "**/*.dart" -exclude: [] -rules: - large_file: - enabled: true - maxLines: 500 - large_class: - enabled: true - maxLines: 300 - large_build_method: - enabled: true - maxLines: 80 - lifecycle_resource: - enabled: true -architecture: - layers: - - name: ui - path: "**/boundary_issue.dart" - allowed_deps: [] - - name: model - path: "**/forbidden_file.dart" - allowed_deps: [] - modules: - - name: feature_a - path: "**/boundary_issue.dart" - allowed_deps: [] - - name: feature_b - path: "**/forbidden_file.dart" - allowed_deps: [] - detect_cycles: false - layer_violation: - enabled: false - module_violation: - enabled: false diff --git a/packages/flutterguard_cli/test/fixtures/ble_scanning_issue.dart b/packages/flutterguard_cli/test/fixtures/ble_scanning_issue.dart deleted file mode 100644 index c6fdaf7..0000000 --- a/packages/flutterguard_cli/test/fixtures/ble_scanning_issue.dart +++ /dev/null @@ -1,17 +0,0 @@ -// ignore_for_file: unused_field -// Fixture: BLE scanning issues -class BleDevice {} - -class BleService { - late BleDevice _device; - - void startScan() { - // scanning without timeout - } - - void connect() { - // connecting to device - } - - // stopScan() and disconnect() are missing -} diff --git a/packages/flutterguard_cli/test/fixtures/bloc_state.dart b/packages/flutterguard_cli/test/fixtures/bloc_state.dart deleted file mode 100644 index 88c2c72..0000000 --- a/packages/flutterguard_cli/test/fixtures/bloc_state.dart +++ /dev/null @@ -1,34 +0,0 @@ -class DeviceState extends Equatable { - const DeviceState(this.name, this.connected); - - final String name; - final bool connected; - - List get props => [name]; -} - -class ReadingState extends Equatable { - const ReadingState(this.temperature, this.humidity); - - final double temperature; - final double humidity; - - List get props => [temperature]; -} - -class CompleteState extends Equatable { - const CompleteState(this.a, this.b); - - final int a; - final int b; - - List get props => [a, b]; -} - -class EmptyState extends Equatable { - const EmptyState(); - - static const version = 1; - int get computed => 1; - List get props => const []; -} diff --git a/packages/flutterguard_cli/test/fixtures/boundary_issue.dart b/packages/flutterguard_cli/test/fixtures/boundary_issue.dart deleted file mode 100644 index dd675dc..0000000 --- a/packages/flutterguard_cli/test/fixtures/boundary_issue.dart +++ /dev/null @@ -1,5 +0,0 @@ -// ignore_for_file: unused_element -import 'forbidden_file.dart'; - -// Fixture: boundary import violation -class BoundaryIssueWidget extends ForbiddenWidget {} diff --git a/packages/flutterguard_cli/test/fixtures/cycle_a.dart b/packages/flutterguard_cli/test/fixtures/cycle_a.dart deleted file mode 100644 index 690c60b..0000000 --- a/packages/flutterguard_cli/test/fixtures/cycle_a.dart +++ /dev/null @@ -1,7 +0,0 @@ -// Fixture: circular dependency (part of cycle) -import 'cycle_b.dart'; - -class CycleA { - final CycleB b; - CycleA(this.b); -} diff --git a/packages/flutterguard_cli/test/fixtures/cycle_b.dart b/packages/flutterguard_cli/test/fixtures/cycle_b.dart deleted file mode 100644 index 440a173..0000000 --- a/packages/flutterguard_cli/test/fixtures/cycle_b.dart +++ /dev/null @@ -1,7 +0,0 @@ -// Fixture: circular dependency (part of cycle) -import 'cycle_c.dart'; - -class CycleB { - final CycleC c; - CycleB(this.c); -} diff --git a/packages/flutterguard_cli/test/fixtures/cycle_c.dart b/packages/flutterguard_cli/test/fixtures/cycle_c.dart deleted file mode 100644 index 62d0556..0000000 --- a/packages/flutterguard_cli/test/fixtures/cycle_c.dart +++ /dev/null @@ -1,7 +0,0 @@ -// Fixture: circular dependency (part of cycle) -import 'cycle_a.dart'; - -class CycleC { - final CycleA a; - CycleC(this.a); -} diff --git a/packages/flutterguard_cli/test/fixtures/device_lifecycle_issue.dart b/packages/flutterguard_cli/test/fixtures/device_lifecycle_issue.dart deleted file mode 100644 index 477932c..0000000 --- a/packages/flutterguard_cli/test/fixtures/device_lifecycle_issue.dart +++ /dev/null @@ -1,8 +0,0 @@ -// Fixture: device lifecycle issue — initState without dispose -class DeviceWidget { - void initState() { - // connect to device - } - - // dispose() is missing -} diff --git a/packages/flutterguard_cli/test/fixtures/forbidden_file.dart b/packages/flutterguard_cli/test/fixtures/forbidden_file.dart deleted file mode 100644 index dfa55fa..0000000 --- a/packages/flutterguard_cli/test/fixtures/forbidden_file.dart +++ /dev/null @@ -1,2 +0,0 @@ -// Forbidden file that boundary rules should catch -class ForbiddenWidget {} diff --git a/packages/flutterguard_cli/test/fixtures/generic_state.dart b/packages/flutterguard_cli/test/fixtures/generic_state.dart deleted file mode 100644 index fb7e4ba..0000000 --- a/packages/flutterguard_cli/test/fixtures/generic_state.dart +++ /dev/null @@ -1,90 +0,0 @@ -class Widget { - Widget({Object? onPressed}); -} - -class BuildContext {} - -class StatelessWidget {} - -class DeviceController { - DeviceController(); - - final List _items = []; - List get items => _items; - - void mutate() { - state.items.add(1); - } -} - -class DeviceBloc {} - -class BadBuildWidget extends StatelessWidget { - Widget build(BuildContext context) { - notifyListeners(); - final controller = DeviceController(); - return Widget(); - } -} - -class SecondBadBuildWidget extends StatelessWidget { - Widget build(BuildContext context) { - setState(() {}); - final bloc = DeviceBloc(); - return Widget(); - } -} - -class SafeBuildWidget extends StatelessWidget { - Widget build(BuildContext context) { - final values = []; - values.add(1); - return Widget(onPressed: () { - notifyListeners(); - final controller = DeviceController(); - }); - } -} - -class DeviceState { - int count = 0; - final List items = []; -} - -class SafeState { - final List items = List.unmodifiable(const []); -} - -class State {} - -class Page {} - -class PageState extends State { - int widgetCounter = 0; -} - -class NavigationController { - NavigationController(this.context); - - final BuildContext context; - - void open() { - Navigator.of(context).push('/device'); - } -} - -class ThemeController { - void update(Widget widget) {} -} - -class CycleController { - CycleController(this.service); - - final CycleService service; -} - -class CycleService { - CycleService(this.controller); - - final CycleController controller; -} diff --git a/packages/flutterguard_cli/test/fixtures/iot_security_issue.dart b/packages/flutterguard_cli/test/fixtures/iot_security_issue.dart deleted file mode 100644 index 422822f..0000000 --- a/packages/flutterguard_cli/test/fixtures/iot_security_issue.dart +++ /dev/null @@ -1,17 +0,0 @@ -// ignore_for_file: unused_local_variable -// Fixture: IoT security issues -class IotSecurityWidget { - void connect() { - // hardcoded credential - final password = "admin123"; - - // cleartext MQTT - final brokerUrl = "tcp://192.168.1.100:1883"; - - // cleartext HTTP - final apiUrl = "http://iot.example.com/api/data"; - - // insecure BLE - final bleConfig = "withoutBonding"; - } -} diff --git a/packages/flutterguard_cli/test/fixtures/large_build.dart b/packages/flutterguard_cli/test/fixtures/large_build.dart deleted file mode 100644 index 859a000..0000000 --- a/packages/flutterguard_cli/test/fixtures/large_build.dart +++ /dev/null @@ -1,105 +0,0 @@ -class Widget {} -class BuildContext {} -class StatelessWidget {} -class SizedBox { - const SizedBox({double? height}); -} -class Column { - const Column({List children}); -} -class Text { - const Text(String data); -} - -class LargeBuildWidget extends StatelessWidget { - Widget build(BuildContext context) { - return Column( - children: const [ - SizedBox(height: 1), - SizedBox(height: 2), - SizedBox(height: 3), - SizedBox(height: 4), - SizedBox(height: 5), - SizedBox(height: 6), - SizedBox(height: 7), - SizedBox(height: 8), - SizedBox(height: 9), - SizedBox(height: 10), - SizedBox(height: 11), - SizedBox(height: 12), - SizedBox(height: 13), - SizedBox(height: 14), - SizedBox(height: 15), - SizedBox(height: 16), - SizedBox(height: 17), - SizedBox(height: 18), - SizedBox(height: 19), - SizedBox(height: 20), - SizedBox(height: 21), - SizedBox(height: 22), - SizedBox(height: 23), - SizedBox(height: 24), - SizedBox(height: 25), - SizedBox(height: 26), - SizedBox(height: 27), - SizedBox(height: 28), - SizedBox(height: 29), - SizedBox(height: 30), - SizedBox(height: 31), - SizedBox(height: 32), - SizedBox(height: 33), - SizedBox(height: 34), - SizedBox(height: 35), - SizedBox(height: 36), - SizedBox(height: 37), - SizedBox(height: 38), - SizedBox(height: 39), - SizedBox(height: 40), - SizedBox(height: 41), - SizedBox(height: 42), - SizedBox(height: 43), - SizedBox(height: 44), - SizedBox(height: 45), - SizedBox(height: 46), - SizedBox(height: 47), - SizedBox(height: 48), - SizedBox(height: 49), - SizedBox(height: 50), - SizedBox(height: 51), - SizedBox(height: 52), - SizedBox(height: 53), - SizedBox(height: 54), - SizedBox(height: 55), - SizedBox(height: 56), - SizedBox(height: 57), - SizedBox(height: 58), - SizedBox(height: 59), - SizedBox(height: 60), - SizedBox(height: 61), - SizedBox(height: 62), - SizedBox(height: 63), - SizedBox(height: 64), - SizedBox(height: 65), - SizedBox(height: 66), - SizedBox(height: 67), - SizedBox(height: 68), - SizedBox(height: 69), - SizedBox(height: 70), - SizedBox(height: 71), - SizedBox(height: 72), - SizedBox(height: 73), - SizedBox(height: 74), - SizedBox(height: 75), - SizedBox(height: 76), - SizedBox(height: 77), - SizedBox(height: 78), - SizedBox(height: 79), - SizedBox(height: 80), - SizedBox(height: 81), - SizedBox(height: 82), - SizedBox(height: 83), - Text('end'), - ], - ); - } -} diff --git a/packages/flutterguard_cli/test/fixtures/large_class.dart b/packages/flutterguard_cli/test/fixtures/large_class.dart deleted file mode 100644 index 2707bac..0000000 --- a/packages/flutterguard_cli/test/fixtures/large_class.dart +++ /dev/null @@ -1,303 +0,0 @@ -class LargeClass { - // filler line 1 to make this class very large at least 300 lines long for testing purposes - // filler line 2 to make this class very large at least 300 lines long for testing purposes - // filler line 3 to make this class very large at least 300 lines long for testing purposes - // filler line 4 to make this class very large at least 300 lines long for testing purposes - // filler line 5 to make this class very large at least 300 lines long for testing purposes - // filler line 6 to make this class very large at least 300 lines long for testing purposes - // filler line 7 to make this class very large at least 300 lines long for testing purposes - // filler line 8 to make this class very large at least 300 lines long for testing purposes - // filler line 9 to make this class very large at least 300 lines long for testing purposes - // filler line 10 to make this class very large at least 300 lines long for testing purposes - // filler line 11 to make this class very large at least 300 lines long for testing purposes - // filler line 12 to make this class very large at least 300 lines long for testing purposes - // filler line 13 to make this class very large at least 300 lines long for testing purposes - // filler line 14 to make this class very large at least 300 lines long for testing purposes - // filler line 15 to make this class very large at least 300 lines long for testing purposes - // filler line 16 to make this class very large at least 300 lines long for testing purposes - // filler line 17 to make this class very large at least 300 lines long for testing purposes - // filler line 18 to make this class very large at least 300 lines long for testing purposes - // filler line 19 to make this class very large at least 300 lines long for testing purposes - // filler line 20 to make this class very large at least 300 lines long for testing purposes - // filler line 21 to make this class very large at least 300 lines long for testing purposes - // filler line 22 to make this class very large at least 300 lines long for testing purposes - // filler line 23 to make this class very large at least 300 lines long for testing purposes - // filler line 24 to make this class very large at least 300 lines long for testing purposes - // filler line 25 to make this class very large at least 300 lines long for testing purposes - // filler line 26 to make this class very large at least 300 lines long for testing purposes - // filler line 27 to make this class very large at least 300 lines long for testing purposes - // filler line 28 to make this class very large at least 300 lines long for testing purposes - // filler line 29 to make this class very large at least 300 lines long for testing purposes - // filler line 30 to make this class very large at least 300 lines long for testing purposes - // filler line 31 to make this class very large at least 300 lines long for testing purposes - // filler line 32 to make this class very large at least 300 lines long for testing purposes - // filler line 33 to make this class very large at least 300 lines long for testing purposes - // filler line 34 to make this class very large at least 300 lines long for testing purposes - // filler line 35 to make this class very large at least 300 lines long for testing purposes - // filler line 36 to make this class very large at least 300 lines long for testing purposes - // filler line 37 to make this class very large at least 300 lines long for testing purposes - // filler line 38 to make this class very large at least 300 lines long for testing purposes - // filler line 39 to make this class very large at least 300 lines long for testing purposes - // filler line 40 to make this class very large at least 300 lines long for testing purposes - // filler line 41 to make this class very large at least 300 lines long for testing purposes - // filler line 42 to make this class very large at least 300 lines long for testing purposes - // filler line 43 to make this class very large at least 300 lines long for testing purposes - // filler line 44 to make this class very large at least 300 lines long for testing purposes - // filler line 45 to make this class very large at least 300 lines long for testing purposes - // filler line 46 to make this class very large at least 300 lines long for testing purposes - // filler line 47 to make this class very large at least 300 lines long for testing purposes - // filler line 48 to make this class very large at least 300 lines long for testing purposes - // filler line 49 to make this class very large at least 300 lines long for testing purposes - // filler line 50 to make this class very large at least 300 lines long for testing purposes - // filler line 51 to make this class very large at least 300 lines long for testing purposes - // filler line 52 to make this class very large at least 300 lines long for testing purposes - // filler line 53 to make this class very large at least 300 lines long for testing purposes - // filler line 54 to make this class very large at least 300 lines long for testing purposes - // filler line 55 to make this class very large at least 300 lines long for testing purposes - // filler line 56 to make this class very large at least 300 lines long for testing purposes - // filler line 57 to make this class very large at least 300 lines long for testing purposes - // filler line 58 to make this class very large at least 300 lines long for testing purposes - // filler line 59 to make this class very large at least 300 lines long for testing purposes - // filler line 60 to make this class very large at least 300 lines long for testing purposes - // filler line 61 to make this class very large at least 300 lines long for testing purposes - // filler line 62 to make this class very large at least 300 lines long for testing purposes - // filler line 63 to make this class very large at least 300 lines long for testing purposes - // filler line 64 to make this class very large at least 300 lines long for testing purposes - // filler line 65 to make this class very large at least 300 lines long for testing purposes - // filler line 66 to make this class very large at least 300 lines long for testing purposes - // filler line 67 to make this class very large at least 300 lines long for testing purposes - // filler line 68 to make this class very large at least 300 lines long for testing purposes - // filler line 69 to make this class very large at least 300 lines long for testing purposes - // filler line 70 to make this class very large at least 300 lines long for testing purposes - // filler line 71 to make this class very large at least 300 lines long for testing purposes - // filler line 72 to make this class very large at least 300 lines long for testing purposes - // filler line 73 to make this class very large at least 300 lines long for testing purposes - // filler line 74 to make this class very large at least 300 lines long for testing purposes - // filler line 75 to make this class very large at least 300 lines long for testing purposes - // filler line 76 to make this class very large at least 300 lines long for testing purposes - // filler line 77 to make this class very large at least 300 lines long for testing purposes - // filler line 78 to make this class very large at least 300 lines long for testing purposes - // filler line 79 to make this class very large at least 300 lines long for testing purposes - // filler line 80 to make this class very large at least 300 lines long for testing purposes - // filler line 81 to make this class very large at least 300 lines long for testing purposes - // filler line 82 to make this class very large at least 300 lines long for testing purposes - // filler line 83 to make this class very large at least 300 lines long for testing purposes - // filler line 84 to make this class very large at least 300 lines long for testing purposes - // filler line 85 to make this class very large at least 300 lines long for testing purposes - // filler line 86 to make this class very large at least 300 lines long for testing purposes - // filler line 87 to make this class very large at least 300 lines long for testing purposes - // filler line 88 to make this class very large at least 300 lines long for testing purposes - // filler line 89 to make this class very large at least 300 lines long for testing purposes - // filler line 90 to make this class very large at least 300 lines long for testing purposes - // filler line 91 to make this class very large at least 300 lines long for testing purposes - // filler line 92 to make this class very large at least 300 lines long for testing purposes - // filler line 93 to make this class very large at least 300 lines long for testing purposes - // filler line 94 to make this class very large at least 300 lines long for testing purposes - // filler line 95 to make this class very large at least 300 lines long for testing purposes - // filler line 96 to make this class very large at least 300 lines long for testing purposes - // filler line 97 to make this class very large at least 300 lines long for testing purposes - // filler line 98 to make this class very large at least 300 lines long for testing purposes - // filler line 99 to make this class very large at least 300 lines long for testing purposes - // filler line 100 to make this class very large at least 300 lines long for testing purposes - // filler line 101 to make this class very large at least 300 lines long for testing purposes - // filler line 102 to make this class very large at least 300 lines long for testing purposes - // filler line 103 to make this class very large at least 300 lines long for testing purposes - // filler line 104 to make this class very large at least 300 lines long for testing purposes - // filler line 105 to make this class very large at least 300 lines long for testing purposes - // filler line 106 to make this class very large at least 300 lines long for testing purposes - // filler line 107 to make this class very large at least 300 lines long for testing purposes - // filler line 108 to make this class very large at least 300 lines long for testing purposes - // filler line 109 to make this class very large at least 300 lines long for testing purposes - // filler line 110 to make this class very large at least 300 lines long for testing purposes - // filler line 111 to make this class very large at least 300 lines long for testing purposes - // filler line 112 to make this class very large at least 300 lines long for testing purposes - // filler line 113 to make this class very large at least 300 lines long for testing purposes - // filler line 114 to make this class very large at least 300 lines long for testing purposes - // filler line 115 to make this class very large at least 300 lines long for testing purposes - // filler line 116 to make this class very large at least 300 lines long for testing purposes - // filler line 117 to make this class very large at least 300 lines long for testing purposes - // filler line 118 to make this class very large at least 300 lines long for testing purposes - // filler line 119 to make this class very large at least 300 lines long for testing purposes - // filler line 120 to make this class very large at least 300 lines long for testing purposes - // filler line 121 to make this class very large at least 300 lines long for testing purposes - // filler line 122 to make this class very large at least 300 lines long for testing purposes - // filler line 123 to make this class very large at least 300 lines long for testing purposes - // filler line 124 to make this class very large at least 300 lines long for testing purposes - // filler line 125 to make this class very large at least 300 lines long for testing purposes - // filler line 126 to make this class very large at least 300 lines long for testing purposes - // filler line 127 to make this class very large at least 300 lines long for testing purposes - // filler line 128 to make this class very large at least 300 lines long for testing purposes - // filler line 129 to make this class very large at least 300 lines long for testing purposes - // filler line 130 to make this class very large at least 300 lines long for testing purposes - // filler line 131 to make this class very large at least 300 lines long for testing purposes - // filler line 132 to make this class very large at least 300 lines long for testing purposes - // filler line 133 to make this class very large at least 300 lines long for testing purposes - // filler line 134 to make this class very large at least 300 lines long for testing purposes - // filler line 135 to make this class very large at least 300 lines long for testing purposes - // filler line 136 to make this class very large at least 300 lines long for testing purposes - // filler line 137 to make this class very large at least 300 lines long for testing purposes - // filler line 138 to make this class very large at least 300 lines long for testing purposes - // filler line 139 to make this class very large at least 300 lines long for testing purposes - // filler line 140 to make this class very large at least 300 lines long for testing purposes - // filler line 141 to make this class very large at least 300 lines long for testing purposes - // filler line 142 to make this class very large at least 300 lines long for testing purposes - // filler line 143 to make this class very large at least 300 lines long for testing purposes - // filler line 144 to make this class very large at least 300 lines long for testing purposes - // filler line 145 to make this class very large at least 300 lines long for testing purposes - // filler line 146 to make this class very large at least 300 lines long for testing purposes - // filler line 147 to make this class very large at least 300 lines long for testing purposes - // filler line 148 to make this class very large at least 300 lines long for testing purposes - // filler line 149 to make this class very large at least 300 lines long for testing purposes - // filler line 150 to make this class very large at least 300 lines long for testing purposes - // filler line 151 to make this class very large at least 300 lines long for testing purposes - // filler line 152 to make this class very large at least 300 lines long for testing purposes - // filler line 153 to make this class very large at least 300 lines long for testing purposes - // filler line 154 to make this class very large at least 300 lines long for testing purposes - // filler line 155 to make this class very large at least 300 lines long for testing purposes - // filler line 156 to make this class very large at least 300 lines long for testing purposes - // filler line 157 to make this class very large at least 300 lines long for testing purposes - // filler line 158 to make this class very large at least 300 lines long for testing purposes - // filler line 159 to make this class very large at least 300 lines long for testing purposes - // filler line 160 to make this class very large at least 300 lines long for testing purposes - // filler line 161 to make this class very large at least 300 lines long for testing purposes - // filler line 162 to make this class very large at least 300 lines long for testing purposes - // filler line 163 to make this class very large at least 300 lines long for testing purposes - // filler line 164 to make this class very large at least 300 lines long for testing purposes - // filler line 165 to make this class very large at least 300 lines long for testing purposes - // filler line 166 to make this class very large at least 300 lines long for testing purposes - // filler line 167 to make this class very large at least 300 lines long for testing purposes - // filler line 168 to make this class very large at least 300 lines long for testing purposes - // filler line 169 to make this class very large at least 300 lines long for testing purposes - // filler line 170 to make this class very large at least 300 lines long for testing purposes - // filler line 171 to make this class very large at least 300 lines long for testing purposes - // filler line 172 to make this class very large at least 300 lines long for testing purposes - // filler line 173 to make this class very large at least 300 lines long for testing purposes - // filler line 174 to make this class very large at least 300 lines long for testing purposes - // filler line 175 to make this class very large at least 300 lines long for testing purposes - // filler line 176 to make this class very large at least 300 lines long for testing purposes - // filler line 177 to make this class very large at least 300 lines long for testing purposes - // filler line 178 to make this class very large at least 300 lines long for testing purposes - // filler line 179 to make this class very large at least 300 lines long for testing purposes - // filler line 180 to make this class very large at least 300 lines long for testing purposes - // filler line 181 to make this class very large at least 300 lines long for testing purposes - // filler line 182 to make this class very large at least 300 lines long for testing purposes - // filler line 183 to make this class very large at least 300 lines long for testing purposes - // filler line 184 to make this class very large at least 300 lines long for testing purposes - // filler line 185 to make this class very large at least 300 lines long for testing purposes - // filler line 186 to make this class very large at least 300 lines long for testing purposes - // filler line 187 to make this class very large at least 300 lines long for testing purposes - // filler line 188 to make this class very large at least 300 lines long for testing purposes - // filler line 189 to make this class very large at least 300 lines long for testing purposes - // filler line 190 to make this class very large at least 300 lines long for testing purposes - // filler line 191 to make this class very large at least 300 lines long for testing purposes - // filler line 192 to make this class very large at least 300 lines long for testing purposes - // filler line 193 to make this class very large at least 300 lines long for testing purposes - // filler line 194 to make this class very large at least 300 lines long for testing purposes - // filler line 195 to make this class very large at least 300 lines long for testing purposes - // filler line 196 to make this class very large at least 300 lines long for testing purposes - // filler line 197 to make this class very large at least 300 lines long for testing purposes - // filler line 198 to make this class very large at least 300 lines long for testing purposes - // filler line 199 to make this class very large at least 300 lines long for testing purposes - // filler line 200 to make this class very large at least 300 lines long for testing purposes - // filler line 201 to make this class very large at least 300 lines long for testing purposes - // filler line 202 to make this class very large at least 300 lines long for testing purposes - // filler line 203 to make this class very large at least 300 lines long for testing purposes - // filler line 204 to make this class very large at least 300 lines long for testing purposes - // filler line 205 to make this class very large at least 300 lines long for testing purposes - // filler line 206 to make this class very large at least 300 lines long for testing purposes - // filler line 207 to make this class very large at least 300 lines long for testing purposes - // filler line 208 to make this class very large at least 300 lines long for testing purposes - // filler line 209 to make this class very large at least 300 lines long for testing purposes - // filler line 210 to make this class very large at least 300 lines long for testing purposes - // filler line 211 to make this class very large at least 300 lines long for testing purposes - // filler line 212 to make this class very large at least 300 lines long for testing purposes - // filler line 213 to make this class very large at least 300 lines long for testing purposes - // filler line 214 to make this class very large at least 300 lines long for testing purposes - // filler line 215 to make this class very large at least 300 lines long for testing purposes - // filler line 216 to make this class very large at least 300 lines long for testing purposes - // filler line 217 to make this class very large at least 300 lines long for testing purposes - // filler line 218 to make this class very large at least 300 lines long for testing purposes - // filler line 219 to make this class very large at least 300 lines long for testing purposes - // filler line 220 to make this class very large at least 300 lines long for testing purposes - // filler line 221 to make this class very large at least 300 lines long for testing purposes - // filler line 222 to make this class very large at least 300 lines long for testing purposes - // filler line 223 to make this class very large at least 300 lines long for testing purposes - // filler line 224 to make this class very large at least 300 lines long for testing purposes - // filler line 225 to make this class very large at least 300 lines long for testing purposes - // filler line 226 to make this class very large at least 300 lines long for testing purposes - // filler line 227 to make this class very large at least 300 lines long for testing purposes - // filler line 228 to make this class very large at least 300 lines long for testing purposes - // filler line 229 to make this class very large at least 300 lines long for testing purposes - // filler line 230 to make this class very large at least 300 lines long for testing purposes - // filler line 231 to make this class very large at least 300 lines long for testing purposes - // filler line 232 to make this class very large at least 300 lines long for testing purposes - // filler line 233 to make this class very large at least 300 lines long for testing purposes - // filler line 234 to make this class very large at least 300 lines long for testing purposes - // filler line 235 to make this class very large at least 300 lines long for testing purposes - // filler line 236 to make this class very large at least 300 lines long for testing purposes - // filler line 237 to make this class very large at least 300 lines long for testing purposes - // filler line 238 to make this class very large at least 300 lines long for testing purposes - // filler line 239 to make this class very large at least 300 lines long for testing purposes - // filler line 240 to make this class very large at least 300 lines long for testing purposes - // filler line 241 to make this class very large at least 300 lines long for testing purposes - // filler line 242 to make this class very large at least 300 lines long for testing purposes - // filler line 243 to make this class very large at least 300 lines long for testing purposes - // filler line 244 to make this class very large at least 300 lines long for testing purposes - // filler line 245 to make this class very large at least 300 lines long for testing purposes - // filler line 246 to make this class very large at least 300 lines long for testing purposes - // filler line 247 to make this class very large at least 300 lines long for testing purposes - // filler line 248 to make this class very large at least 300 lines long for testing purposes - // filler line 249 to make this class very large at least 300 lines long for testing purposes - // filler line 250 to make this class very large at least 300 lines long for testing purposes - // filler line 251 to make this class very large at least 300 lines long for testing purposes - // filler line 252 to make this class very large at least 300 lines long for testing purposes - // filler line 253 to make this class very large at least 300 lines long for testing purposes - // filler line 254 to make this class very large at least 300 lines long for testing purposes - // filler line 255 to make this class very large at least 300 lines long for testing purposes - // filler line 256 to make this class very large at least 300 lines long for testing purposes - // filler line 257 to make this class very large at least 300 lines long for testing purposes - // filler line 258 to make this class very large at least 300 lines long for testing purposes - // filler line 259 to make this class very large at least 300 lines long for testing purposes - // filler line 260 to make this class very large at least 300 lines long for testing purposes - // filler line 261 to make this class very large at least 300 lines long for testing purposes - // filler line 262 to make this class very large at least 300 lines long for testing purposes - // filler line 263 to make this class very large at least 300 lines long for testing purposes - // filler line 264 to make this class very large at least 300 lines long for testing purposes - // filler line 265 to make this class very large at least 300 lines long for testing purposes - // filler line 266 to make this class very large at least 300 lines long for testing purposes - // filler line 267 to make this class very large at least 300 lines long for testing purposes - // filler line 268 to make this class very large at least 300 lines long for testing purposes - // filler line 269 to make this class very large at least 300 lines long for testing purposes - // filler line 270 to make this class very large at least 300 lines long for testing purposes - // filler line 271 to make this class very large at least 300 lines long for testing purposes - // filler line 272 to make this class very large at least 300 lines long for testing purposes - // filler line 273 to make this class very large at least 300 lines long for testing purposes - // filler line 274 to make this class very large at least 300 lines long for testing purposes - // filler line 275 to make this class very large at least 300 lines long for testing purposes - // filler line 276 to make this class very large at least 300 lines long for testing purposes - // filler line 277 to make this class very large at least 300 lines long for testing purposes - // filler line 278 to make this class very large at least 300 lines long for testing purposes - // filler line 279 to make this class very large at least 300 lines long for testing purposes - // filler line 280 to make this class very large at least 300 lines long for testing purposes - // filler line 281 to make this class very large at least 300 lines long for testing purposes - // filler line 282 to make this class very large at least 300 lines long for testing purposes - // filler line 283 to make this class very large at least 300 lines long for testing purposes - // filler line 284 to make this class very large at least 300 lines long for testing purposes - // filler line 285 to make this class very large at least 300 lines long for testing purposes - // filler line 286 to make this class very large at least 300 lines long for testing purposes - // filler line 287 to make this class very large at least 300 lines long for testing purposes - // filler line 288 to make this class very large at least 300 lines long for testing purposes - // filler line 289 to make this class very large at least 300 lines long for testing purposes - // filler line 290 to make this class very large at least 300 lines long for testing purposes - // filler line 291 to make this class very large at least 300 lines long for testing purposes - // filler line 292 to make this class very large at least 300 lines long for testing purposes - // filler line 293 to make this class very large at least 300 lines long for testing purposes - // filler line 294 to make this class very large at least 300 lines long for testing purposes - // filler line 295 to make this class very large at least 300 lines long for testing purposes - // filler line 296 to make this class very large at least 300 lines long for testing purposes - // filler line 297 to make this class very large at least 300 lines long for testing purposes - // filler line 298 to make this class very large at least 300 lines long for testing purposes - // filler line 299 to make this class very large at least 300 lines long for testing purposes - // filler line 300 to make this class very large at least 300 lines long for testing purposes - // filler line 301 to make this class very large at least 300 lines long for testing purposes -} diff --git a/packages/flutterguard_cli/test/fixtures/large_file.dart b/packages/flutterguard_cli/test/fixtures/large_file.dart deleted file mode 100644 index 1a20974..0000000 --- a/packages/flutterguard_cli/test/fixtures/large_file.dart +++ /dev/null @@ -1,501 +0,0 @@ -// Line 1 of a very large file -// Line 2 of a very large file -// Line 3 of a very large file -// Line 4 of a very large file -// Line 5 of a very large file -// Line 6 of a very large file -// Line 7 of a very large file -// Line 8 of a very large file -// Line 9 of a very large file -// Line 10 of a very large file -// Line 11 of a very large file -// Line 12 of a very large file -// Line 13 of a very large file -// Line 14 of a very large file -// Line 15 of a very large file -// Line 16 of a very large file -// Line 17 of a very large file -// Line 18 of a very large file -// Line 19 of a very large file -// Line 20 of a very large file -// Line 21 of a very large file -// Line 22 of a very large file -// Line 23 of a very large file -// Line 24 of a very large file -// Line 25 of a very large file -// Line 26 of a very large file -// Line 27 of a very large file -// Line 28 of a very large file -// Line 29 of a very large file -// Line 30 of a very large file -// Line 31 of a very large file -// Line 32 of a very large file -// Line 33 of a very large file -// Line 34 of a very large file -// Line 35 of a very large file -// Line 36 of a very large file -// Line 37 of a very large file -// Line 38 of a very large file -// Line 39 of a very large file -// Line 40 of a very large file -// Line 41 of a very large file -// Line 42 of a very large file -// Line 43 of a very large file -// Line 44 of a very large file -// Line 45 of a very large file -// Line 46 of a very large file -// Line 47 of a very large file -// Line 48 of a very large file -// Line 49 of a very large file -// Line 50 of a very large file -// Line 51 of a very large file -// Line 52 of a very large file -// Line 53 of a very large file -// Line 54 of a very large file -// Line 55 of a very large file -// Line 56 of a very large file -// Line 57 of a very large file -// Line 58 of a very large file -// Line 59 of a very large file -// Line 60 of a very large file -// Line 61 of a very large file -// Line 62 of a very large file -// Line 63 of a very large file -// Line 64 of a very large file -// Line 65 of a very large file -// Line 66 of a very large file -// Line 67 of a very large file -// Line 68 of a very large file -// Line 69 of a very large file -// Line 70 of a very large file -// Line 71 of a very large file -// Line 72 of a very large file -// Line 73 of a very large file -// Line 74 of a very large file -// Line 75 of a very large file -// Line 76 of a very large file -// Line 77 of a very large file -// Line 78 of a very large file -// Line 79 of a very large file -// Line 80 of a very large file -// Line 81 of a very large file -// Line 82 of a very large file -// Line 83 of a very large file -// Line 84 of a very large file -// Line 85 of a very large file -// Line 86 of a very large file -// Line 87 of a very large file -// Line 88 of a very large file -// Line 89 of a very large file -// Line 90 of a very large file -// Line 91 of a very large file -// Line 92 of a very large file -// Line 93 of a very large file -// Line 94 of a very large file -// Line 95 of a very large file -// Line 96 of a very large file -// Line 97 of a very large file -// Line 98 of a very large file -// Line 99 of a very large file -// Line 100 of a very large file -// Line 101 of a very large file -// Line 102 of a very large file -// Line 103 of a very large file -// Line 104 of a very large file -// Line 105 of a very large file -// Line 106 of a very large file -// Line 107 of a very large file -// Line 108 of a very large file -// Line 109 of a very large file -// Line 110 of a very large file -// Line 111 of a very large file -// Line 112 of a very large file -// Line 113 of a very large file -// Line 114 of a very large file -// Line 115 of a very large file -// Line 116 of a very large file -// Line 117 of a very large file -// Line 118 of a very large file -// Line 119 of a very large file -// Line 120 of a very large file -// Line 121 of a very large file -// Line 122 of a very large file -// Line 123 of a very large file -// Line 124 of a very large file -// Line 125 of a very large file -// Line 126 of a very large file -// Line 127 of a very large file -// Line 128 of a very large file -// Line 129 of a very large file -// Line 130 of a very large file -// Line 131 of a very large file -// Line 132 of a very large file -// Line 133 of a very large file -// Line 134 of a very large file -// Line 135 of a very large file -// Line 136 of a very large file -// Line 137 of a very large file -// Line 138 of a very large file -// Line 139 of a very large file -// Line 140 of a very large file -// Line 141 of a very large file -// Line 142 of a very large file -// Line 143 of a very large file -// Line 144 of a very large file -// Line 145 of a very large file -// Line 146 of a very large file -// Line 147 of a very large file -// Line 148 of a very large file -// Line 149 of a very large file -// Line 150 of a very large file -// Line 151 of a very large file -// Line 152 of a very large file -// Line 153 of a very large file -// Line 154 of a very large file -// Line 155 of a very large file -// Line 156 of a very large file -// Line 157 of a very large file -// Line 158 of a very large file -// Line 159 of a very large file -// Line 160 of a very large file -// Line 161 of a very large file -// Line 162 of a very large file -// Line 163 of a very large file -// Line 164 of a very large file -// Line 165 of a very large file -// Line 166 of a very large file -// Line 167 of a very large file -// Line 168 of a very large file -// Line 169 of a very large file -// Line 170 of a very large file -// Line 171 of a very large file -// Line 172 of a very large file -// Line 173 of a very large file -// Line 174 of a very large file -// Line 175 of a very large file -// Line 176 of a very large file -// Line 177 of a very large file -// Line 178 of a very large file -// Line 179 of a very large file -// Line 180 of a very large file -// Line 181 of a very large file -// Line 182 of a very large file -// Line 183 of a very large file -// Line 184 of a very large file -// Line 185 of a very large file -// Line 186 of a very large file -// Line 187 of a very large file -// Line 188 of a very large file -// Line 189 of a very large file -// Line 190 of a very large file -// Line 191 of a very large file -// Line 192 of a very large file -// Line 193 of a very large file -// Line 194 of a very large file -// Line 195 of a very large file -// Line 196 of a very large file -// Line 197 of a very large file -// Line 198 of a very large file -// Line 199 of a very large file -// Line 200 of a very large file -// Line 201 of a very large file -// Line 202 of a very large file -// Line 203 of a very large file -// Line 204 of a very large file -// Line 205 of a very large file -// Line 206 of a very large file -// Line 207 of a very large file -// Line 208 of a very large file -// Line 209 of a very large file -// Line 210 of a very large file -// Line 211 of a very large file -// Line 212 of a very large file -// Line 213 of a very large file -// Line 214 of a very large file -// Line 215 of a very large file -// Line 216 of a very large file -// Line 217 of a very large file -// Line 218 of a very large file -// Line 219 of a very large file -// Line 220 of a very large file -// Line 221 of a very large file -// Line 222 of a very large file -// Line 223 of a very large file -// Line 224 of a very large file -// Line 225 of a very large file -// Line 226 of a very large file -// Line 227 of a very large file -// Line 228 of a very large file -// Line 229 of a very large file -// Line 230 of a very large file -// Line 231 of a very large file -// Line 232 of a very large file -// Line 233 of a very large file -// Line 234 of a very large file -// Line 235 of a very large file -// Line 236 of a very large file -// Line 237 of a very large file -// Line 238 of a very large file -// Line 239 of a very large file -// Line 240 of a very large file -// Line 241 of a very large file -// Line 242 of a very large file -// Line 243 of a very large file -// Line 244 of a very large file -// Line 245 of a very large file -// Line 246 of a very large file -// Line 247 of a very large file -// Line 248 of a very large file -// Line 249 of a very large file -// Line 250 of a very large file -// Line 251 of a very large file -// Line 252 of a very large file -// Line 253 of a very large file -// Line 254 of a very large file -// Line 255 of a very large file -// Line 256 of a very large file -// Line 257 of a very large file -// Line 258 of a very large file -// Line 259 of a very large file -// Line 260 of a very large file -// Line 261 of a very large file -// Line 262 of a very large file -// Line 263 of a very large file -// Line 264 of a very large file -// Line 265 of a very large file -// Line 266 of a very large file -// Line 267 of a very large file -// Line 268 of a very large file -// Line 269 of a very large file -// Line 270 of a very large file -// Line 271 of a very large file -// Line 272 of a very large file -// Line 273 of a very large file -// Line 274 of a very large file -// Line 275 of a very large file -// Line 276 of a very large file -// Line 277 of a very large file -// Line 278 of a very large file -// Line 279 of a very large file -// Line 280 of a very large file -// Line 281 of a very large file -// Line 282 of a very large file -// Line 283 of a very large file -// Line 284 of a very large file -// Line 285 of a very large file -// Line 286 of a very large file -// Line 287 of a very large file -// Line 288 of a very large file -// Line 289 of a very large file -// Line 290 of a very large file -// Line 291 of a very large file -// Line 292 of a very large file -// Line 293 of a very large file -// Line 294 of a very large file -// Line 295 of a very large file -// Line 296 of a very large file -// Line 297 of a very large file -// Line 298 of a very large file -// Line 299 of a very large file -// Line 300 of a very large file -// Line 301 of a very large file -// Line 302 of a very large file -// Line 303 of a very large file -// Line 304 of a very large file -// Line 305 of a very large file -// Line 306 of a very large file -// Line 307 of a very large file -// Line 308 of a very large file -// Line 309 of a very large file -// Line 310 of a very large file -// Line 311 of a very large file -// Line 312 of a very large file -// Line 313 of a very large file -// Line 314 of a very large file -// Line 315 of a very large file -// Line 316 of a very large file -// Line 317 of a very large file -// Line 318 of a very large file -// Line 319 of a very large file -// Line 320 of a very large file -// Line 321 of a very large file -// Line 322 of a very large file -// Line 323 of a very large file -// Line 324 of a very large file -// Line 325 of a very large file -// Line 326 of a very large file -// Line 327 of a very large file -// Line 328 of a very large file -// Line 329 of a very large file -// Line 330 of a very large file -// Line 331 of a very large file -// Line 332 of a very large file -// Line 333 of a very large file -// Line 334 of a very large file -// Line 335 of a very large file -// Line 336 of a very large file -// Line 337 of a very large file -// Line 338 of a very large file -// Line 339 of a very large file -// Line 340 of a very large file -// Line 341 of a very large file -// Line 342 of a very large file -// Line 343 of a very large file -// Line 344 of a very large file -// Line 345 of a very large file -// Line 346 of a very large file -// Line 347 of a very large file -// Line 348 of a very large file -// Line 349 of a very large file -// Line 350 of a very large file -// Line 351 of a very large file -// Line 352 of a very large file -// Line 353 of a very large file -// Line 354 of a very large file -// Line 355 of a very large file -// Line 356 of a very large file -// Line 357 of a very large file -// Line 358 of a very large file -// Line 359 of a very large file -// Line 360 of a very large file -// Line 361 of a very large file -// Line 362 of a very large file -// Line 363 of a very large file -// Line 364 of a very large file -// Line 365 of a very large file -// Line 366 of a very large file -// Line 367 of a very large file -// Line 368 of a very large file -// Line 369 of a very large file -// Line 370 of a very large file -// Line 371 of a very large file -// Line 372 of a very large file -// Line 373 of a very large file -// Line 374 of a very large file -// Line 375 of a very large file -// Line 376 of a very large file -// Line 377 of a very large file -// Line 378 of a very large file -// Line 379 of a very large file -// Line 380 of a very large file -// Line 381 of a very large file -// Line 382 of a very large file -// Line 383 of a very large file -// Line 384 of a very large file -// Line 385 of a very large file -// Line 386 of a very large file -// Line 387 of a very large file -// Line 388 of a very large file -// Line 389 of a very large file -// Line 390 of a very large file -// Line 391 of a very large file -// Line 392 of a very large file -// Line 393 of a very large file -// Line 394 of a very large file -// Line 395 of a very large file -// Line 396 of a very large file -// Line 397 of a very large file -// Line 398 of a very large file -// Line 399 of a very large file -// Line 400 of a very large file -// Line 401 of a very large file -// Line 402 of a very large file -// Line 403 of a very large file -// Line 404 of a very large file -// Line 405 of a very large file -// Line 406 of a very large file -// Line 407 of a very large file -// Line 408 of a very large file -// Line 409 of a very large file -// Line 410 of a very large file -// Line 411 of a very large file -// Line 412 of a very large file -// Line 413 of a very large file -// Line 414 of a very large file -// Line 415 of a very large file -// Line 416 of a very large file -// Line 417 of a very large file -// Line 418 of a very large file -// Line 419 of a very large file -// Line 420 of a very large file -// Line 421 of a very large file -// Line 422 of a very large file -// Line 423 of a very large file -// Line 424 of a very large file -// Line 425 of a very large file -// Line 426 of a very large file -// Line 427 of a very large file -// Line 428 of a very large file -// Line 429 of a very large file -// Line 430 of a very large file -// Line 431 of a very large file -// Line 432 of a very large file -// Line 433 of a very large file -// Line 434 of a very large file -// Line 435 of a very large file -// Line 436 of a very large file -// Line 437 of a very large file -// Line 438 of a very large file -// Line 439 of a very large file -// Line 440 of a very large file -// Line 441 of a very large file -// Line 442 of a very large file -// Line 443 of a very large file -// Line 444 of a very large file -// Line 445 of a very large file -// Line 446 of a very large file -// Line 447 of a very large file -// Line 448 of a very large file -// Line 449 of a very large file -// Line 450 of a very large file -// Line 451 of a very large file -// Line 452 of a very large file -// Line 453 of a very large file -// Line 454 of a very large file -// Line 455 of a very large file -// Line 456 of a very large file -// Line 457 of a very large file -// Line 458 of a very large file -// Line 459 of a very large file -// Line 460 of a very large file -// Line 461 of a very large file -// Line 462 of a very large file -// Line 463 of a very large file -// Line 464 of a very large file -// Line 465 of a very large file -// Line 466 of a very large file -// Line 467 of a very large file -// Line 468 of a very large file -// Line 469 of a very large file -// Line 470 of a very large file -// Line 471 of a very large file -// Line 472 of a very large file -// Line 473 of a very large file -// Line 474 of a very large file -// Line 475 of a very large file -// Line 476 of a very large file -// Line 477 of a very large file -// Line 478 of a very large file -// Line 479 of a very large file -// Line 480 of a very large file -// Line 481 of a very large file -// Line 482 of a very large file -// Line 483 of a very large file -// Line 484 of a very large file -// Line 485 of a very large file -// Line 486 of a very large file -// Line 487 of a very large file -// Line 488 of a very large file -// Line 489 of a very large file -// Line 490 of a very large file -// Line 491 of a very large file -// Line 492 of a very large file -// Line 493 of a very large file -// Line 494 of a very large file -// Line 495 of a very large file -// Line 496 of a very large file -// Line 497 of a very large file -// Line 498 of a very large file -// Line 499 of a very large file -// Line 500 of a very large file -// Line 501 of a very large file diff --git a/packages/flutterguard_cli/test/fixtures/lifecycle_issue.dart b/packages/flutterguard_cli/test/fixtures/lifecycle_issue.dart deleted file mode 100644 index 8c902c0..0000000 --- a/packages/flutterguard_cli/test/fixtures/lifecycle_issue.dart +++ /dev/null @@ -1,18 +0,0 @@ -// ignore_for_file: unused_field, inference_failure_on_instance_creation -import 'dart:async'; - -// Fixture: lifecycle resource not disposed -class LifecycleIssueWidget { - late StreamSubscription _subscription; - late Timer _timer; - - LifecycleIssueWidget() { - _subscription = const Stream.empty().listen((_) {}); - _timer = Timer.periodic(const Duration(seconds: 1), (_) {}); - } - - void dispose() { - // _subscription.cancel() is missing - // _timer.cancel() is missing - } -} diff --git a/packages/flutterguard_cli/test/fixtures/missing_const.dart b/packages/flutterguard_cli/test/fixtures/missing_const.dart deleted file mode 100644 index e02e99b..0000000 --- a/packages/flutterguard_cli/test/fixtures/missing_const.dart +++ /dev/null @@ -1,27 +0,0 @@ -class Widget {} -class BuildContext {} -class StatelessWidget {} -class StatefulWidget {} -class State {} - -class ValidWidget extends StatelessWidget { - const ValidWidget(); - - Widget build(BuildContext context) => Widget(); -} - -class MissingConstWidget extends StatelessWidget { - MissingConstWidget(); - - Widget build(BuildContext context) => Widget(); -} - -class MyStatefulWidget extends StatefulWidget { - MyStatefulWidget(); - - State createState() => _MyStatefulWidgetState(); -} - -class _MyStatefulWidgetState extends State { - Widget build(BuildContext context) => Widget(); -} diff --git a/packages/flutterguard_cli/test/fixtures/mqtt_connection_issue.dart b/packages/flutterguard_cli/test/fixtures/mqtt_connection_issue.dart deleted file mode 100644 index 947459f..0000000 --- a/packages/flutterguard_cli/test/fixtures/mqtt_connection_issue.dart +++ /dev/null @@ -1,14 +0,0 @@ -// ignore_for_file: unused_field -// Fixture: MQTT connection issues -class MqttClient {} - -class MqttService { - late MqttClient _client; - - void connect() { - // broker URL hardcoded - final url = 'tcp://broker.iot.local:1883'; - } - - // disconnect() is missing -} diff --git a/packages/flutterguard_cli/test/fixtures/provider_state.dart b/packages/flutterguard_cli/test/fixtures/provider_state.dart deleted file mode 100644 index 9919a46..0000000 --- a/packages/flutterguard_cli/test/fixtures/provider_state.dart +++ /dev/null @@ -1,48 +0,0 @@ -class DeviceController {} - -class ImmutableValue { - const ImmutableValue(); -} - -final existingController = DeviceController(); - -final badValue = ChangeNotifierProvider.value( - value: DeviceController(), -); - -final badCreate = ChangeNotifierProvider( - create: (_) => existingController, -); - -final safeCreate = ChangeNotifierProvider( - create: (_) => DeviceController(), -); - -final safeValue = ChangeNotifierProvider.value( - value: existingController, -); - -final safeImmutable = Provider.value( - value: const ImmutableValue(), -); - -class DeviceNotifier { - void updateAll(List values) { - for (final value in values) { - notifyListeners(); - } - values.forEach((value) { - notifyListeners(); - }); - } - - void safe() { - for (final value in [1]) { - notifyListeners(); - } - for (final value in [1, 2]) { - consume(value); - } - notifyListeners(); - } -} diff --git a/packages/flutterguard_cli/test/fixtures/riverpod_state.dart b/packages/flutterguard_cli/test/fixtures/riverpod_state.dart deleted file mode 100644 index 03dcea5..0000000 --- a/packages/flutterguard_cli/test/fixtures/riverpod_state.dart +++ /dev/null @@ -1,45 +0,0 @@ -class Widget { - Widget({Object? value, Object? onPressed, Object? onTap}); -} - -class BuildContext {} - -class ConsumerWidget { - Widget build(BuildContext context, dynamic ref) => Widget(); -} - -final deviceProvider = Provider((ref) => 1); - -class DirectReadWidget extends ConsumerWidget { - Widget build(BuildContext context, dynamic ref) { - return Widget(value: ref.read(deviceProvider)); - } -} - -class ConditionalReadWidget extends ConsumerWidget { - Widget build(BuildContext context, dynamic ref) { - final enabled = ref.read(deviceProvider); - if (enabled) return Widget(); - return Widget(); - } -} - -class SafeReadWidget extends ConsumerWidget { - Widget build(BuildContext context, dynamic ref) { - ref.read(deviceProvider.notifier).refresh(); - return Widget(onPressed: () => ref.read(deviceProvider)); - } -} - -class WatchCallbackWidget extends ConsumerWidget { - Widget build(BuildContext context, dynamic ref) { - final value = ref.watch(deviceProvider); - return Widget( - value: value, - onPressed: () => ref.watch(deviceProvider), - onTap: () async { - ref.watch(deviceProvider); - }, - ); - } -} diff --git a/packages/flutterguard_cli/test/fixtures/state_suppression.dart b/packages/flutterguard_cli/test/fixtures/state_suppression.dart deleted file mode 100644 index b744710..0000000 --- a/packages/flutterguard_cli/test/fixtures/state_suppression.dart +++ /dev/null @@ -1,66 +0,0 @@ -class SuppressedBuildWidget { - // flutterguard: ignore side_effect_in_build - Object build(Object context) { - notifyListeners(); - // flutterguard: ignore state_manager_created_in_build - final controller = SuppressedController(); - return controller; - } -} - -class SuppressedController { - // flutterguard: ignore mutable_state_exposed - int counter = 0; -} - -// flutterguard: ignore state_layer_ui_dependency -class SuppressedNavigationController { - void open(BuildContext context) { - Navigator.of(context).push('/device'); - } -} - -// flutterguard: ignore state_dependency_cycle -class SuppressedCycleController { - final SuppressedCycleService service; -} - -class SuppressedCycleService { - final SuppressedCycleController controller; -} - -class SuppressedRiverpodWidget { - Object build(Object context, dynamic ref) { - // flutterguard: ignore riverpod_read_used_for_render - return Text(ref.read(deviceProvider)); - } - - Object callbacks(dynamic ref) { - return Button( - // flutterguard: ignore riverpod_watch_in_callback - onPressed: () => ref.watch(deviceProvider), - ); - } -} - -// flutterguard: ignore bloc_equatable_props_incomplete -class SuppressedEquatableState extends Equatable { - final String name; - final bool connected; - - List get props => [name]; -} - -// flutterguard: ignore provider_value_lifecycle_misuse -final suppressedProvider = ChangeNotifierProvider.value( - value: SuppressedNotifier(), -); - -class SuppressedNotifier { - void update(List values) { - // flutterguard: ignore notify_listeners_in_loop - for (final value in values) { - notifyListeners(); - } - } -} diff --git a/packages/flutterguard_cli/test/scanner_test.dart b/packages/flutterguard_cli/test/scanner_test.dart deleted file mode 100644 index b2a1115..0000000 --- a/packages/flutterguard_cli/test/scanner_test.dart +++ /dev/null @@ -1,1820 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutterguard_cli/src/baseline.dart'; -import 'package:flutterguard_cli/src/config_loader.dart'; -import 'package:flutterguard_cli/src/config_tools.dart'; -import 'package:flutterguard_cli/src/domain.dart'; -import 'package:flutterguard_cli/src/import_utils.dart'; -import 'package:flutterguard_cli/src/install_doctor.dart'; -import 'package:flutterguard_cli/src/issue_export.dart'; -import 'package:flutterguard_cli/src/path_utils.dart'; -import 'package:flutterguard_cli/src/priority.dart'; -import 'package:flutterguard_cli/src/report_generator.dart'; -import 'package:flutterguard_cli/src/rules/circular_dependency.dart'; -import 'package:flutterguard_cli/src/rules/large_units.dart'; -import 'package:flutterguard_cli/src/rules/layer_violation.dart'; -import 'package:flutterguard_cli/src/rules/ble_scanning.dart'; -import 'package:flutterguard_cli/src/rules/bloc_state_management.dart'; -import 'package:flutterguard_cli/src/rules/device_lifecycle.dart'; -import 'package:flutterguard_cli/src/rules/generic_state_management.dart'; -import 'package:flutterguard_cli/src/rules/iot_security.dart'; -import 'package:flutterguard_cli/src/rules/lifecycle_resource.dart'; -import 'package:flutterguard_cli/src/rules/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/provider_state_management.dart'; -import 'package:flutterguard_cli/src/rules/registry.dart'; -import 'package:flutterguard_cli/src/rules/riverpod_state_management.dart'; -import 'package:flutterguard_cli/src/rules/state_dependency_cycle.dart'; -import 'package:flutterguard_cli/src/sarif_report.dart'; -import 'package:flutterguard_cli/src/scanner.dart'; -import 'package:flutterguard_cli/src/source_workspace.dart'; -import 'package:flutterguard_cli/src/static_issue.dart'; -import 'package:path/path.dart' as p; -import 'package:test/test.dart'; - -String get fixturesPath => p.join(Directory.current.path, 'test', 'fixtures'); - -const _stateRuleIds = { - 'side_effect_in_build', - 'state_manager_created_in_build', - 'mutable_state_exposed', - 'state_layer_ui_dependency', - 'state_dependency_cycle', - 'riverpod_read_used_for_render', - 'riverpod_watch_in_callback', - 'bloc_equatable_props_incomplete', - 'provider_value_lifecycle_misuse', - 'notify_listeners_in_loop', -}; - -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 {} -'''); -} - -StateManagementConfig _stateManagement({ - bool enabled = true, - bool frameworkAutoDetect = true, -}) => - ( - enabled: enabled, - frameworkAutoDetect: frameworkAutoDetect, - confidenceThreshold: RuleConfidence.certain, - ); - -StateRuleConfig _stateRule( - RiskLevel severity, { - bool enabled = true, - List allowlist = const [], - List ignorePaths = const [], -}) => - ( - enabled: enabled, - severity: severity, - allowlist: allowlist, - ignorePaths: ignorePaths, - ); - -void main() { - group('Static Rules', () { - test('scan detects large file', () { - final config = - ScanConfig.fromFile(p.join(fixturesPath, 'architecture_config.yaml')); - final files = [p.join(fixturesPath, 'large_file.dart')]; - - final issues = LargeUnitsRule( - largeFileConfig: config.rules.largeFile, - largeClassConfig: config.rules.largeClass, - largeBuildMethodConfig: config.rules.largeBuildMethod, - ).analyze(files); - - final largeFileIssue = issues.where((i) => i.id == 'large_file').toList(); - expect(largeFileIssue, isNotEmpty); - expect(largeFileIssue.first.metadata['actual'], greaterThan(500)); - }); - - test('scan detects large class', () { - final config = - ScanConfig.fromFile(p.join(fixturesPath, 'architecture_config.yaml')); - final files = [p.join(fixturesPath, 'large_class.dart')]; - - final issues = LargeUnitsRule( - largeFileConfig: config.rules.largeFile, - largeClassConfig: config.rules.largeClass, - largeBuildMethodConfig: config.rules.largeBuildMethod, - ).analyze(files); - - final largeClassIssue = - issues.where((i) => i.id == 'large_class').toList(); - expect(largeClassIssue, isNotEmpty); - }); - - test('scan detects large build method', () { - final config = - ScanConfig.fromFile(p.join(fixturesPath, 'architecture_config.yaml')); - final files = [p.join(fixturesPath, 'large_build.dart')]; - - final issues = LargeUnitsRule( - largeFileConfig: config.rules.largeFile, - largeClassConfig: config.rules.largeClass, - largeBuildMethodConfig: config.rules.largeBuildMethod, - ).analyze(files); - - final largeBuildIssue = - issues.where((i) => i.id == 'large_build_method').toList(); - expect(largeBuildIssue, isNotEmpty); - }); - - test('scan detects lifecycle resource not disposed', () { - final config = - ScanConfig.fromFile(p.join(fixturesPath, 'architecture_config.yaml')); - final files = [p.join(fixturesPath, 'lifecycle_issue.dart')]; - - final issues = - LifecycleResourceRule(config.rules.lifecycleResource).analyze(files); - - expect(issues, isNotEmpty); - expect( - issues.any((i) => i.id == 'lifecycle_resource_not_disposed'), isTrue); - }); - - test('scan detects layer violation', () { - final config = - ScanConfig.fromFile(p.join(fixturesPath, 'architecture_config.yaml')); - final files = [ - p.join(fixturesPath, 'boundary_issue.dart'), - p.join(fixturesPath, 'forbidden_file.dart'), - ]; - - final issues = - LayerViolationRule(config.architecture.layers).analyze(files); - - expect(issues, isNotEmpty); - expect(issues.any((i) => i.id == 'layer_violation'), isTrue); - }); - - test('layer violation matches project-relative architecture paths', () { - final files = [ - p.join(fixturesPath, 'boundary_issue.dart'), - p.join(fixturesPath, 'forbidden_file.dart'), - ]; - - final issues = LayerViolationRule( - const [ - ( - name: 'ui', - path: 'test/fixtures/boundary_issue.dart', - allowedDeps: [] - ), - ( - name: 'model', - path: 'test/fixtures/forbidden_file.dart', - allowedDeps: [], - ), - ], - projectPath: Directory.current.path, - ).analyze(files); - - expect(issues, isNotEmpty); - expect(issues.any((i) => i.id == 'layer_violation'), isTrue); - }); - - test('scan detects module violation', () { - final config = - ScanConfig.fromFile(p.join(fixturesPath, 'architecture_config.yaml')); - final files = [ - p.join(fixturesPath, 'boundary_issue.dart'), - p.join(fixturesPath, 'forbidden_file.dart'), - ]; - - final issues = - ModuleViolationRule(config.architecture.modules).analyze(files); - - expect(issues, isNotEmpty); - expect(issues.any((i) => i.id == 'module_violation'), isTrue); - }); - - test('scan detects circular dependency', () { - final files = [ - p.join(fixturesPath, 'cycle_a.dart'), - p.join(fixturesPath, 'cycle_b.dart'), - p.join(fixturesPath, 'cycle_c.dart'), - ]; - - final issues = const CircularDependencyRule(enabled: true).analyze(files); - - expect(issues, isNotEmpty); - expect(issues.any((i) => i.id == 'circular_dependency'), isTrue); - }); - - test('scan detects missing const constructor in widgets', () { - final config = - ScanConfig.fromFile(p.join(fixturesPath, 'architecture_config.yaml')); - final files = [p.join(fixturesPath, 'missing_const.dart')]; - - final issues = MissingConstConstructorRule( - config.rules.missingConstConstructor, - ).analyze(files); - - expect(issues, hasLength(2)); - expect(issues.any((i) => i.id == 'missing_const_constructor'), isTrue); - expect(issues.any((i) => i.metadata['className'] == 'MissingConstWidget'), - isTrue); - expect(issues.any((i) => i.metadata['className'] == 'MyStatefulWidget'), - isTrue); - }); - - test('scan detects iot security issues', () { - final files = [p.join(fixturesPath, 'iot_security_issue.dart')]; - final config = (enabled: true, requireTls: true); - - final issues = IotSecurityRule(config).analyze(files); - - expect( - issues.any((i) => i.metadata['securityCheck'] == 'hardcoded_secret'), - isTrue); - expect(issues.any((i) => i.metadata['securityCheck'] == 'cleartext_mqtt'), - isTrue); - expect(issues.any((i) => i.metadata['securityCheck'] == 'cleartext_http'), - isTrue); - expect(issues.any((i) => i.metadata['securityCheck'] == 'insecure_ble'), - isTrue); - }); - - test('scan detects device lifecycle issues', () { - final files = [p.join(fixturesPath, 'device_lifecycle_issue.dart')]; - final config = (enabled: true); - - final issues = DeviceLifecycleRule(config).analyze(files); - - expect(issues.any((i) => i.id == 'device_lifecycle'), isTrue); - expect( - issues.any((i) => i.metadata['initMethod'] == 'initState'), isTrue); - expect( - issues.any((i) => i.metadata['teardownMethod'] == 'dispose'), isTrue); - }); - - test('scan detects mqtt connection issues', () { - final files = [p.join(fixturesPath, 'mqtt_connection_issue.dart')]; - final config = (enabled: true); - - final issues = MqttConnectionRule(config).analyze(files); - - expect(issues.any((i) => i.id == 'mqtt_connection'), isTrue); - expect( - issues - .any((i) => i.metadata['check'] == 'connect_without_disconnect'), - isTrue); - expect(issues.any((i) => i.metadata['check'] == 'hardcoded_broker_url'), - isTrue); - }); - - test('scan detects ble scanning issues', () { - final files = [p.join(fixturesPath, 'ble_scanning_issue.dart')]; - final config = (enabled: true, maxScanDurationMs: 10000); - - final issues = BleScanningRule(config).analyze(files); - - expect(issues.any((i) => i.id == 'ble_scanning'), isTrue); - expect( - issues - .any((i) => i.metadata['check'] == 'startScan_without_stopScan'), - isTrue); - }); - - test('scan detects pubspec security issues', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_test_'); - addTearDown(() => dir.deleteSync(recursive: true)); - - File(p.join(dir.path, 'pubspec.yaml')).writeAsStringSync(''' -name: test_app -dependencies: - mqtt_client: ^9.0.0 - flutter_blue: ^0.8.0 - path: any -'''); - - File(p.join(dir.path, 'dummy.dart')).writeAsStringSync('// dummy'); - final files = [p.join(dir.path, 'dummy.dart')]; - final config = (enabled: true); - - final issues = PubspecSecurityRule(config).analyze(files); - - expect(issues.any((i) => i.id == 'pubspec_security'), isTrue); - expect(issues.any((i) => i.metadata['check'] == 'outdated_dependency'), - isTrue); - expect(issues.any((i) => i.metadata['check'] == 'deprecated_package'), - isTrue); - expect(issues.any((i) => i.metadata['check'] == 'unbounded_dependency'), - isTrue); - }); - - test('IoT rules respect disabled config', () { - final files = [p.join(fixturesPath, 'iot_security_issue.dart')]; - final config = (enabled: false, requireTls: true); - - final issues = IotSecurityRule(config).analyze(files); - - expect(issues, isEmpty); - }); - - test('architecture config parses layer/module enabled flags', () { - final enabledConfig = - ScanConfig.fromFile(p.join(fixturesPath, 'architecture_config.yaml')); - expect(enabledConfig.architecture.layerViolationEnabled, isTrue); - expect(enabledConfig.architecture.moduleViolationEnabled, isTrue); - - final disabledConfig = ScanConfig.fromFile( - p.join(fixturesPath, 'architecture_disabled.yaml')); - expect(disabledConfig.architecture.layerViolationEnabled, isFalse); - expect(disabledConfig.architecture.moduleViolationEnabled, isFalse); - }); - - test('wiring: disabled layer/module violations produce no issues', () { - final config = ScanConfig.fromFile( - p.join(fixturesPath, 'architecture_disabled.yaml')); - final files = [ - p.join(fixturesPath, 'boundary_issue.dart'), - p.join(fixturesPath, 'forbidden_file.dart'), - ]; - - List issues = []; - if (config.architecture.layerViolationEnabled) { - issues.addAll( - LayerViolationRule(config.architecture.layers).analyze(files)); - } - if (config.architecture.moduleViolationEnabled) { - issues.addAll( - ModuleViolationRule(config.architecture.modules).analyze(files)); - } - expect(issues, isEmpty); - }); - - test('ci fail on high returns exit 1 scenario', () { - final issues = [ - StaticIssue( - id: 'test_high', - title: 'Test high', - file: 'test.dart', - level: RiskLevel.high, - domain: IssueDomain.architecture, - priority: Priority.p0, - message: 'High severity issue', - detail: '', - suggestion: 'Fix it', - ), - ]; - - expect(ReportGenerator.shouldFail(issues, 'high'), isTrue); - expect(ReportGenerator.shouldFail(issues, 'medium'), isTrue); - }); - }); - - group('Report Generation', () { - test('json report is generated', () { - final issues = [ - StaticIssue( - id: 'test_issue', - title: 'Test issue', - file: '/test/file.dart', - line: 42, - level: RiskLevel.medium, - domain: IssueDomain.architecture, - priority: Priority.p1, - message: 'A medium architecture issue', - detail: 'Detailed description', - suggestion: 'Try fixing it', - ), - ]; - - final json = ReportGenerator.generateJson( - projectPath: '/test', - issues: issues, - ); - - expect(json, contains('"version"')); - expect(json, contains('"projectPath"')); - expect(json, contains('"scanMode"')); - expect(json, contains('"score"')); - expect(json, contains('"issues"')); - expect(json, contains('"byDomain"')); - expect(json, contains('test_issue')); - final decoded = jsonDecode(json) as Map; - final issue = (decoded['issues'] as List).single as Map; - expect(issue['ruleId'], 'test_issue'); - expect(issue['severity'], 'medium'); - expect(issue['framework'], 'generic'); - expect(issue['confidence'], 'certain'); - expect(issue['evidence'], isEmpty); - }); - - test('stdout report uses scanned file count when provided', () { - final report = ReportGenerator.generateStdout( - projectPath: '/test', - issues: const [], - scannedFileCount: 3, - ); - - expect(report, contains('扫描文件: 3')); - expect(report, contains('问题总数: ')); - }); - - test('json summary includes suppression counters', () { - final json = ReportGenerator.generateJson( - projectPath: '/test', - issues: const [], - suppressedCount: 2, - suppressedByBaselineCount: 3, - diagnostics: const [ - ScanDiagnostic(stage: 'read', message: 'unreadable'), - ], - ); - final decoded = jsonDecode(json) as Map; - final summary = decoded['summary'] as Map; - - expect(summary['suppressed'], 2); - expect(summary['suppressedByBaseline'], 3); - expect(summary['diagnostics'], 1); - expect(decoded['diagnostics'], hasLength(1)); - }); - }); - - group('Scanner Orchestration', () { - test('scanner allows built-in defaults when the default config is absent', - () { - final dir = - Directory.systemTemp.createTempSync('flutterguard_no_config_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(); - File(p.join(dir.path, 'lib', 'plain.dart')) - .writeAsStringSync('class Plain {}\n'); - - final result = FlutterGuardScanner.scan(projectPath: dir.path); - - expect(result.files, [p.join(dir.path, 'lib', 'plain.dart')]); - }); - - test('scanner analyzes the project-root pubspec', () { - final dir = - Directory.systemTemp.createTempSync('flutterguard_root_pubspec_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(); - File(p.join(dir.path, 'lib', 'plain.dart')) - .writeAsStringSync('class Plain {}\n'); - File(p.join(dir.path, 'pubspec.yaml')).writeAsStringSync(''' -name: root_pubspec_test -dependencies: - mqtt_client: ^9.0.0 -'''); - - final result = FlutterGuardScanner.scan(projectPath: dir.path); - - expect( - result.issues.where((issue) => issue.id == 'pubspec_security'), - isNotEmpty, - ); - }); - - test('source workspace retains read failures as diagnostics', () { - final workspace = SourceWorkspace(); - - expect(workspace.source(p.join(fixturesPath, 'missing.dart')), isNull); - expect(workspace.diagnostics, hasLength(1)); - expect(workspace.diagnostics.single.stage, 'read'); - }); - - test('scanner rejects a missing custom config', () { - final dir = - Directory.systemTemp.createTempSync('flutterguard_custom_config_'); - addTearDown(() => dir.deleteSync(recursive: true)); - - expect( - () => FlutterGuardScanner.scan( - projectPath: dir.path, - configPath: 'policy/flutterguard.yaml', - ), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('does not exist'), - ), - ), - ); - }); - - test('scanner rejects a missing explicitly selected default config', () { - final dir = - Directory.systemTemp.createTempSync('flutterguard_required_config_'); - addTearDown(() => dir.deleteSync(recursive: true)); - - expect( - () => FlutterGuardScanner.scan( - projectPath: dir.path, - configPath: 'flutterguard.yaml', - ), - throwsA(isA()), - ); - }); - - test('scanner runs all configured rules and returns sorted result', () { - final result = FlutterGuardScanner.scan( - projectPath: Directory.current.path, - configPath: p.join('test', 'fixtures', 'architecture_config.yaml'), - ); - - expect(result.files, isNotEmpty); - expect(result.issues, isNotEmpty); - expect(result.score, inInclusiveRange(0, 100)); - expect(result.issues.first.level, RiskLevel.high); - }); - - test('scanner reports missing project path as scan exception', () { - expect( - () => FlutterGuardScanner.scan( - projectPath: p.join(fixturesPath, 'does_not_exist'), - ), - throwsA(isA()), - ); - }); - - test('config parser rejects invalid rule values', () { - final file = File(p.join(fixturesPath, 'invalid_config.yaml')); - file.writeAsStringSync('rules:\n large_file: false\n'); - addTearDown(() { - if (file.existsSync()) file.deleteSync(); - }); - - expect( - () => ScanConfig.fromFile(file.path), - throwsA(isA()), - ); - }); - - test('config parser rejects a required file that is absent', () { - expect( - () => ScanConfig.fromFile( - p.join(fixturesPath, 'does_not_exist.yaml'), - requireFile: true, - ), - throwsA(isA()), - ); - }); - - test('same-line suppression filters matching rule only', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_suppress_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - File(p.join(dir.path, 'lib', 'ignored.dart')).writeAsStringSync(''' -class StatelessWidget {} -class IgnoredWidget extends StatelessWidget {} // flutterguard: ignore missing_const_constructor -'''); - File(p.join(dir.path, 'lib', 'visible.dart')).writeAsStringSync(''' -class StatelessWidget {} -class VisibleWidget extends StatelessWidget {} // flutterguard: ignore large_file -'''); - - final result = FlutterGuardScanner.scan(projectPath: dir.path); - - expect(result.rawIssues, hasLength(2)); - expect(result.issues, hasLength(1)); - expect(result.suppressedCount, 1); - expect(result.issues.single.metadata['className'], 'VisibleWidget'); - }); - - test('previous-line ignore all filters next line issues', () { - final dir = - Directory.systemTemp.createTempSync('flutterguard_ignore_all_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - File(p.join(dir.path, 'lib', 'widget.dart')).writeAsStringSync(''' -class StatelessWidget {} -// flutterguard: ignore all -class IgnoredWidget extends StatelessWidget {} -'''); - - final result = FlutterGuardScanner.scan(projectPath: dir.path); - - expect(result.rawIssues, hasLength(1)); - expect(result.issues, isEmpty); - expect(result.suppressedCount, 1); - }); - - test('baseline filters matching fingerprints and leaves new issues visible', - () { - final dir = Directory.systemTemp.createTempSync('flutterguard_baseline_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - _writeWidgetIssue(p.join(dir.path, 'lib', 'old.dart'), 'OldWidget'); - - final initial = FlutterGuardScanner.scan( - projectPath: dir.path, - applySuppression: false, - ); - final baselinePath = p.join(dir.path, '.flutterguard', 'baseline.json'); - Directory(p.dirname(baselinePath)).createSync(recursive: true); - File(baselinePath).writeAsStringSync(Baseline.encode( - projectPath: initial.projectPath, - issues: initial.rawIssues, - )); - - _writeWidgetIssue(p.join(dir.path, 'lib', 'new.dart'), 'NewWidget'); - final result = FlutterGuardScanner.scan( - projectPath: dir.path, - baselinePath: '.flutterguard/baseline.json', - ); - - expect(result.rawIssues, hasLength(2)); - expect(result.suppressedByBaselineCount, 1); - expect(result.issues, hasLength(1)); - expect(result.issues.single.file, endsWith('new.dart')); - }); - - test('baseline stats prune and no-growth helpers work', () { - final dir = - Directory.systemTemp.createTempSync('flutterguard_baseline_tools_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - _writeWidgetIssue(p.join(dir.path, 'lib', 'old.dart'), 'OldWidget'); - - final initial = FlutterGuardScanner.scan( - projectPath: dir.path, - applySuppression: false, - ); - final staleIssue = StaticIssue( - id: 'missing_const_constructor', - title: 'Stale', - file: p.join(dir.path, 'lib', 'deleted.dart'), - line: 2, - level: RiskLevel.low, - domain: IssueDomain.standards, - priority: Priority.p2, - message: 'stale issue', - suggestion: 'fix', - ); - final baselineJson = Baseline.encode( - projectPath: initial.projectPath, - issues: [...initial.rawIssues, staleIssue], - ); - final baseline = Baseline.loadFromString(baselineJson); - - expect(baseline.fingerprints, hasLength(2)); - - final pruned = Baseline.loadFromString(Baseline.prune( - projectPath: initial.projectPath, - baseline: baseline, - issues: initial.rawIssues, - )); - expect(pruned.fingerprints, hasLength(1)); - - _writeWidgetIssue(p.join(dir.path, 'lib', 'new.dart'), 'NewWidget'); - final current = FlutterGuardScanner.scan( - projectPath: dir.path, - applySuppression: false, - ); - final newFingerprints = Baseline.newFingerprints( - projectPath: current.projectPath, - baseline: pruned, - issues: current.rawIssues, - ); - expect(newFingerprints, hasLength(1)); - }); - - test('missing baseline file fails the scan', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_no_base_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - _writeWidgetIssue(p.join(dir.path, 'lib', 'one.dart'), 'OneWidget'); - - expect( - () => FlutterGuardScanner.scan( - projectPath: dir.path, - baselinePath: '.flutterguard/missing.json', - ), - throwsA(isA()), - ); - }); - - test('sarif report contains rules results severity and line fallback', () { - final issues = [ - StaticIssue( - id: 'iot_security', - title: 'High', - file: p.join('/repo', 'lib', 'a.dart'), - line: 12, - level: RiskLevel.high, - domain: IssueDomain.standards, - priority: Priority.p0, - message: 'high issue', - suggestion: 'fix', - ), - StaticIssue( - id: 'ble_scanning', - title: 'Medium', - file: p.join('/repo', 'lib', 'b.dart'), - level: RiskLevel.medium, - domain: IssueDomain.performance, - priority: Priority.p1, - message: 'medium issue', - suggestion: 'fix', - ), - StaticIssue( - id: 'missing_const_constructor', - title: 'Low', - file: p.join('/repo', 'lib', 'c.dart'), - line: 3, - level: RiskLevel.low, - domain: IssueDomain.standards, - priority: Priority.p2, - message: 'low issue', - suggestion: 'fix', - ), - ]; - - final decoded = jsonDecode(SarifReport.generate( - projectPath: '/repo', - issues: issues, - )) as Map; - final runs = decoded['runs'] as List; - final run = runs.single as Map; - final results = run['results'] as List; - - expect(decoded['version'], '2.1.0'); - expect(jsonEncode(run), contains('"rules"')); - expect(results.map((r) => (r as Map)['level']), [ - 'error', - 'warning', - 'note', - ]); - final second = results[1] as Map; - final properties = second['properties'] as Map; - expect(properties['framework'], 'generic'); - expect(properties['confidence'], 'certain'); - expect(properties['evidence'], isEmpty); - final locations = second['locations'] as List; - final physical = (locations.single as Map)['physicalLocation'] as Map; - final region = physical['region'] as Map; - expect(region['startLine'], 1); - expect(jsonEncode(second), contains('lib/b.dart')); - }); - - test('issue export includes rule metadata context and fingerprint', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_export_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - final file = p.join(dir.path, 'lib', 'widget.dart'); - _writeWidgetIssue(file, 'ExportedWidget'); - - final result = FlutterGuardScanner.scan( - projectPath: dir.path, - applySuppression: false, - ); - final exported = IssueExporter.export( - projectPath: result.projectPath, - issues: result.rawIssues, - ruleId: 'missing_const_constructor', - filePath: 'lib/widget.dart', - line: 2, - ); - final decoded = jsonDecode(exported) as Map; - - expect(decoded['fingerprint'], isA()); - expect( - jsonEncode(decoded['rule']), contains('missing_const_constructor')); - expect(jsonEncode(decoded['context']), contains('ExportedWidget')); - expect(jsonEncode(decoded['issue']), contains('lib/widget.dart')); - }); - }); - - group('Changed-only mode', () { - test('changed_only_filters_files', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_changed_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - final changedFile = p.join(dir.path, 'lib', 'changed 设备.dart'); - final unchangedFile = p.join(dir.path, 'lib', 'unchanged.dart'); - _writeWidgetIssue(changedFile, 'ChangedWidget'); - _writeWidgetIssue(unchangedFile, 'UnchangedWidget'); - - _runGit(dir, ['init', '-b', 'main']); - _runGit(dir, ['config', 'user.email', 'test@example.com']); - _runGit(dir, ['config', 'user.name', 'FlutterGuard Test']); - _runGit(dir, ['add', '.']); - _runGit(dir, ['commit', '-m', 'initial']); - File(changedFile).writeAsStringSync(''' -class StatelessWidget {} -class ChangedWidget extends StatelessWidget {} -class AnotherChangedWidget extends StatelessWidget {} -'''); - - final result = FlutterGuardScanner.scan( - projectPath: dir.path, - changedOnly: true, - base: 'main', - ); - - expect(result.scanMode, 'changed'); - expect(result.files, [changedFile]); - expect(result.issues, isNotEmpty); - expect(result.issues.every((i) => i.file == changedFile), isTrue); - }); - - test('changed_only resolves imports to unchanged architecture targets', () { - final dir = Directory.systemTemp.createTempSync( - 'flutterguard_changed_boundary_', - ); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib', 'ui')).createSync(recursive: true); - Directory(p.join(dir.path, 'lib', 'data')).createSync(recursive: true); - File(p.join(dir.path, 'flutterguard.yaml')).writeAsStringSync(''' -include: - - lib/** -architecture: - layers: - - name: ui - path: lib/ui/** - allowed_deps: [] - - name: data - path: lib/data/** - allowed_deps: [] -'''); - final source = p.join(dir.path, 'lib', 'ui', 'screen.dart'); - final target = p.join(dir.path, 'lib', 'data', 'repository.dart'); - File(source).writeAsStringSync( - "import '../data/repository.dart';\nclass Screen {}\n", - ); - File(target).writeAsStringSync('class Repository {}\n'); - _runGit(dir, ['init', '-b', 'main']); - _runGit(dir, ['config', 'user.email', 'test@example.com']); - _runGit(dir, ['config', 'user.name', 'FlutterGuard Test']); - _runGit(dir, ['add', '.']); - _runGit(dir, ['commit', '-m', 'initial']); - File(source).writeAsStringSync( - "import '../data/repository.dart';\nclass Screen {}\n// changed\n", - ); - - final result = FlutterGuardScanner.scan( - projectPath: dir.path, - changedOnly: true, - base: 'main', - ); - - expect(result.files, [source]); - expect( - result.issues.where((issue) => issue.id == 'layer_violation'), - hasLength(1), - ); - }); - - test('changed_only_full_scan_when_no_git', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_no_git_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - File(p.join(dir.path, 'lib', 'one.dart')).writeAsStringSync( - "import 'two.dart';\nclass One {}\n", - ); - File(p.join(dir.path, 'lib', 'two.dart')).writeAsStringSync( - "import 'one.dart';\nclass Two {}\n", - ); - - final result = FlutterGuardScanner.scan( - projectPath: dir.path, - changedOnly: true, - ); - - expect(result.scanMode, 'full'); - expect(result.files, hasLength(2)); - expect( - result.issues.where((issue) => issue.id == 'circular_dependency'), - isNotEmpty, - ); - }); - - test('changed_only clean repository returns an empty changed scan', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_clean_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - File(p.join(dir.path, 'lib', 'clean.dart')) - .writeAsStringSync('class Clean {}\n'); - _runGit(dir, ['init', '-b', 'main']); - _runGit(dir, ['config', 'user.email', 'test@example.com']); - _runGit(dir, ['config', 'user.name', 'FlutterGuard Test']); - _runGit(dir, ['add', '.']); - _runGit(dir, ['commit', '-m', 'initial']); - - final result = FlutterGuardScanner.scan( - projectPath: dir.path, - changedOnly: true, - base: 'main', - ); - - expect(result.scanMode, 'changed'); - expect(result.files, isEmpty); - expect(result.issues, isEmpty); - }); - - test('changed_only runs project rules when pubspec changes', () { - final dir = Directory.systemTemp.createTempSync( - 'flutterguard_changed_pubspec_', - ); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - File(p.join(dir.path, 'lib', 'clean.dart')) - .writeAsStringSync('class Clean {}\n'); - final pubspec = File(p.join(dir.path, 'pubspec.yaml')) - ..writeAsStringSync('name: changed_pubspec_test\n'); - _runGit(dir, ['init', '-b', 'main']); - _runGit(dir, ['config', 'user.email', 'test@example.com']); - _runGit(dir, ['config', 'user.name', 'FlutterGuard Test']); - _runGit(dir, ['add', '.']); - _runGit(dir, ['commit', '-m', 'initial']); - pubspec.writeAsStringSync(''' -name: changed_pubspec_test -dependencies: - mqtt_client: ^9.0.0 -'''); - - final result = FlutterGuardScanner.scan( - projectPath: dir.path, - changedOnly: true, - base: 'main', - ); - - expect(result.files, isEmpty); - expect( - result.issues.where((issue) => issue.id == 'pubspec_security'), - isNotEmpty, - ); - }); - - test('changed_only rejects invalid or option-like base refs', () { - final dir = Directory.systemTemp.createTempSync( - 'flutterguard_invalid_base_', - ); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - File(p.join(dir.path, 'lib', 'tracked.dart')) - .writeAsStringSync('class Tracked {}\n'); - _runGit(dir, ['init', '-b', 'main']); - _runGit(dir, ['config', 'user.email', 'test@example.com']); - _runGit(dir, ['config', 'user.name', 'FlutterGuard Test']); - _runGit(dir, ['add', '.']); - _runGit(dir, ['commit', '-m', 'initial']); - - expect( - () => FlutterGuardScanner.scan( - projectPath: dir.path, - changedOnly: true, - base: 'missing-ref', - ), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('Invalid Git base'), - ), - ), - ); - expect( - () => FlutterGuardScanner.scan( - projectPath: dir.path, - changedOnly: true, - base: '--cached', - ), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('Invalid Git base'), - ), - ), - ); - }); - - test('changed_only_skips_circular_dependency', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_cycle_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(recursive: true); - _writeMinimalProjectConfig(dir); - File(p.join(dir.path, 'lib', 'a.dart')).writeAsStringSync( - "import 'b.dart';\nclass A {}\n", - ); - File(p.join(dir.path, 'lib', 'b.dart')).writeAsStringSync( - "import 'c.dart';\nclass B {}\n", - ); - File(p.join(dir.path, 'lib', 'c.dart')).writeAsStringSync( - "import 'a.dart';\nclass C {}\n", - ); - _runGit(dir, ['init', '-b', 'main']); - _runGit(dir, ['config', 'user.email', 'test@example.com']); - _runGit(dir, ['config', 'user.name', 'FlutterGuard Test']); - _runGit(dir, ['commit', '--allow-empty', '-m', 'initial']); - - final result = FlutterGuardScanner.scan( - projectPath: dir.path, - changedOnly: true, - base: 'main', - ); - - expect(result.scanMode, 'changed'); - expect(result.files, hasLength(3)); - expect( - result.issues.where((i) => i.id == 'circular_dependency'), - isEmpty, - ); - }); - }); - - group('State Management Rules', () { - final genericFile = p.join(fixturesPath, 'generic_state.dart'); - final riverpodFile = p.join(fixturesPath, 'riverpod_state.dart'); - final blocFile = p.join(fixturesPath, 'bloc_state.dart'); - final providerFile = p.join(fixturesPath, 'provider_state.dart'); - - test('detects build side effects and excludes callback/local collection', - () { - final issues = SideEffectInBuildRule( - _stateRule(RiskLevel.high), - _stateManagement(), - projectPath: Directory.current.path, - ).analyze([genericFile]); - - expect(issues, hasLength(2)); - expect(issues.every((issue) => issue.evidence.isNotEmpty), isTrue); - expect( - issues.expand((issue) => issue.evidence), - isNot(contains(contains('values.add'))), - ); - }); - - test('detects state manager creation in build and excludes callbacks', () { - final issues = StateManagerCreatedInBuildRule( - _stateRule(RiskLevel.high), - _stateManagement(), - projectPath: Directory.current.path, - ).analyze([genericFile]); - - expect(issues, hasLength(2)); - expect( - issues.map((issue) => issue.metadata['type']), - containsAll(['DeviceController', 'DeviceBloc']), - ); - }); - - test('detects exposed mutable state and excludes Flutter State/safe data', - () { - final issues = MutableStateExposedRule( - _stateRule(RiskLevel.medium), - _stateManagement(), - projectPath: Directory.current.path, - ).analyze([genericFile]); - - expect(issues.length, greaterThanOrEqualTo(4)); - expect( - issues.any((issue) => issue.metadata['className'] == 'PageState'), - isFalse, - ); - expect( - issues.any((issue) => issue.metadata['className'] == 'SafeState'), - isFalse, - ); - }); - - test('detects state-layer UI dependencies once per owner', () { - final issues = StateLayerUiDependencyRule( - _stateRule(RiskLevel.high), - _stateManagement(), - projectPath: Directory.current.path, - ).analyze([genericFile]); - - expect( - issues.map((issue) => issue.metadata['className']), - containsAll(['NavigationController', 'ThemeController']), - ); - expect( - issues.where( - (issue) => issue.metadata['className'] == 'NavigationController', - ), - hasLength(1), - ); - }); - - test('detects deterministic state dependency cycle and edge allowlist', () { - final issues = StateDependencyCycleRule( - _stateRule(RiskLevel.high), - _stateManagement(), - projectPath: Directory.current.path, - ).analyze([genericFile]); - - expect(issues, hasLength(1)); - expect(issues.single.message, contains('CycleController')); - expect(issues.single.message, contains('CycleService')); - - final allowed = StateDependencyCycleRule( - _stateRule( - RiskLevel.high, - allowlist: ['CycleService->CycleController'], - ), - _stateManagement(), - projectPath: Directory.current.path, - ).analyze([genericFile]); - expect(allowed, isEmpty); - }); - - test( - 'detects Riverpod read render flow and excludes command/callback reads', - () { - final issues = RiverpodReadUsedForRenderRule( - _stateRule(RiskLevel.medium), - _stateManagement(frameworkAutoDetect: false), - projectPath: Directory.current.path, - ).analyze([riverpodFile]); - - expect(issues, hasLength(2)); - expect( - issues.every( - (issue) => issue.framework == StateManagementFramework.riverpod, - ), - isTrue, - ); - }); - - test('detects Riverpod watch in event callbacks only', () { - final issues = RiverpodWatchInCallbackRule( - _stateRule(RiskLevel.medium), - _stateManagement(frameworkAutoDetect: false), - projectPath: Directory.current.path, - ).analyze([riverpodFile]); - - expect(issues, hasLength(2)); - }); - - test('merges missing Equatable props per class', () { - final issues = BlocEquatablePropsIncompleteRule( - _stateRule(RiskLevel.medium), - _stateManagement(frameworkAutoDetect: false), - projectPath: Directory.current.path, - ).analyze([blocFile]); - - expect(issues, hasLength(2)); - expect( - issues.map((issue) => issue.metadata['className']), - containsAll(['DeviceState', 'ReadingState']), - ); - }); - - test('detects Provider value/create ownership inversions', () { - final issues = ProviderValueLifecycleMisuseRule( - _stateRule(RiskLevel.medium), - _stateManagement(frameworkAutoDetect: false), - projectPath: Directory.current.path, - ).analyze([providerFile]); - - expect(issues, hasLength(2)); - expect( - issues.map((issue) => issue.metadata['ownershipError']), - containsAll(['value_creates', 'create_reuses']), - ); - }); - - test( - 'detects notifyListeners in repeated loops and skips literal singleton', - () { - final issues = NotifyListenersInLoopRule( - _stateRule(RiskLevel.medium), - _stateManagement(frameworkAutoDetect: false), - projectPath: Directory.current.path, - ).analyze([providerFile]); - - expect(issues, hasLength(2)); - }); - - test('global and per-rule switches disable state rules', () { - final globalOff = SideEffectInBuildRule( - _stateRule(RiskLevel.high), - _stateManagement(enabled: false), - projectPath: Directory.current.path, - ).analyze([genericFile]); - final ruleOff = SideEffectInBuildRule( - _stateRule(RiskLevel.high, enabled: false), - _stateManagement(), - projectPath: Directory.current.path, - ).analyze([genericFile]); - - expect(globalOff, isEmpty); - expect(ruleOff, isEmpty); - }); - - test('framework auto-detection can be disabled without weakening AST shape', - () { - final dir = - Directory.systemTemp.createTempSync('flutterguard_framework_'); - addTearDown(() => dir.deleteSync(recursive: true)); - final file = File(p.join(dir.path, 'view.dart'))..writeAsStringSync(''' -class View { - Object build(Object context, dynamic ref) { - return Text(ref.read(deviceProvider)); - } -} -'''); - final imported = File(p.join(dir.path, 'imported_view.dart')) - ..writeAsStringSync(''' -import 'package:flutter_riverpod/flutter_riverpod.dart'; -class ImportedView { - Object build(Object context, dynamic ref) { - return Text(ref.read(deviceProvider)); - } -} -'''); - - final detected = RiverpodReadUsedForRenderRule( - _stateRule(RiskLevel.medium), - _stateManagement(), - projectPath: dir.path, - ).analyze([file.path]); - final forced = RiverpodReadUsedForRenderRule( - _stateRule(RiskLevel.medium), - _stateManagement(frameworkAutoDetect: false), - projectPath: dir.path, - ).analyze([file.path]); - final autoDetected = RiverpodReadUsedForRenderRule( - _stateRule(RiskLevel.medium), - _stateManagement(), - projectPath: dir.path, - ).analyze([imported.path]); - - expect(detected, isEmpty); - expect(forced, hasLength(1)); - expect(autoDetected, hasLength(1)); - }); - - test('severity override updates priority and JSON compatibility aliases', - () { - final issue = SideEffectInBuildRule( - _stateRule(RiskLevel.low), - _stateManagement(), - projectPath: Directory.current.path, - ).analyze([genericFile]).first; - final json = issue.toJson(); - - expect(issue.level, RiskLevel.low); - expect(issue.priority, Priority.p2); - expect(json['ruleId'], issue.id); - expect(json['severity'], 'low'); - expect(json['framework'], 'generic'); - expect(json['confidence'], 'certain'); - }); - - test('config validates new enums/types and prints complete defaults', () { - final dir = - Directory.systemTemp.createTempSync('flutterguard_state_cfg_'); - addTearDown(() => dir.deleteSync(recursive: true)); - final valid = File(p.join(dir.path, 'valid.yaml'))..writeAsStringSync(''' -state_management: - enabled: true - framework_auto_detect: false - confidence_threshold: probable -rules: - side_effect_in_build: - severity: low - allowlist: [refresh] - ignore_paths: [lib/generated/**] -'''); - final config = ScanConfig.fromFile(valid.path); - expect(config.stateManagement.frameworkAutoDetect, isFalse); - expect( - config.stateManagement.confidenceThreshold, RuleConfidence.probable); - expect(config.rules.sideEffectInBuild.severity, RiskLevel.low); - expect(ConfigTools.effectiveYaml(config), contains('state_management:')); - - for (final invalidBody in const [ - 'state_management:\n confidence_threshold: maybe\n', - 'rules:\n side_effect_in_build:\n severity: critical\n', - 'rules:\n side_effect_in_build:\n allowlist: value\n', - ]) { - final invalid = File(p.join(dir.path, 'invalid.yaml')) - ..writeAsStringSync(invalidBody); - expect(() => ScanConfig.fromFile(invalid.path), throwsFormatException); - } - }); - - test('state findings reuse line suppression and keep raw issues', () { - final dir = - Directory.systemTemp.createTempSync('flutterguard_state_suppress_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(); - File(p.join(dir.path, 'pubspec.yaml')).writeAsStringSync('name: app\n'); - File(p.join(dir.path, 'flutterguard.yaml')).writeAsStringSync(''' -include: [lib/**] -state_management: - framework_auto_detect: false -'''); - File(p.join(dir.path, 'lib', 'view.dart')).writeAsStringSync(''' -class View { - // flutterguard: ignore side_effect_in_build - Object build(Object context) { - notifyListeners(); - return Object(); - } -} -'''); - - final result = FlutterGuardScanner.scan(projectPath: dir.path); - expect( - result.rawIssues.where((issue) => issue.id == 'side_effect_in_build'), - hasLength(1), - ); - expect( - result.issues.where((issue) => issue.id == 'side_effect_in_build'), - isEmpty, - ); - expect(result.suppressedCount, greaterThanOrEqualTo(1)); - }); - - test('changed mode builds the full state graph and anchors to target file', - () { - final target = p.join(fixturesPath, 'generic_state.dart'); - final issues = StateDependencyCycleRule( - _stateRule(RiskLevel.high), - _stateManagement(), - projectPath: Directory.current.path, - ).analyze( - [target], - targetFiles: [target], - changedOnly: true, - ); - - expect(issues, hasLength(1)); - expect(issues.single.file, target); - }); - - test('every state rule honors its independent enabled switch', () { - final highOff = _stateRule(RiskLevel.high, enabled: false); - final mediumOff = _stateRule(RiskLevel.medium, enabled: false); - final analyzers = Function()>{ - 'side_effect_in_build': () => SideEffectInBuildRule( - highOff, - _stateManagement(), - projectPath: Directory.current.path, - ).analyze([genericFile]), - 'state_manager_created_in_build': () => StateManagerCreatedInBuildRule( - highOff, - _stateManagement(), - projectPath: Directory.current.path, - ).analyze([genericFile]), - 'mutable_state_exposed': () => MutableStateExposedRule( - mediumOff, - _stateManagement(), - projectPath: Directory.current.path, - ).analyze([genericFile]), - 'state_layer_ui_dependency': () => StateLayerUiDependencyRule( - highOff, - _stateManagement(), - projectPath: Directory.current.path, - ).analyze([genericFile]), - 'state_dependency_cycle': () => StateDependencyCycleRule( - highOff, - _stateManagement(), - projectPath: Directory.current.path, - ).analyze([genericFile]), - 'riverpod_read_used_for_render': () => RiverpodReadUsedForRenderRule( - mediumOff, - _stateManagement(), - projectPath: Directory.current.path, - ).analyze([riverpodFile]), - 'riverpod_watch_in_callback': () => RiverpodWatchInCallbackRule( - mediumOff, - _stateManagement(), - projectPath: Directory.current.path, - ).analyze([riverpodFile]), - 'bloc_equatable_props_incomplete': () => - BlocEquatablePropsIncompleteRule( - mediumOff, - _stateManagement(), - projectPath: Directory.current.path, - ).analyze([blocFile]), - 'provider_value_lifecycle_misuse': () => - ProviderValueLifecycleMisuseRule( - mediumOff, - _stateManagement(), - projectPath: Directory.current.path, - ).analyze([providerFile]), - 'notify_listeners_in_loop': () => NotifyListenersInLoopRule( - mediumOff, - _stateManagement(), - projectPath: Directory.current.path, - ).analyze([providerFile]), - }; - - expect(analyzers.keys.toSet(), _stateRuleIds); - for (final entry in analyzers.entries) { - expect(entry.value(), isEmpty, reason: entry.key); - } - }); - - test('all state rules reuse suppression and baseline pipelines', () { - final dir = - Directory.systemTemp.createTempSync('flutterguard_state_all_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(); - File(p.join(dir.path, 'pubspec.yaml')).writeAsStringSync('name: app\n'); - File(p.join(dir.path, 'flutterguard.yaml')).writeAsStringSync(''' -include: [lib/**] -state_management: - framework_auto_detect: false -'''); - File(p.join(dir.path, 'lib', 'state_suppression.dart')).writeAsStringSync( - File(p.join(fixturesPath, 'state_suppression.dart')).readAsStringSync(), - ); - - final suppressed = FlutterGuardScanner.scan(projectPath: dir.path); - final rawStateIds = suppressed.rawIssues - .where((issue) => _stateRuleIds.contains(issue.id)) - .map((issue) => issue.id) - .toSet(); - final visibleStateIds = suppressed.issues - .where((issue) => _stateRuleIds.contains(issue.id)) - .map((issue) => issue.id) - .toSet(); - expect(rawStateIds, _stateRuleIds); - expect(visibleStateIds, isEmpty); - - final unsuppressed = FlutterGuardScanner.scan( - projectPath: dir.path, - applySuppression: false, - ); - final baselinePath = p.join(dir.path, 'baseline.json'); - File(baselinePath).writeAsStringSync(Baseline.encode( - projectPath: unsuppressed.projectPath, - issues: unsuppressed.rawIssues, - )); - final baselined = FlutterGuardScanner.scan( - projectPath: dir.path, - applySuppression: false, - baselinePath: baselinePath, - ); - expect( - baselined.issues.where((issue) => _stateRuleIds.contains(issue.id)), - isEmpty, - ); - expect( - baselined.suppressedByBaselineCount, - unsuppressed.rawIssues.length, - ); - }); - - test('duplicate type names do not create a false state cycle', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_names_'); - addTearDown(() => dir.deleteSync(recursive: true)); - final a = Directory(p.join(dir.path, 'a'))..createSync(); - final b = Directory(p.join(dir.path, 'b'))..createSync(); - final controller = File(p.join(a.path, 'controller.dart')) - ..writeAsStringSync(''' -import 'service.dart'; -class DeviceController { final DuplicateService service; } -'''); - final serviceA = File(p.join(a.path, 'service.dart')) - ..writeAsStringSync('class DuplicateService {}\n'); - final serviceB = File(p.join(b.path, 'service.dart')) - ..writeAsStringSync(''' -import '../a/controller.dart'; -class DuplicateService { final DeviceController controller; } -'''); - - final issues = StateDependencyCycleRule( - _stateRule(RiskLevel.high), - _stateManagement(), - projectPath: dir.path, - ).analyze([controller.path, serviceA.path, serviceB.path]); - - expect(issues, isEmpty); - }); - - test('real cycles disambiguate duplicate type names in evidence', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_names_'); - addTearDown(() => dir.deleteSync(recursive: true)); - final a = Directory(p.join(dir.path, 'a'))..createSync(); - final b = Directory(p.join(dir.path, 'b'))..createSync(); - final controller = File(p.join(a.path, 'controller.dart')) - ..writeAsStringSync(''' -import 'service.dart'; -class DeviceController { final DuplicateService service; } -'''); - final serviceA = File(p.join(a.path, 'service.dart')) - ..writeAsStringSync(''' -import 'controller.dart'; -class DuplicateService { final DeviceController controller; } -'''); - final serviceB = File(p.join(b.path, 'service.dart')) - ..writeAsStringSync('class DuplicateService {}\n'); - - final issues = StateDependencyCycleRule( - _stateRule(RiskLevel.high), - _stateManagement(), - projectPath: dir.path, - ).analyze([controller.path, serviceA.path, serviceB.path]); - - expect(issues, hasLength(1)); - expect( - issues.single.message, contains('a/service.dart::DuplicateService')); - }); - - test('scanner changed-only uses unchanged files in the state graph', () { - final dir = - Directory.systemTemp.createTempSync('flutterguard_changed_state_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib')).createSync(); - File(p.join(dir.path, 'flutterguard.yaml')).writeAsStringSync(''' -include: [lib/**] -'''); - File(p.join(dir.path, 'lib', 'controller.dart')).writeAsStringSync(''' -import 'service.dart'; -class DeviceController { final DeviceService service; } -'''); - final service = File(p.join(dir.path, 'lib', 'service.dart')) - ..writeAsStringSync(''' -import 'controller.dart'; -class DeviceService { final DeviceController controller; } -'''); - _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']); - service.writeAsStringSync('${service.readAsStringSync()}\n// changed\n'); - - final result = FlutterGuardScanner.scan( - projectPath: dir.path, - changedOnly: true, - base: 'HEAD', - ); - final cycles = result.issues - .where((issue) => issue.id == 'state_dependency_cycle') - .toList(); - - expect(result.files, [service.path]); - expect(cycles, hasLength(1)); - expect(cycles.single.file, service.path); - }); - }); - - group('Rules Registry', () { - test('registry_contains_all_23_rules', () { - expect(RuleRegistry.all(), hasLength(23)); - for (final id in const [ - 'side_effect_in_build', - 'state_manager_created_in_build', - 'mutable_state_exposed', - 'state_layer_ui_dependency', - 'state_dependency_cycle', - 'riverpod_read_used_for_render', - 'riverpod_watch_in_callback', - 'bloc_equatable_props_incomplete', - 'provider_value_lifecycle_misuse', - 'notify_listeners_in_loop', - ]) { - expect(RuleRegistry.find(id), isNotNull, reason: id); - } - }); - - test('registry_find_returns_correct_meta', () { - final meta = RuleRegistry.find('large_file'); - - expect(meta, isNotNull); - expect(meta!.id, 'large_file'); - expect(meta.domain, 'standards'); - }); - - test('registry_find_unknown_returns_null', () { - expect(RuleRegistry.find('nonexistent'), isNull); - }); - }); - - group('Config Tools', () { - test('init template includes optional architecture block', () { - final basic = ConfigTools.initTemplate(withArchitecture: false); - final withArchitecture = ConfigTools.initTemplate(withArchitecture: true); - - expect(basic, contains('large_file:')); - expect(basic, isNot(contains('architecture:'))); - expect(withArchitecture, contains('architecture:')); - expect(withArchitecture, contains('mqtt_feature')); - }); - - test('init template profiles tune rule defaults', () { - final migration = ConfigTools.initTemplate( - withArchitecture: false, - profile: 'migration', - ); - final security = ConfigTools.initTemplate( - withArchitecture: false, - profile: 'iot-security', - ); - - expect(migration, contains('maxLines: 800')); - expect(migration, contains('missing_const_constructor:')); - expect(migration, contains('enabled: false')); - expect(security, contains('iot_security:')); - expect(security, contains('requireTls: true')); - expect( - () => ConfigTools.initTemplate( - withArchitecture: false, - profile: 'unknown', - ), - throwsA(isA()), - ); - - final dir = Directory.systemTemp.createTempSync('flutterguard_profiles_'); - addTearDown(() => dir.deleteSync(recursive: true)); - for (final profile in ConfigTools.profiles) { - final file = File(p.join(dir.path, '$profile.yaml')) - ..writeAsStringSync(ConfigTools.initTemplate( - withArchitecture: false, - profile: profile, - )); - final parsed = ScanConfig.fromFile(file.path); - expect(parsed.rules.sideEffectInBuild.severity, RiskLevel.high); - } - final performance = ScanConfig.fromFile( - p.join(dir.path, 'performance-only.yaml'), - ); - expect(performance.rules.sideEffectInBuild.enabled, isTrue); - expect(performance.rules.mutableStateExposed.enabled, isFalse); - final architecture = ScanConfig.fromFile( - p.join(dir.path, 'architecture-only.yaml'), - ); - expect(architecture.rules.sideEffectInBuild.enabled, isFalse); - }); - - test('effective config print includes merged defaults', () { - final config = ScanConfig.fromFile( - p.join(fixturesPath, 'does_not_exist.yaml'), - ); - - final yaml = ConfigTools.effectiveYaml(config); - - expect(yaml, contains('include:')); - expect(yaml, contains('iot_security:')); - expect(yaml, contains('maxScanDurationMs: 10000')); - expect(yaml, contains('detect_cycles: false')); - }); - - test('doctor reports unknown architecture dependencies', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_doctor_'); - addTearDown(() => dir.deleteSync(recursive: true)); - Directory(p.join(dir.path, 'lib', 'presentation')) - .createSync(recursive: true); - File(p.join(dir.path, 'lib', 'presentation', 'page.dart')) - .writeAsStringSync('class Page {}\n'); - File(p.join(dir.path, 'flutterguard.yaml')).writeAsStringSync(''' -include: - - lib/** -architecture: - layers: - - name: presentation - path: lib/presentation/** - allowed_deps: [domain] -'''); - - final result = ConfigTools.doctor( - projectPath: dir.path, - configPath: 'flutterguard.yaml', - ); - - expect(result.hasErrors, isTrue); - expect( - result.messages.any((message) => - message.severity == DoctorSeverity.error && - message.message.contains('unknown dependency "domain"')), - isTrue, - ); - }); - - test('doctor rejects configs that match no Dart files', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_empty_'); - addTearDown(() => dir.deleteSync(recursive: true)); - File(p.join(dir.path, 'pubspec.yaml')).writeAsStringSync('name: app\n'); - File(p.join(dir.path, 'flutterguard.yaml')).writeAsStringSync(''' -include: - - lib/** -'''); - - final result = ConfigTools.doctor( - projectPath: dir.path, - configPath: 'flutterguard.yaml', - ); - - expect(result.hasErrors, isTrue); - expect( - result.messages.any((message) => - message.severity == DoctorSeverity.error && - message.message.contains('No Dart files matched')), - isTrue, - ); - }); - - test('config tools prefer project config for default config name', () { - final dir = Directory.systemTemp.createTempSync('flutterguard_config_'); - addTearDown(() => dir.deleteSync(recursive: true)); - File(p.join(dir.path, 'pubspec.yaml')).writeAsStringSync('name: app\n'); - File(p.join(dir.path, 'flutterguard.yaml')).writeAsStringSync(''' -include: - - custom_lib/** -'''); - - final resolved = ConfigTools.resolveConfigPathForProject( - projectPath: dir.path, - configPath: 'flutterguard.yaml', - ); - - expect(resolved, p.join(dir.path, 'flutterguard.yaml')); - }); - - test('install doctor reports version and path checks', () { - final report = InstallDoctor.generate(version: '0.4.0-test'); - - expect(report, contains('FlutterGuard install doctor')); - expect(report, contains('0.4.0-test')); - expect(report, contains('PATH entries named flutterguard')); - }); - }); - - group('Path handling', () { - test('matches project-relative globs against Windows paths', () { - final windows = p.Context(style: p.Style.windows, current: r'C:\repo'); - - expect( - matchesProjectGlob( - r'C:\repo\lib\presentation\device_page.dart', - 'lib/presentation/**', - r'C:\repo', - context: windows, - ), - isTrue, - ); - }); - - test('resolves nested package imports from project lib root', () { - final source = - p.join(Directory.current.path, 'lib', 'presentation', 'page.dart'); - final target = p.join(Directory.current.path, 'lib', 'data', 'repo.dart'); - - final resolved = resolveImport( - source, - 'package:app/data/repo.dart', - {source, target}, - projectPath: Directory.current.path, - ); - - expect(resolved, target); - }); - - test('resolves Windows package imports from project lib root', () { - final windows = p.Context(style: p.Style.windows, current: r'C:\repo'); - const source = r'C:\repo\lib\presentation\page.dart'; - const target = r'C:\repo\lib\data\repo.dart'; - - final resolved = resolveImport( - source, - 'package:app/data/repo.dart', - {source, target}, - projectPath: r'C:\repo', - context: windows, - ); - - expect(resolved, target); - }); - }); -} diff --git a/scripts/compile.ps1 b/scripts/compile.ps1 deleted file mode 100644 index 69c470c..0000000 --- a/scripts/compile.ps1 +++ /dev/null @@ -1,16 +0,0 @@ -$ErrorActionPreference = "Stop" - -$rootDir = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) -$src = Join-Path $rootDir "packages\flutterguard_cli\bin\flutterguard.dart" -$out = Join-Path $rootDir "flutterguard.exe" - -Write-Host "==> Compiling FlutterGuard CLI..." -ForegroundColor Cyan -dart compile exe $src -o $out - -if (Test-Path $out) { - Write-Host "==> Done: $out" -ForegroundColor Green - & $out --version -} else { - Write-Host "==> Error: compilation failed" -ForegroundColor Red - exit 1 -} diff --git a/scripts/compile.sh b/scripts/compile.sh deleted file mode 100755 index 8d86a11..0000000 --- a/scripts/compile.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -SRC="$ROOT_DIR/packages/flutterguard_cli/bin/flutterguard.dart" -OUT="$ROOT_DIR/flutterguard" - -echo "==> Compiling FlutterGuard CLI..." -dart compile exe "$SRC" -o "$OUT" - -if [ -f "$OUT" ]; then - echo "==> Done: $OUT" - "$OUT" --version -else - echo "==> Error: compilation failed" - exit 1 -fi diff --git a/scripts/flutterguard-dev b/scripts/flutterguard-dev deleted file mode 100755 index dc742c2..0000000 --- a/scripts/flutterguard-dev +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -exec dart "$ROOT_DIR/packages/flutterguard_cli/bin/flutterguard.dart" "$@" diff --git a/scripts/flutterguard-dev.ps1 b/scripts/flutterguard-dev.ps1 deleted file mode 100644 index e452889..0000000 --- a/scripts/flutterguard-dev.ps1 +++ /dev/null @@ -1,5 +0,0 @@ -$ErrorActionPreference = "Stop" - -$rootDir = Split-Path -Parent $PSScriptRoot -$entrypoint = Join-Path $rootDir "packages\flutterguard_cli\bin\flutterguard.dart" -dart $entrypoint @args diff --git a/scripts/scan_ci.ps1 b/scripts/scan_ci.ps1 deleted file mode 100644 index 2c0bd08..0000000 --- a/scripts/scan_ci.ps1 +++ /dev/null @@ -1,29 +0,0 @@ -$ErrorActionPreference = "Stop" - -$failOn = $env:FLUTTERGUARD_FAIL_ON ?? "high" -$minScore = $env:FLUTTERGUARD_MIN_SCORE ?? "80" -$rootDir = Split-Path -Parent $PSScriptRoot -$target = if ($args.Count -gt 0) { $args[0] } else { Join-Path $rootDir "examples\scan_demo" } -$launcher = Join-Path $PSScriptRoot "flutterguard-dev.ps1" - -Write-Host "==> FlutterGuard CI Scan" -ForegroundColor Cyan -Write-Host " Target: $target" -Write-Host " Fail-on: $failOn" -Write-Host " Min-score: $minScore" -Write-Host "" - -& $launcher scan $target --format json --fail-on $failOn --min-score $minScore -$status = $LASTEXITCODE - -if ($status -eq 0) { - Write-Host "" - Write-Host "All checks passed!" -ForegroundColor Green -} else { - Write-Host "" - if ($status -eq 1) { - Write-Host "CI gate failed! Check $target/.flutterguard/report.json for details." -ForegroundColor Red - } else { - Write-Host "FlutterGuard scan setup failed with exit code $status." -ForegroundColor Red - } - exit $status -} diff --git a/scripts/scan_ci.sh b/scripts/scan_ci.sh deleted file mode 100755 index 2c64ead..0000000 --- a/scripts/scan_ci.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -FAIL_ON="${FLUTTERGUARD_FAIL_ON:-high}" -MIN_SCORE="${FLUTTERGUARD_MIN_SCORE:-80}" -TARGET="${1:-$ROOT_DIR/examples/scan_demo}" - -echo "==> FlutterGuard CI Scan" -echo " Target: $TARGET" -echo " Fail-on: $FAIL_ON" -echo " Min-score: $MIN_SCORE" -echo "" - -if "$SCRIPT_DIR/flutterguard-dev" scan "$TARGET" --format json --fail-on "$FAIL_ON" --min-score "$MIN_SCORE"; then - echo "" - echo "All checks passed!" -else - status=$? - echo "" - if [ "$status" -eq 1 ]; then - echo "CI gate failed! Check $TARGET/.flutterguard/report.json for details." - else - echo "FlutterGuard scan setup failed with exit code $status." - fi - exit "$status" -fi From 805d914a15e28dccace30d6215afaffb55228f61 Mon Sep 17 00:00:00 2001 From: lizy Date: Sun, 19 Jul 2026 11:59:08 +0800 Subject: [PATCH 13/16] =?UTF-8?q?refactor:=20=E8=BF=81=E7=A7=BB=E6=BA=90?= =?UTF-8?q?=E7=A0=81=E5=88=B0=E6=A0=B9=E7=9B=AE=E5=BD=95=E5=B9=B6=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=8C=85=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 12 +- analysis_options.yaml | 6 +- bin/AGENTS.md | 7 + bin/flutterguard.dart | 111 +++++ flutterguard.yaml | 94 +--- lib/AGENTS.md | 7 + lib/flutterguard_cli.dart | 5 + lib/src/AGENTS.md | 18 + lib/src/baseline.dart | 82 ++++ lib/src/boundary_engine.dart | 79 ++++ lib/src/cli/baseline_commands.dart | 44 ++ lib/src/cli/cli_parsers.dart | 77 ++++ lib/src/cli/config_commands.dart | 44 ++ lib/src/cli/rule_commands.dart | 62 +++ lib/src/cli/scan_command.dart | 81 ++++ lib/src/config_loader.dart | 229 ++++++++++ lib/src/config_tools.dart | 258 +++++++++++ lib/src/file_collector.dart | 150 +++++++ lib/src/import_graph.dart | 75 ++++ lib/src/import_utils.dart | 71 +++ lib/src/path_utils.dart | 44 ++ lib/src/project_resolver.dart | 50 +++ lib/src/report_generator.dart | 179 ++++++++ lib/src/rules/AGENTS.md | 20 + lib/src/rules/ble_scanning.dart | 110 +++++ lib/src/rules/bloc_state_management.dart | 103 +++++ lib/src/rules/boundary_rule.dart | 86 ++++ lib/src/rules/circular_dependency.dart | 127 ++++++ lib/src/rules/generic_state_management.dart | 449 +++++++++++++++++++ lib/src/rules/iot_security.dart | 183 ++++++++ lib/src/rules/lifecycle_resource.dart | 128 ++++++ lib/src/rules/provider_state_management.dart | 326 ++++++++++++++ lib/src/rules/registry.dart | 197 ++++++++ lib/src/rules/riverpod_state_management.dart | 276 ++++++++++++ lib/src/rules/rule.dart | 64 +++ lib/src/rules/state_dependency_cycle.dart | 400 +++++++++++++++++ lib/src/rules/state_management_utils.dart | 205 +++++++++ lib/src/sarif_report.dart | 87 ++++ lib/src/scan_context.dart | 26 ++ lib/src/scanner.dart | 211 +++++++++ lib/src/source_workspace.dart | 102 +++++ lib/src/static_issue.dart | 50 +++ lib/src/suppression.dart | 65 +++ pubspec.yaml | 26 +- test/AGENTS.md | 13 + test/cli_test.dart | 193 ++++++++ test/config_test.dart | 103 +++++ test/fixtures/AGENTS.md | 13 + test/fixtures/architecture_config.yaml | 29 ++ test/fixtures/architecture_disabled.yaml | 24 + test/fixtures/ble_scanning_issue.dart | 17 + test/fixtures/bloc_state.dart | 37 ++ test/fixtures/boundary_issue.dart | 5 + test/fixtures/cycle_a.dart | 7 + test/fixtures/cycle_b.dart | 7 + test/fixtures/cycle_c.dart | 7 + test/fixtures/forbidden_file.dart | 2 + test/fixtures/generic_state.dart | 92 ++++ test/fixtures/iot_security_issue.dart | 17 + test/fixtures/lifecycle_issue.dart | 18 + test/fixtures/provider_state.dart | 42 ++ test/fixtures/riverpod_state.dart | 47 ++ test/fixtures/state_suppression.dart | 66 +++ test/rules_test.dart | 148 ++++++ test/scanner_test.dart | 110 +++++ 65 files changed, 5915 insertions(+), 108 deletions(-) create mode 100644 bin/AGENTS.md create mode 100644 bin/flutterguard.dart create mode 100644 lib/AGENTS.md create mode 100644 lib/flutterguard_cli.dart create mode 100644 lib/src/AGENTS.md create mode 100644 lib/src/baseline.dart create mode 100644 lib/src/boundary_engine.dart create mode 100644 lib/src/cli/baseline_commands.dart create mode 100644 lib/src/cli/cli_parsers.dart create mode 100644 lib/src/cli/config_commands.dart create mode 100644 lib/src/cli/rule_commands.dart create mode 100644 lib/src/cli/scan_command.dart create mode 100644 lib/src/config_loader.dart create mode 100644 lib/src/config_tools.dart create mode 100644 lib/src/file_collector.dart create mode 100644 lib/src/import_graph.dart create mode 100644 lib/src/import_utils.dart create mode 100644 lib/src/path_utils.dart create mode 100644 lib/src/project_resolver.dart create mode 100644 lib/src/report_generator.dart create mode 100644 lib/src/rules/AGENTS.md create mode 100644 lib/src/rules/ble_scanning.dart create mode 100644 lib/src/rules/bloc_state_management.dart create mode 100644 lib/src/rules/boundary_rule.dart create mode 100644 lib/src/rules/circular_dependency.dart create mode 100644 lib/src/rules/generic_state_management.dart create mode 100644 lib/src/rules/iot_security.dart create mode 100644 lib/src/rules/lifecycle_resource.dart create mode 100644 lib/src/rules/provider_state_management.dart create mode 100644 lib/src/rules/registry.dart create mode 100644 lib/src/rules/riverpod_state_management.dart create mode 100644 lib/src/rules/rule.dart create mode 100644 lib/src/rules/state_dependency_cycle.dart create mode 100644 lib/src/rules/state_management_utils.dart create mode 100644 lib/src/sarif_report.dart create mode 100644 lib/src/scan_context.dart create mode 100644 lib/src/scanner.dart create mode 100644 lib/src/source_workspace.dart create mode 100644 lib/src/static_issue.dart create mode 100644 lib/src/suppression.dart create mode 100644 test/AGENTS.md create mode 100644 test/cli_test.dart create mode 100644 test/config_test.dart create mode 100644 test/fixtures/AGENTS.md create mode 100644 test/fixtures/architecture_config.yaml create mode 100644 test/fixtures/architecture_disabled.yaml create mode 100644 test/fixtures/ble_scanning_issue.dart create mode 100644 test/fixtures/bloc_state.dart create mode 100644 test/fixtures/boundary_issue.dart create mode 100644 test/fixtures/cycle_a.dart create mode 100644 test/fixtures/cycle_b.dart create mode 100644 test/fixtures/cycle_c.dart create mode 100644 test/fixtures/forbidden_file.dart create mode 100644 test/fixtures/generic_state.dart create mode 100644 test/fixtures/iot_security_issue.dart create mode 100644 test/fixtures/lifecycle_issue.dart create mode 100644 test/fixtures/provider_state.dart create mode 100644 test/fixtures/riverpod_state.dart create mode 100644 test/fixtures/state_suppression.dart create mode 100644 test/rules_test.dart create mode 100644 test/scanner_test.dart diff --git a/.gitignore b/.gitignore index e379085..1302ffc 100644 --- a/.gitignore +++ b/.gitignore @@ -11,17 +11,11 @@ build/ # CLI .flutterguard/ flutterguard +flutterguard.exe +dist/ # IDE -.idea/*/ -.idea/*.xml -.idea/*.iml -!.idea/modules.xml -!.idea/misc.xml -!.idea/vcs.xml -!.idea/runConfigurations/ -!.idea/runConfigurations/** -!.idea/.name +.idea/ .vscode/ *.iml diff --git a/analysis_options.yaml b/analysis_options.yaml index 5d31927..a12411a 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,6 +1,8 @@ +include: package:lints/recommended.yaml + analyzer: exclude: - - archive/** + - test/fixtures/** language: strict-casts: true strict-inference: true @@ -8,8 +10,6 @@ analyzer: linter: rules: - avoid_dynamic_calls - - prefer_const_constructors - prefer_const_declarations - prefer_final_locals - unawaited_futures - - use_key_in_widget_constructors diff --git a/bin/AGENTS.md b/bin/AGENTS.md new file mode 100644 index 0000000..8e7587e --- /dev/null +++ b/bin/AGENTS.md @@ -0,0 +1,7 @@ +# Executable layer + +`flutterguard.dart` may parse global arguments, route the four command families, +print usage/version, and map format errors to exit code 2. + +Keep business logic in `lib/src/cli` or the scan kernel. Do not add rule wiring, +configuration defaults, report construction, or a second versioned API here. diff --git a/bin/flutterguard.dart b/bin/flutterguard.dart new file mode 100644 index 0000000..678c719 --- /dev/null +++ b/bin/flutterguard.dart @@ -0,0 +1,111 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:flutterguard_cli/src/cli/baseline_commands.dart'; +import 'package:flutterguard_cli/src/cli/cli_parsers.dart'; +import 'package:flutterguard_cli/src/cli/config_commands.dart'; +import 'package:flutterguard_cli/src/cli/rule_commands.dart'; +import 'package:flutterguard_cli/src/cli/scan_command.dart'; + +const _version = '0.7.0'; + +void main(List args) { + final parsers = CliParsers(); + try { + final result = parsers.root.parse(args); + if (result['version'] == true) { + stdout.writeln('flutterguard $_version'); + return; + } + if (args.isEmpty || result['help'] == true || result.command == null) { + _usage(parsers.root); + return; + } + + final command = result.command!; + switch (command.name) { + case 'scan': + if (_help(command, parsers.scan, 'flutterguard scan [path]')) return; + ScanCommand.run(command, configPath: _explicitConfigPath(command)); + return; + case 'baseline': + final leaf = command.command; + if (command['help'] == true || leaf == null) { + _subcommandUsage( + 'flutterguard baseline create [path]', + parsers.baseline, + ); + return; + } + if (_help( + leaf, + parsers.baselineCreate, + 'flutterguard baseline create [path]', + )) { + return; + } + BaselineCommands.create(leaf, configPath: _explicitConfigPath(leaf)); + return; + case 'config': + final leaf = command.command; + if (command['help'] == true || leaf == null) { + _subcommandUsage( + 'flutterguard config [path]', + parsers.config, + ); + return; + } + if (leaf.name == 'init') { + if (_help( + leaf, + parsers.configInit, + 'flutterguard config init [path]', + )) { + return; + } + ConfigCommands.init(leaf); + return; + } + if (_help( + leaf, + parsers.configCheck, + 'flutterguard config check [path]', + )) { + return; + } + ConfigCommands.check(leaf, configPath: _explicitConfigPath(leaf)); + return; + case 'rules': + if (_help(command, parsers.rules, 'flutterguard rules [rule-id]')) { + return; + } + RuleCommands.run(command); + return; + } + } on FormatException catch (error) { + stderr.writeln('Error: ${error.message}'); + exitCode = 2; + } +} + +bool _help(ArgResults args, ArgParser parser, String usage) { + if (args['help'] != true) return false; + _subcommandUsage(usage, parser); + return true; +} + +String? _explicitConfigPath(ArgResults args) => + args.wasParsed('config') ? args['config'] as String : null; + +void _usage(ArgParser parser) { + stdout.writeln('FlutterGuard — IoT Flutter static analysis CLI'); + stdout.writeln('Usage: flutterguard [options]'); + stdout.writeln(); + stdout.writeln('Commands: scan, baseline, config, rules'); + stdout.writeln(parser.usage); +} + +void _subcommandUsage(String usage, ArgParser parser) { + stdout.writeln('Usage: $usage'); + stdout.writeln(parser.usage); +} diff --git a/flutterguard.yaml b/flutterguard.yaml index 5ae15ae..8fc2034 100644 --- a/flutterguard.yaml +++ b/flutterguard.yaml @@ -7,95 +7,7 @@ exclude: - lib/**.freezed.dart - lib/**.mocks.dart -rules: - large_file: - enabled: true - maxLines: 500 - large_class: - enabled: true - maxLines: 300 - large_build_method: - enabled: true - maxLines: 80 - lifecycle_resource: - enabled: true - missing_const_constructor: - enabled: true - side_effect_in_build: - enabled: true - severity: high - allowlist: [] - ignore_paths: [] - state_manager_created_in_build: - enabled: true - severity: high - allowlist: [] - ignore_paths: [] - mutable_state_exposed: - enabled: true - severity: medium - allowlist: [] - ignore_paths: [] - state_layer_ui_dependency: - enabled: true - severity: high - allowlist: [] - ignore_paths: [] - state_dependency_cycle: - enabled: true - severity: high - allowlist: [] - ignore_paths: [] - riverpod_read_used_for_render: - enabled: true - severity: medium - allowlist: [] - ignore_paths: [] - riverpod_watch_in_callback: - enabled: true - severity: medium - allowlist: [] - ignore_paths: [] - bloc_equatable_props_incomplete: - enabled: true - severity: medium - allowlist: [] - ignore_paths: [] - provider_value_lifecycle_misuse: - enabled: true - severity: medium - allowlist: [] - ignore_paths: [] - notify_listeners_in_loop: - enabled: true - severity: medium - allowlist: [] - ignore_paths: [] - -state_management: - enabled: true - framework_auto_detect: true - confidence_threshold: certain - architecture: - # layers: - # - name: presentation - # path: lib/presentation/** - # allowed_deps: [domain, core] - # - name: domain - # path: lib/domain/** - # allowed_deps: [core] - # - name: data - # path: lib/data/** - # allowed_deps: [domain, core] - # - name: core - # path: lib/core/** - # allowed_deps: [] - # modules: - # - name: device_mqtt - # path: lib/device/mqtt/** - # allowed_deps: [domain, core] - # - name: device_ble - # path: lib/device/ble/** - # allowed_deps: [domain, core] - # detect_cycles: true + layers: [] + modules: [] + detect_cycles: false diff --git a/lib/AGENTS.md b/lib/AGENTS.md new file mode 100644 index 0000000..387ed75 --- /dev/null +++ b/lib/AGENTS.md @@ -0,0 +1,7 @@ +# Library boundary + +`flutterguard_cli.dart` intentionally exports no scanner API. FlutterGuard is +an executable package; integrations use the CLI plus JSON or SARIF. + +All implementation belongs under `src/`. Do not export internal rule, +configuration, AST, or report types from the package barrel. diff --git a/lib/flutterguard_cli.dart b/lib/flutterguard_cli.dart new file mode 100644 index 0000000..1b53eb7 --- /dev/null +++ b/lib/flutterguard_cli.dart @@ -0,0 +1,5 @@ +/// FlutterGuard is distributed as the `flutterguard` executable. +/// +/// The scanner implementation under `lib/src` is intentionally not a public +/// Dart API. Integrations should invoke the CLI and consume JSON or SARIF. +library; diff --git a/lib/src/AGENTS.md b/lib/src/AGENTS.md new file mode 100644 index 0000000..943ea3f --- /dev/null +++ b/lib/src/AGENTS.md @@ -0,0 +1,18 @@ +# Scan kernel + +This directory owns reusable implementation behind the executable. + +- `scanner.dart`: orchestration, suppression, baseline, report writes. +- `scan_context.dart`: immutable scan scope. +- `source_workspace.dart`: one-read/one-parse cache and diagnostics. +- `import_graph.dart`: resolved project import graph. +- `boundary_engine.dart`: boundary evaluation shared by layer/module rules. +- `config_loader.dart`: compact YAML parser and generic `RuleConfig`. +- `config_tools.dart`: generated starter config and config validation. +- `report_generator.dart` / `sarif_report.dart`: output adapters. +- `cli/`: argument parser and command I/O only. +- `rules/`: definitions, registry, and detectors. + +Do not bypass `ScanContext`, parse files independently inside scanner wiring, +duplicate config defaults, or create another registry. File/path behavior must +remain cross-platform and changed-only imports must resolve against all files. diff --git a/lib/src/baseline.dart b/lib/src/baseline.dart new file mode 100644 index 0000000..0fd8ec6 --- /dev/null +++ b/lib/src/baseline.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'static_issue.dart'; + +class Baseline { + final Set fingerprints; + + const Baseline(this.fingerprints); + + static String fingerprint(StaticIssue issue, String projectPath) { + final relativePath = + p.isWithin(projectPath, issue.file) || p.equals(projectPath, issue.file) + ? p.relative(issue.file, from: projectPath) + : issue.file; + final normalizedPath = relativePath.replaceAll('\\', '/'); + final source = [ + issue.id, + normalizedPath, + issue.line ?? 1, + issue.message, + ].join('|'); + return _stableHash(source); + } + + bool contains(StaticIssue issue, String projectPath) { + return fingerprints.contains(fingerprint(issue, projectPath)); + } + + static Baseline load(String path) { + final file = File(path); + if (!file.existsSync()) { + throw FormatException('Baseline file "$path" does not exist.'); + } + + return loadFromString(file.readAsStringSync()); + } + + static Baseline loadFromString(String content) { + final decoded = jsonDecode(content); + if (decoded is! Map) { + throw const FormatException('Baseline file must be a JSON object.'); + } + final fingerprints = decoded['fingerprints']; + if (fingerprints is! List) { + throw const FormatException( + 'Baseline file must contain a fingerprints list.', + ); + } + + return Baseline(fingerprints.map((v) => v.toString()).toSet()); + } + + static 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/lib/src/boundary_engine.dart b/lib/src/boundary_engine.dart new file mode 100644 index 0000000..979aaea --- /dev/null +++ b/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/lib/src/cli/baseline_commands.dart b/lib/src/cli/baseline_commands.dart new file mode 100644 index 0000000..5dc4dbd --- /dev/null +++ b/lib/src/cli/baseline_commands.dart @@ -0,0 +1,44 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:path/path.dart' as p; + +import '../baseline.dart'; +import '../scanner.dart'; + +class BaselineCommands { + static void create(ArgResults args, {String? configPath}) { + final projectPath = args.rest.isNotEmpty ? args.rest.first : '.'; + final output = args['output'] as String; + try { + final result = FlutterGuardScanner.scan( + projectPath: projectPath, + configPath: configPath, + applySuppression: false, + ); + final outputPath = p.isAbsolute(output) + ? output + : p.join(result.projectPath, output); + Directory(p.dirname(outputPath)).createSync(recursive: true); + File(outputPath).writeAsStringSync( + Baseline.encode( + projectPath: result.projectPath, + issues: result.rawIssues, + ), + ); + stdout.writeln( + 'Created FlutterGuard baseline: $outputPath ' + '(${result.rawIssues.length} issues)', + ); + } on ScanException catch (error) { + _fail(error.message); + } on FormatException catch (error) { + _fail(error.message); + } + } + + static Never _fail(String message) { + stderr.writeln('Error: $message'); + exit(2); + } +} diff --git a/lib/src/cli/cli_parsers.dart b/lib/src/cli/cli_parsers.dart new file mode 100644 index 0000000..d438d91 --- /dev/null +++ b/lib/src/cli/cli_parsers.dart @@ -0,0 +1,77 @@ +import 'package:args/args.dart'; + +class CliParsers { + late final ArgParser scan; + late final ArgParser baselineCreate; + late final ArgParser baseline; + late final ArgParser configInit; + late final ArgParser configCheck; + late final ArgParser config; + late final ArgParser rules; + late final ArgParser root; + + CliParsers() { + scan = ArgParser() + ..addOption('config', abbr: 'c', help: 'Explicit config file') + ..addOption( + 'format', + abbr: 'f', + defaultsTo: 'table', + allowed: ['table', 'json', 'sarif'], + ) + ..addOption('output', abbr: 'o', defaultsTo: '.flutterguard') + ..addFlag('verbose', abbr: 'v', negatable: false) + ..addFlag('no-color', negatable: false) + ..addFlag('changed-only', negatable: false) + ..addOption('base', defaultsTo: 'main') + ..addOption( + 'fail-on', + defaultsTo: 'none', + allowed: ['none', 'high', 'medium', 'low'], + ) + ..addOption('baseline') + ..addFlag('help', abbr: 'h', negatable: false); + + baselineCreate = ArgParser() + ..addOption('config', abbr: 'c') + ..addOption( + 'output', + abbr: 'o', + defaultsTo: '.flutterguard/baseline.json', + ) + ..addFlag('help', abbr: 'h', negatable: false); + baseline = ArgParser() + ..addCommand('create', baselineCreate) + ..addFlag('help', abbr: 'h', negatable: false); + + configInit = ArgParser() + ..addOption('config', abbr: 'c', defaultsTo: 'flutterguard.yaml') + ..addFlag('with-architecture', negatable: false) + ..addFlag('force', negatable: false) + ..addFlag('help', abbr: 'h', negatable: false); + configCheck = ArgParser() + ..addOption('config', abbr: 'c') + ..addFlag('help', abbr: 'h', negatable: false); + config = ArgParser() + ..addCommand('init', configInit) + ..addCommand('check', configCheck) + ..addFlag('help', abbr: 'h', negatable: false); + + rules = ArgParser() + ..addOption( + 'format', + abbr: 'f', + defaultsTo: 'table', + allowed: ['table', 'json'], + ) + ..addFlag('help', abbr: 'h', negatable: false); + + root = ArgParser() + ..addCommand('scan', scan) + ..addCommand('baseline', baseline) + ..addCommand('config', config) + ..addCommand('rules', rules) + ..addFlag('help', abbr: 'h', negatable: false) + ..addFlag('version', abbr: 'V', negatable: false); + } +} diff --git a/lib/src/cli/config_commands.dart b/lib/src/cli/config_commands.dart new file mode 100644 index 0000000..496b88d --- /dev/null +++ b/lib/src/cli/config_commands.dart @@ -0,0 +1,44 @@ +import 'dart:io'; + +import 'package:args/args.dart'; + +import '../config_tools.dart'; + +class ConfigCommands { + static void init(ArgResults args) { + try { + final projectPath = args.rest.isNotEmpty ? args.rest.first : '.'; + final outputPath = ConfigTools.writeInitConfig( + projectPath: projectPath, + configPath: args['config'] as String, + withArchitecture: args['with-architecture'] as bool, + force: args['force'] as bool, + ); + stdout.writeln('Created FlutterGuard config: $outputPath'); + stdout.writeln('Next: flutterguard config check $projectPath'); + } on StateError catch (error) { + _fail(error.message); + } + } + + static void check(ArgResults args, {String? configPath}) { + try { + final projectPath = args.rest.isNotEmpty ? args.rest.first : '.'; + final result = ConfigTools.doctor( + projectPath: projectPath, + configPath: configPath, + ); + stdout.write(ConfigTools.formatDoctorResult(result)); + if (result.hasErrors) exit(1); + } on StateError catch (error) { + _fail(error.message); + } on FormatException catch (error) { + _fail(error.message); + } + } + + static Never _fail(String message) { + stderr.writeln('Error: $message'); + exit(2); + } +} diff --git a/lib/src/cli/rule_commands.dart b/lib/src/cli/rule_commands.dart new file mode 100644 index 0000000..fcb41c3 --- /dev/null +++ b/lib/src/cli/rule_commands.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/args.dart'; + +import '../rules/registry.dart'; +import '../rules/rule.dart'; + +class RuleCommands { + static void run(ArgResults args) { + if (args.rest.isNotEmpty) { + _describe(args.rest.first, json: args['format'] == 'json'); + return; + } + final rules = RuleRegistry.all()..sort((a, b) => a.id.compareTo(b.id)); + if (args['format'] == 'json') { + stdout.writeln( + const JsonEncoder.withIndent( + ' ', + ).convert(rules.map((rule) => rule.toJson()).toList()), + ); + return; + } + stdout.writeln('Available rules (${rules.length}):'); + for (final rule in rules) { + stdout.writeln( + ' ${rule.id.padRight(36)} ' + '${rule.domain.name.padRight(14)} ' + '${rule.defaultSeverity.name.padRight(8)} ${rule.name}', + ); + } + stdout.writeln(); + stdout.writeln('Run flutterguard rules for details.'); + } + + static void _describe(String id, {required bool json}) { + final rule = RuleRegistry.find(id); + if (rule == null) { + stderr.writeln('Error: unknown rule "$id".'); + exit(2); + } + if (json) { + stdout.writeln(const JsonEncoder.withIndent(' ').convert(rule.toJson())); + return; + } + _writeDefinition(rule); + } + + static void _writeDefinition(RuleDefinition rule) { + stdout.writeln('Rule: ${rule.id}'); + stdout.writeln('Name: ${rule.name}'); + stdout.writeln('Domain: ${rule.domain.name}'); + stdout.writeln('Severity: ${rule.defaultSeverity.name}'); + stdout.writeln('Framework: ${rule.framework}'); + stdout.writeln(); + stdout.writeln('Purpose: ${rule.purpose}'); + stdout.writeln('Risk: ${rule.riskReason}'); + stdout.writeln('Example: ${rule.badExample}'); + stdout.writeln('Fix: ${rule.fixSuggestion}'); + stdout.writeln('Config: ${rule.configKeys.join(', ')}'); + } +} diff --git a/lib/src/cli/scan_command.dart b/lib/src/cli/scan_command.dart new file mode 100644 index 0000000..9efcb83 --- /dev/null +++ b/lib/src/cli/scan_command.dart @@ -0,0 +1,81 @@ +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 projectPath = args.rest.isNotEmpty ? args.rest.first : '.'; + + late final ScanResult result; + try { + result = FlutterGuardScanner.scan( + projectPath: projectPath, + 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); + } + } +} diff --git a/lib/src/config_loader.dart b/lib/src/config_loader.dart new file mode 100644 index 0000000..55e6fb5 --- /dev/null +++ b/lib/src/config_loader.dart @@ -0,0 +1,229 @@ +// ignore_for_file: avoid_dynamic_calls + +import 'dart:io'; + +import 'package:yaml/yaml.dart'; + +import 'static_issue.dart'; + +typedef BoundaryConfig = ({String name, String path, List allowedDeps}); + +typedef ArchitectureConfig = ({ + List layers, + List modules, + bool detectCycles, +}); + +class RuleConfig { + final bool enabled; + final RiskLevel severity; + final Map options; + + const RuleConfig({ + required this.enabled, + required this.severity, + this.options = const {}, + }); + + bool boolOption(String key) { + final value = options[key]; + if (value is bool) return value; + throw FormatException('rules option "$key" must be a boolean.'); + } +} + +class ScanConfig { + final List include; + final List exclude; + final Map< + String, + ({bool enabled, RiskLevel? severity, Map options}) + > + _rules; + final ArchitectureConfig architecture; + + const ScanConfig({ + required this.include, + required this.exclude, + required Map< + String, + ({bool enabled, RiskLevel? severity, Map options}) + > + rules, + required this.architecture, + }) : _rules = rules; + + Set get configuredRuleIds => _rules.keys.toSet(); + + Map configuredOptions(String id) => + Map.unmodifiable(_rules[id]?.options ?? const {}); + + RuleConfig rule( + String id, { + required RiskLevel defaultSeverity, + Map defaultOptions = const {}, + }) { + final configured = _rules[id]; + return RuleConfig( + enabled: configured?.enabled ?? true, + severity: configured?.severity ?? defaultSeverity, + options: {...defaultOptions, ...?configured?.options}, + ); + } + + static const _knownTopLevelKeys = { + 'include', + 'exclude', + 'rules', + 'architecture', + }; + static const _knownArchitectureKeys = {'layers', 'modules', 'detect_cycles'}; + static const _knownBoundaryKeys = {'name', 'path', 'allowed_deps'}; + + factory ScanConfig.fromFile(String path, {bool requireFile = false}) { + final file = File(path); + if (!file.existsSync()) { + if (requireFile) { + throw FormatException('Config file "$path" does not exist.'); + } + return ScanConfig.defaults(); + } + + final parsed = loadYaml(file.readAsStringSync()); + if (parsed == null) return ScanConfig.defaults(); + if (parsed is! YamlMap) { + throw FormatException('Config file "$path" must contain a YAML map.'); + } + + _warnUnknownKeys(parsed, _knownTopLevelKeys, 'config'); + final architecture = _optionalMap(parsed['architecture'], 'architecture'); + _warnUnknownKeys(architecture, _knownArchitectureKeys, 'architecture'); + + return ScanConfig( + include: _stringList(parsed['include']) ?? const ['lib/**'], + exclude: + _stringList(parsed['exclude']) ?? + const [ + 'lib/generated/**', + 'lib/**.g.dart', + 'lib/**.freezed.dart', + 'lib/**.mocks.dart', + ], + rules: _parseRules(_optionalMap(parsed['rules'], 'rules')), + architecture: ( + layers: _parseBoundaries(architecture['layers'], 'layers'), + modules: _parseBoundaries(architecture['modules'], 'modules'), + detectCycles: _boolValue(architecture, 'detect_cycles', false), + ), + ); + } + + factory ScanConfig.defaults() => const ScanConfig( + include: ['lib/**'], + exclude: [ + 'lib/generated/**', + 'lib/**.g.dart', + 'lib/**.freezed.dart', + 'lib/**.mocks.dart', + ], + rules: {}, + architecture: (layers: [], modules: [], detectCycles: false), + ); + + static Map< + String, + ({bool enabled, RiskLevel? severity, Map options}) + > + _parseRules(YamlMap rules) { + final result = + < + String, + ({bool enabled, RiskLevel? severity, Map options}) + >{}; + for (final entry in rules.entries) { + final id = entry.key.toString(); + final map = _optionalMap(entry.value, 'rules.$id'); + final options = {}; + for (final option in map.entries) { + final key = option.key.toString(); + if (key == 'enabled' || key == 'severity') continue; + final value = option.value; + if (value is bool || value is int || value is String) { + options[key] = value; + } else { + throw FormatException('rules.$id.$key must be a scalar value.'); + } + } + result[id] = ( + enabled: _boolValue(map, 'enabled', true), + severity: _optionalRiskLevel(map, 'severity'), + options: options, + ); + } + return result; + } + + static List _parseBoundaries(Object? value, String path) { + if (value == null) return const []; + if (value is! YamlList) { + throw FormatException('architecture.$path must be a list.'); + } + return [ + for (final item in value) _parseBoundary(item, 'architecture.$path'), + ]; + } + + static BoundaryConfig _parseBoundary(Object? value, String path) { + if (value is! YamlMap) { + throw FormatException('Each $path entry must be a map.'); + } + _warnUnknownKeys(value, _knownBoundaryKeys, path); + return ( + name: _requiredString(value, 'name', path), + path: _requiredString(value, 'path', path), + allowedDeps: _stringList(value['allowed_deps']) ?? const [], + ); + } + + static YamlMap _optionalMap(Object? value, String path) { + if (value == null) return YamlMap(); + if (value is YamlMap) return value; + throw FormatException('$path must be a map.'); + } + + static List? _stringList(Object? value) { + if (value == null) return null; + if (value is! YamlList) throw const FormatException('Expected a list.'); + return value.map((item) => item.toString()).toList(); + } + + static bool _boolValue(YamlMap map, String key, bool fallback) { + final value = map[key]; + if (value == null) return fallback; + if (value is bool) return value; + throw FormatException('$key must be a boolean.'); + } + + static RiskLevel? _optionalRiskLevel(YamlMap map, String key) { + final value = map[key]; + if (value == null) return null; + for (final severity in RiskLevel.values) { + if (severity.name == value) return severity; + } + throw FormatException('$key must be one of: low, medium, high.'); + } + + static String _requiredString(YamlMap map, String key, String path) { + final value = map[key]; + if (value is String && value.trim().isNotEmpty) return value; + throw FormatException('$path.$key must be a non-empty string.'); + } + + static void _warnUnknownKeys(YamlMap map, Set known, String context) { + for (final key in map.keys.map((key) => key.toString())) { + if (!known.contains(key)) { + stderr.writeln('Warning: unknown $context key "$key".'); + } + } + } +} diff --git a/lib/src/config_tools.dart b/lib/src/config_tools.dart new file mode 100644 index 0000000..5b24221 --- /dev/null +++ b/lib/src/config_tools.dart @@ -0,0 +1,258 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'config_loader.dart'; +import 'file_collector.dart'; +import 'path_utils.dart'; +import 'project_resolver.dart'; +import 'rules/registry.dart'; + +enum DoctorSeverity { info, warning, error } + +class DoctorMessage { + final DoctorSeverity severity; + final String message; + + const DoctorMessage(this.severity, this.message); +} + +class DoctorResult { + final String projectPath; + final String configPath; + final bool configExists; + final int fileCount; + final List messages; + + const DoctorResult({ + required this.projectPath, + required this.configPath, + required this.configExists, + required this.fileCount, + required this.messages, + }); + + bool get hasErrors => + messages.any((message) => message.severity == DoctorSeverity.error); +} + +class ConfigTools { + static String initTemplate({required bool withArchitecture}) { + final buffer = StringBuffer() + ..writeln('include:') + ..writeln(' - lib/**') + ..writeln() + ..writeln('exclude:') + ..writeln(' - lib/generated/**') + ..writeln(' - lib/**.g.dart') + ..writeln(' - lib/**.freezed.dart') + ..writeln(' - lib/**.mocks.dart') + ..writeln() + ..writeln('rules:'); + for (final rule in RuleRegistry.all()) { + buffer + ..writeln(' ${rule.id}:') + ..writeln(' enabled: true') + ..writeln(' severity: ${rule.defaultSeverity.name}'); + for (final option in rule.defaultOptions.entries) { + buffer.writeln(' ${option.key}: ${option.value}'); + } + } + + buffer + ..writeln() + ..writeln('architecture:') + ..writeln(' detect_cycles: false'); + if (!withArchitecture) { + buffer + ..writeln(' layers: []') + ..writeln(' modules: []'); + return buffer.toString(); + } + + buffer + ..writeln(' layers:') + ..writeln(' - name: presentation') + ..writeln(' path: lib/presentation/**') + ..writeln(' allowed_deps: [domain, core]') + ..writeln(' - name: domain') + ..writeln(' path: lib/domain/**') + ..writeln(' allowed_deps: [core]') + ..writeln(' - name: core') + ..writeln(' path: lib/core/**') + ..writeln(' allowed_deps: []') + ..writeln(' modules: []'); + return buffer.toString(); + } + + static String writeInitConfig({ + required String projectPath, + required String configPath, + required bool withArchitecture, + required bool force, + }) { + final resolvedProjectPath = ProjectResolver.resolveProjectPath(projectPath); + final outputPath = p.isAbsolute(configPath) + ? configPath + : p.join(resolvedProjectPath, configPath); + final file = File(outputPath); + if (file.existsSync() && !force) { + throw StateError( + 'Config file already exists at "$outputPath". Use --force to overwrite.', + ); + } + file.parent.createSync(recursive: true); + file.writeAsStringSync(initTemplate(withArchitecture: withArchitecture)); + return outputPath; + } + + static String resolveConfigPathForProject({ + required String projectPath, + String? configPath, + }) => ProjectResolver.resolveConfigPath( + projectPath: projectPath, + explicitConfig: configPath, + ); + + static DoctorResult doctor({ + required String projectPath, + String? configPath, + }) { + final resolvedProjectPath = ProjectResolver.resolveProjectPath(projectPath); + if (!Directory(resolvedProjectPath).existsSync()) { + throw StateError('Project path "$resolvedProjectPath" does not exist.'); + } + final resolvedConfigPath = resolveConfigPathForProject( + projectPath: resolvedProjectPath, + configPath: configPath, + ); + final exists = File(resolvedConfigPath).existsSync(); + final config = ScanConfig.fromFile( + resolvedConfigPath, + requireFile: configPath != null, + ); + final files = FileCollector.collect(resolvedProjectPath, config); + final messages = []; + + if (!exists) { + messages.add( + const DoctorMessage( + DoctorSeverity.info, + 'No config file found. Built-in defaults are being used.', + ), + ); + } + if (files.isEmpty) { + messages.add( + const DoctorMessage( + DoctorSeverity.error, + 'No Dart files matched include/exclude patterns.', + ), + ); + } + + final definitions = {for (final rule in RuleRegistry.all()) rule.id: rule}; + final knownRules = definitions.keys.toSet(); + for (final id in config.configuredRuleIds.difference(knownRules)) { + messages.add(DoctorMessage(DoctorSeverity.error, 'Unknown rule "$id".')); + } + for (final id in config.configuredRuleIds.intersection(knownRules)) { + final knownOptions = definitions[id]!.defaultOptions.keys.toSet(); + for (final option + in config + .configuredOptions(id) + .keys + .toSet() + .difference(knownOptions)) { + messages.add( + DoctorMessage( + DoctorSeverity.error, + 'Unknown option "rules.$id.$option".', + ), + ); + } + } + _checkBoundaries( + kind: 'layer', + boundaries: config.architecture.layers, + files: files, + projectPath: resolvedProjectPath, + messages: messages, + ); + _checkBoundaries( + kind: 'module', + boundaries: config.architecture.modules, + files: files, + projectPath: resolvedProjectPath, + messages: messages, + ); + + return DoctorResult( + projectPath: resolvedProjectPath, + configPath: resolvedConfigPath, + configExists: exists, + fileCount: files.length, + messages: messages, + ); + } + + static String formatDoctorResult(DoctorResult result) { + final buffer = StringBuffer() + ..writeln('FlutterGuard config check') + ..writeln('Project: ${result.projectPath}') + ..writeln('Config: ${result.configPath}') + ..writeln('Config exists: ${result.configExists}') + ..writeln('Matched Dart files: ${result.fileCount}'); + if (result.messages.isEmpty) { + buffer.writeln('Status: OK'); + return buffer.toString(); + } + buffer.writeln(); + for (final message in result.messages) { + buffer.writeln( + '${message.severity.name.toUpperCase()}: ${message.message}', + ); + } + return buffer.toString(); + } + + static void _checkBoundaries({ + required String kind, + required List boundaries, + required List files, + required String projectPath, + required List messages, + }) { + final names = boundaries.map((boundary) => boundary.name).toSet(); + if (names.length != boundaries.length) { + messages.add( + DoctorMessage( + DoctorSeverity.error, + 'Duplicate architecture $kind names found.', + ), + ); + } + for (final boundary in boundaries) { + final unknown = boundary.allowedDeps.where((dep) => !names.contains(dep)); + for (final dependency in unknown) { + messages.add( + DoctorMessage( + DoctorSeverity.error, + 'Architecture $kind "${boundary.name}" allows unknown dependency "$dependency".', + ), + ); + } + final matches = files.any( + (file) => matchesProjectGlob(file, boundary.path, projectPath), + ); + if (!matches) { + messages.add( + DoctorMessage( + DoctorSeverity.warning, + 'Architecture $kind "${boundary.name}" path "${boundary.path}" matched no files.', + ), + ); + } + } + } +} diff --git a/lib/src/file_collector.dart b/lib/src/file_collector.dart new file mode 100644 index 0000000..fb849b8 --- /dev/null +++ b/lib/src/file_collector.dart @@ -0,0 +1,150 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:glob/glob.dart'; +import 'package:glob/list_local_fs.dart'; +import 'package:path/path.dart' as p; + +import 'config_loader.dart'; +import 'path_utils.dart'; + +class ChangedFilesException implements Exception { + final String message; + + const ChangedFilesException(this.message); + + @override + String toString() => message; +} + +class FileCollector { + static List collect(String projectPath, ScanConfig config) { + final context = projectPathContext(projectPath); + final allFiles = {}; + + for (final pattern in config.include) { + final glob = Glob(pattern.replaceAll('\\', '/'), context: context); + for (final entity in glob.listSync(root: context.current)) { + if (entity is File && entity.path.endsWith('.dart')) { + allFiles.add(normalizePath(entity.path, context: context)); + } + } + } + + for (final pattern in config.exclude) { + final glob = Glob(pattern.replaceAll('\\', '/'), context: context); + for (final entity in glob.listSync(root: context.current)) { + allFiles.remove(normalizePath(entity.path, context: context)); + } + allFiles.removeWhere((f) => glob.matches(f)); + } + + return allFiles.toList()..sort(); + } + + static Set? getChangedFiles(String projectPath, String base) { + try { + final rootResult = Process.runSync( + 'git', + ['rev-parse', '--show-toplevel'], + workingDirectory: projectPath, + stdoutEncoding: utf8, + ); + if (rootResult.exitCode != 0) return null; + + final gitRoot = (rootResult.stdout as String).trim(); + final canonicalGitRoot = Directory(gitRoot).resolveSymbolicLinksSync(); + final canonicalProjectPath = Directory( + projectPath, + ).resolveSymbolicLinksSync(); + if (base.startsWith('-')) { + throw ChangedFilesException('Invalid Git base "$base".'); + } + final refResult = Process.runSync( + 'git', + ['rev-parse', '--verify', '$base^{commit}'], + workingDirectory: gitRoot, + stdoutEncoding: utf8, + ); + if (refResult.exitCode != 0) { + throw ChangedFilesException( + _gitFailureMessage( + 'Invalid Git base "$base"', + refResult.stderr.toString(), + ), + ); + } + 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.toString()), + ); + } + final untracked = Process.runSync( + 'git', + ['ls-files', '--others', '--exclude-standard', '-z'], + workingDirectory: gitRoot, + stdoutEncoding: utf8, + ); + if (untracked.exitCode != 0) { + throw ChangedFilesException( + _gitFailureMessage('git ls-files', untracked.stderr.toString()), + ); + } + + final changed = {}; + for (final path in (result.stdout as String).split('\x00')) { + if (path.isNotEmpty) { + changed.add( + _projectAnchoredGitPath( + projectPath: projectPath, + canonicalProjectPath: canonicalProjectPath, + canonicalGitRoot: canonicalGitRoot, + gitRelativePath: path, + ), + ); + } + } + for (final path in (untracked.stdout as String).split('\x00')) { + if (path.isNotEmpty) { + changed.add( + _projectAnchoredGitPath( + projectPath: projectPath, + canonicalProjectPath: canonicalProjectPath, + canonicalGitRoot: canonicalGitRoot, + gitRelativePath: path, + ), + ); + } + } + return changed; + } on ProcessException { + return null; + } + } + + static String _gitFailureMessage(String command, Object stderr) { + final detail = stderr.toString().trim(); + return detail.isEmpty ? '$command failed.' : '$command failed: $detail'; + } + + static String _projectAnchoredGitPath({ + required String projectPath, + required String canonicalProjectPath, + required String canonicalGitRoot, + required String gitRelativePath, + }) { + final canonicalPath = p.join(canonicalGitRoot, gitRelativePath); + final projectRelativePath = p.relative( + canonicalPath, + from: canonicalProjectPath, + ); + return p.normalize(p.join(projectPath, projectRelativePath)); + } +} diff --git a/lib/src/import_graph.dart b/lib/src/import_graph.dart new file mode 100644 index 0000000..855cd2b --- /dev/null +++ b/lib/src/import_graph.dart @@ -0,0 +1,75 @@ +import 'package:analyzer/dart/ast/ast.dart'; + +import 'import_utils.dart'; +import 'path_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/lib/src/import_utils.dart b/lib/src/import_utils.dart new file mode 100644 index 0000000..999fba9 --- /dev/null +++ b/lib/src/import_utils.dart @@ -0,0 +1,71 @@ +import 'package:path/path.dart' as p; + +import 'path_utils.dart'; + +String? resolveImport( + String sourceFile, + String importStr, + Set fileSet, { + String? projectPath, + p.Context? context, +}) { + context ??= p.context; + final source = normalizePath(sourceFile, context: context); + final normalizedFiles = { + for (final file in fileSet) normalizePath(file, context: context), + }; + + if (importStr.startsWith('package:')) { + final packageRelative = importStr.replaceFirst( + RegExp(r'^package:[^/]+/'), + '', + ); + return _resolvePackageImport( + packageRelative, + normalizedFiles, + projectPath: projectPath, + context: context, + ); + } + + final sourceDir = context.dirname(source); + final resolved = context.normalize(context.join(sourceDir, importStr)); + if (normalizedFiles.contains(resolved)) return resolved; + final withExt = resolved.endsWith('.dart') ? resolved : '$resolved.dart'; + if (normalizedFiles.contains(withExt)) return withExt; + return null; +} + +String? _resolvePackageImport( + String packageRelative, + Set fileSet, { + String? projectPath, + required p.Context context, +}) { + final relativeWithExt = packageRelative.endsWith('.dart') + ? packageRelative + : '$packageRelative.dart'; + final normalizedRelative = context.normalize(relativeWithExt); + + if (projectPath != null) { + final projectContext = projectPathContext(projectPath, context: context); + final candidate = projectContext.normalize( + projectContext.join(projectContext.current, 'lib', normalizedRelative), + ); + if (fileSet.contains(candidate)) return candidate; + } + + for (final file in fileSet) { + final libRelative = _relativeToLib(file, context); + if (libRelative == normalizedRelative) return file; + } + + return null; +} + +String? _relativeToLib(String file, p.Context context) { + final parts = context.split(context.normalize(file)); + final libIndex = parts.lastIndexOf('lib'); + if (libIndex == -1 || libIndex == parts.length - 1) return null; + return context.joinAll(parts.skip(libIndex + 1)); +} diff --git a/lib/src/path_utils.dart b/lib/src/path_utils.dart new file mode 100644 index 0000000..622ddb9 --- /dev/null +++ b/lib/src/path_utils.dart @@ -0,0 +1,44 @@ +import 'package:glob/glob.dart'; +import 'package:path/path.dart' as p; + +p.Context projectPathContext(String projectPath, {p.Context? context}) { + context ??= p.context; + final absoluteRoot = context.normalize(context.absolute(projectPath)); + return p.Context(style: context.style, current: absoluteRoot); +} + +String normalizePath(String path, {p.Context? context, String? basePath}) { + context ??= p.context; + if (basePath != null) { + context = projectPathContext(basePath, context: context); + } + final absolute = context.isAbsolute(path) ? path : context.absolute(path); + return context.normalize(absolute); +} + +bool matchesProjectGlob( + String filePath, + String pattern, + String projectPath, { + p.Context? context, +}) { + final projectContext = projectPathContext(projectPath, context: context); + final normalizedFile = normalizePath(filePath, context: projectContext); + final normalizedPattern = pattern.replaceAll('\\', '/'); + return Glob( + normalizedPattern, + context: projectContext, + ).matches(normalizedFile); +} + +String projectRelativePath( + String filePath, + String projectPath, { + p.Context? context, +}) { + final projectContext = projectPathContext(projectPath, context: context); + return projectContext.relative( + normalizePath(filePath, context: projectContext), + from: projectContext.current, + ); +} diff --git a/lib/src/project_resolver.dart b/lib/src/project_resolver.dart new file mode 100644 index 0000000..29c7954 --- /dev/null +++ b/lib/src/project_resolver.dart @@ -0,0 +1,50 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +class ProjectResolver { + static const defaultConfigPath = 'flutterguard.yaml'; + + static const _discoveryMarkers = [defaultConfigPath, 'pubspec.yaml']; + + static String resolveProjectPath(String? explicitPath) { + if (explicitPath != null && explicitPath != '.') { + return p.normalize(p.absolute(explicitPath)); + } + final discovered = _walkUpFind(Directory.current.path); + return discovered ?? Directory.current.path; + } + + static String resolveConfigPath({ + required String projectPath, + required String? explicitConfig, + }) { + 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.'); + } + + return resolvedPath; + } + + static String? _walkUpFind(String startPath) { + var dir = Directory(startPath); + while (true) { + for (final marker in _discoveryMarkers) { + final candidate = p.join(dir.path, marker); + if (File(candidate).existsSync()) return dir.path; + } + final libCandidate = p.join(dir.path, 'lib'); + if (Directory(libCandidate).existsSync()) return dir.path; + + final parent = dir.parent.path; + if (parent == dir.path) break; + dir = dir.parent; + } + return null; + } +} diff --git a/lib/src/report_generator.dart b/lib/src/report_generator.dart new file mode 100644 index 0000000..5c0cee6 --- /dev/null +++ b/lib/src/report_generator.dart @@ -0,0 +1,179 @@ +import 'dart:convert'; + +import 'package:path/path.dart' as p; + +import 'source_workspace.dart'; +import 'static_issue.dart'; + +class _Ansi { + static const reset = '\x1B[0m'; + static const red = '\x1B[31m'; + static const yellow = '\x1B[33m'; + static const gray = '\x1B[90m'; + static const bold = '\x1B[1m'; + static const dim = '\x1B[2m'; + static const green = '\x1B[32m'; +} + +const _domainLabels = { + IssueDomain.architecture: '架构与安全', + IssueDomain.performance: '生命周期与性能', + IssueDomain.standards: '工程标准', +}; + +const _levelLabels = { + RiskLevel.high: 'HIGH', + RiskLevel.medium: 'MEDIUM', + RiskLevel.low: 'LOW', +}; + +class ReportGenerator { + static String generateJson({ + required String projectPath, + required List issues, + required String scanMode, + int suppressedCount = 0, + int suppressedByBaselineCount = 0, + List diagnostics = const [], + }) { + final payload = { + 'schemaVersion': '2.0.0', + 'projectPath': projectPath, + 'scanMode': scanMode, + 'summary': { + 'total': issues.length, + 'high': issues.where((issue) => issue.level == RiskLevel.high).length, + 'medium': issues + .where((issue) => issue.level == RiskLevel.medium) + .length, + 'low': issues.where((issue) => issue.level == RiskLevel.low).length, + 'suppressed': suppressedCount, + 'suppressedByBaseline': suppressedByBaselineCount, + 'diagnostics': diagnostics.length, + 'byDomain': _summaryByDomain(issues), + }, + 'issues': issues.map((issue) => issue.toJson()).toList(), + 'diagnostics': diagnostics.map((item) => item.toJson()).toList(), + }; + return const JsonEncoder.withIndent(' ').convert(payload); + } + + static String generateStdout({ + required String projectPath, + required List issues, + int? scannedFileCount, + bool verbose = false, + bool noColor = false, + }) { + final buffer = StringBuffer(); + final bold = noColor ? '' : _Ansi.bold; + final reset = noColor ? '' : _Ansi.reset; + buffer + ..writeln('$bold FlutterGuard Report$reset — ${p.basename(projectPath)}') + ..writeln( + ' Files: ${scannedFileCount ?? issues.map((issue) => issue.file).toSet().length} ' + 'Issues: ${issues.length}', + ); + if (issues.isEmpty) { + buffer.writeln(' No issues found.'); + return buffer.toString(); + } + + for (final domain in IssueDomain.values) { + final domainIssues = issues + .where((issue) => issue.domain == domain) + .toList(); + if (domainIssues.isEmpty) continue; + buffer + ..writeln() + ..writeln('${_domainLabels[domain]} (${domainIssues.length})'); + domainIssues.sort( + (a, b) => _severityOrder(a).compareTo(_severityOrder(b)), + ); + for (final issue in domainIssues) { + _writeIssue( + buffer, + issue, + projectPath, + verbose: verbose, + noColor: noColor, + ); + } + } + return buffer.toString(); + } + + static bool shouldFail(List issues, String failOn) { + final threshold = switch (failOn) { + 'high' => 2, + 'medium' => 1, + 'low' => 0, + _ => 3, + }; + return issues.any((issue) => issue.level.index >= threshold); + } + + static void _writeIssue( + StringBuffer buffer, + StaticIssue issue, + String projectPath, { + required bool verbose, + required bool noColor, + }) { + final color = noColor + ? '' + : switch (issue.level) { + RiskLevel.high => _Ansi.red, + RiskLevel.medium => _Ansi.yellow, + RiskLevel.low => _Ansi.gray, + }; + final reset = noColor ? '' : _Ansi.reset; + final bold = noColor ? '' : _Ansi.bold; + final dim = noColor ? '' : _Ansi.dim; + final green = noColor ? '' : _Ansi.green; + final path = p.isWithin(projectPath, issue.file) + ? p.relative(issue.file, from: projectPath) + : issue.file; + final line = issue.line == null ? '' : ':${issue.line}'; + buffer + ..writeln(' $color${_levelLabels[issue.level]}$reset ${issue.id}') + ..writeln(' $bold${issue.title}$reset') + ..writeln(' $path$line — ${issue.message}'); + if (verbose && issue.detail.isNotEmpty) { + for (final detail in issue.detail.split('\n')) { + buffer.writeln(' $dim$detail$reset'); + } + } + if (verbose) { + for (final evidence in issue.evidence.take(5)) { + buffer.writeln(' $dim evidence: $evidence$reset'); + } + } + buffer.writeln(' $green fix: ${issue.suggestion}$reset'); + } + + static int _severityOrder(StaticIssue issue) => switch (issue.level) { + RiskLevel.high => 0, + RiskLevel.medium => 1, + RiskLevel.low => 2, + }; + + static Map> _summaryByDomain( + List issues, + ) { + final result = >{}; + for (final domain in IssueDomain.values) { + final values = issues.where((issue) => issue.domain == domain).toList(); + if (values.isEmpty) continue; + result[domain.name] = { + 'high': values.where((issue) => issue.level == RiskLevel.high).length, + 'medium': values + .where((issue) => issue.level == RiskLevel.medium) + .length, + 'low': values.where((issue) => issue.level == RiskLevel.low).length, + 'total': values.length, + }; + } + return result; + } +} diff --git a/lib/src/rules/AGENTS.md b/lib/src/rules/AGENTS.md new file mode 100644 index 0000000..44c1e5f --- /dev/null +++ b/lib/src/rules/AGENTS.md @@ -0,0 +1,20 @@ +# Rule layer + +Every detector returns `List`. Scanner execution is explicit in +`RuleRegistry.registrations`; metadata and defaults live in the adjacent +`RuleDefinition`. + +Ownership: + +- `boundary_rule.dart`: both layer and module dependency enforcement. +- `lifecycle_resource.dart`: resource cancel/close/dispose/disconnect checks. +- `ble_scanning.dart`: BLE scan timeout only. +- `iot_security.dart`: credentials and insecure transport. +- state files: generic, Riverpod, Bloc, Provider, and dependency-cycle checks. + +Use the supplied `SourceWorkspace`; do not read or parse files again. Respect +`RuleConfig.enabled` and emit `config.severity`. Add special scalar defaults to +`RuleDefinition.defaultOptions`. + +Do not add `describe` metadata to another catalog, restore direct public rule +exports, or let two rule families own the same finding. diff --git a/lib/src/rules/ble_scanning.dart b/lib/src/rules/ble_scanning.dart new file mode 100644 index 0000000..cd9672f --- /dev/null +++ b/lib/src/rules/ble_scanning.dart @@ -0,0 +1,110 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/source/line_info.dart'; + +import '../config_loader.dart'; +import 'rule.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; + +const _bleTypePatterns = ['Ble', 'BluetoothDevice', 'Bluetooth']; + +class BleScanningRule { + final RuleConfig config; + + const BleScanningRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!config.enabled) return []; + + final issues = []; + final sources = workspace ?? SourceWorkspace(); + + for (final file in files) { + final source = sources.source(file); + if (source == null) continue; + issues.addAll(_checkFile(file, source.unit, source.lineInfo)); + } + + return issues; + } + + List _checkFile( + String file, + CompilationUnit unit, + LineInfo lineInfo, + ) { + final issues = []; + + for (final cls in unit.declarations.whereType()) { + final hasBleField = cls.members.whereType().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(); + _checkScanTimeout( + file, + startScanMethod: methods, + lineInfo: lineInfo, + issues: issues, + ); + } + + return issues; + } + + void _checkScanTimeout( + String file, { + required List startScanMethod, + required LineInfo lineInfo, + required List issues, + }) { + for (final method in startScanMethod) { + if (method.name.lexeme != 'startScan') continue; + + final body = method.toString().toLowerCase(); + final hasTimeout = + body.contains('timeout') || + body.contains('duration') || + body.contains('maxscanduration'); + + if (!hasTimeout) { + final line = lineNumberForOffset(lineInfo, method.name.offset); + issues.add( + StaticIssue( + id: 'ble_scanning', + title: 'BLE 扫描缺少超时配置', + file: file, + line: line, + level: config.severity, + domain: IssueDomain.architecture, + message: 'startScan() 调用未配置超时参数', + detail: + '方法: ${method.name.lexeme}\n' + 'BLE 扫描应设置超时以限制扫描时间,避免过度耗电', + suggestion: '为 startScan() 添加明确的 timeout 或 duration 参数', + metadata: {'check': 'scan_without_timeout'}, + ), + ); + } + } + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'ble_scanning', + name: 'BLE 扫描管理异常', + domain: IssueDomain.architecture, + defaultSeverity: RiskLevel.medium, + purpose: '检测 BLE 扫描是否配置明确超时', + riskReason: '无限扫描会持续占用无线资源并消耗电量', + badExample: 'startScan() 没有 timeout 或 duration 限制', + fixSuggestion: '为 startScan 添加明确超时,并由资源生命周期规则检查释放', + ); +} diff --git a/lib/src/rules/bloc_state_management.dart b/lib/src/rules/bloc_state_management.dart new file mode 100644 index 0000000..9ec79e3 --- /dev/null +++ b/lib/src/rules/bloc_state_management.dart @@ -0,0 +1,103 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import '../config_loader.dart'; +import 'rule.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; +import 'state_management_utils.dart'; + +class BlocEquatablePropsIncompleteRule { + final RuleConfig config; + + const BlocEquatablePropsIncompleteRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + final source = sources.source(file); + if (source == null) continue; + if (!hasEquatableImport(source.unit)) { + continue; + } + if (!frameworkAllowed(source.unit, StateManagementFramework.bloc)) { + continue; + } + for (final cls + in source.unit.declarations.whereType()) { + if (!_inheritsEquatable(cls)) continue; + final fields = {}; + for (final field in cls.members.whereType()) { + if (field.isStatic || !field.fields.isFinal) continue; + for (final variable in field.fields.variables) { + final name = variable.name.lexeme; + fields.add(name); + } + } + if (fields.isEmpty) continue; + final props = cls.members.whereType().where( + (method) => method.isGetter && method.name.lexeme == 'props', + ); + final referenced = {}; + for (final getter in props) { + final visitor = _IdentifierCollector(); + getter.body.accept(visitor); + referenced.addAll(visitor.names); + } + final missing = fields.difference(referenced).toList()..sort(); + if (missing.isEmpty) continue; + issues.add( + StaticIssue( + id: 'bloc_equatable_props_incomplete', + title: 'Equatable props 不完整', + file: file, + line: sourceLine(source, cls), + level: config.severity, + domain: IssueDomain.standards, + message: '${cls.name.lexeme}.props 缺少字段: ${missing.join(', ')}', + suggestion: '将所有参与值相等判断的 final instance 字段加入 props', + framework: StateManagementFramework.bloc, + evidence: missing + .map((field) => 'missing: $field') + .take(5) + .toList(), + metadata: {'className': cls.name.lexeme, 'missingFields': missing}, + ), + ); + } + } + return issues; + } + + static bool _inheritsEquatable(ClassDeclaration cls) { + final superclass = cls.extendsClause?.superclass.toSource() ?? ''; + final mixins = + cls.withClause?.mixinTypes.map((type) => type.toSource()).toList() ?? + const []; + return superclass == 'Equatable' || mixins.contains('EquatableMixin'); + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'bloc_equatable_props_incomplete', + name: 'Equatable props 不完整', + domain: IssueDomain.standards, + defaultSeverity: RiskLevel.medium, + purpose: '比较 Equatable 类的 final instance 字段与 props 字段引用', + riskReason: '遗漏字段会让不同状态被误判为相等,阻止 Bloc UI 更新', + badExample: 'final a; final b; List get props => [a];', + fixSuggestion: '把遗漏的值字段加入 props', + framework: 'bloc', + ); +} + +class _IdentifierCollector extends RecursiveAstVisitor { + final names = {}; + + @override + void visitSimpleIdentifier(SimpleIdentifier node) { + names.add(node.name); + super.visitSimpleIdentifier(node); + } +} diff --git a/lib/src/rules/boundary_rule.dart b/lib/src/rules/boundary_rule.dart new file mode 100644 index 0000000..6ec87f7 --- /dev/null +++ b/lib/src/rules/boundary_rule.dart @@ -0,0 +1,86 @@ +import '../boundary_engine.dart'; +import '../config_loader.dart'; +import '../import_graph.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; +import 'rule.dart'; + +enum BoundaryKind { layer, module } + +class BoundaryRule { + final BoundaryKind kind; + final List boundaries; + final RiskLevel severity; + final String projectPath; + + const BoundaryRule({ + required this.kind, + required this.boundaries, + required this.severity, + required this.projectPath, + }); + + List analyze( + List files, { + required List allFiles, + required SourceWorkspace workspace, + required ImportGraph importGraph, + }) { + final definitions = [ + for (final boundary in boundaries) + BoundaryDefinition( + name: boundary.name, + path: boundary.path, + allowedDeps: boundary.allowedDeps, + ), + ]; + final violations = DependencyBoundaryEngine.analyze( + sourceFiles: files, + allFiles: allFiles, + boundaries: definitions, + graph: importGraph, + workspace: workspace, + projectPath: projectPath, + ); + final label = kind == BoundaryKind.layer ? '层' : '模块'; + return [ + for (final violation in violations) + StaticIssue( + id: '${kind.name}_violation', + title: '$label间依赖违规', + file: violation.edge.source, + line: violation.edge.line, + level: severity, + domain: IssueDomain.architecture, + message: + '${violation.source.name} $label不可依赖 ${violation.target.name} $label', + detail: + '导入: ${violation.edge.uri}\n' + '源$label: ${violation.source.name} (${violation.source.path})\n' + '目标$label: ${violation.target.name} (${violation.target.path})', + suggestion: '调整依赖方向,或通过允许的边界抽象解耦', + metadata: { + 'kind': kind.name, + 'sourceBoundary': violation.source.name, + 'targetBoundary': violation.target.name, + 'imported': violation.edge.uri, + 'allowedDeps': violation.source.allowedDeps, + }, + ), + ]; + } + + static RuleDefinition definition(BoundaryKind kind) { + final layer = kind == BoundaryKind.layer; + return RuleDefinition( + id: '${kind.name}_violation', + name: layer ? '层间依赖违规' : '模块间依赖违规', + domain: IssueDomain.architecture, + defaultSeverity: RiskLevel.high, + purpose: layer ? '检测架构层之间的非法依赖' : '检测业务模块之间的非法依赖', + riskReason: '非法边界依赖会破坏隔离并扩大变更影响范围', + badExample: layer ? 'presentation 直接导入 data 实现' : 'mqtt 直接导入 ble 实现', + fixSuggestion: '调整 allowed_deps,或提取稳定接口到允许依赖的边界', + ); + } +} diff --git a/lib/src/rules/circular_dependency.dart b/lib/src/rules/circular_dependency.dart new file mode 100644 index 0000000..baebc0b --- /dev/null +++ b/lib/src/rules/circular_dependency.dart @@ -0,0 +1,127 @@ +import 'package:path/path.dart' as p; + +import '../import_graph.dart'; +import 'rule.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; + +enum _Color { white, gray, black } + +class CircularDependencyRule { + final bool enabled; + final RiskLevel severity; + final String? projectPath; + + const CircularDependencyRule({ + this.enabled = true, + this.severity = RiskLevel.medium, + this.projectPath, + }); + + List analyze( + List files, { + SourceWorkspace? workspace, + ImportGraph? importGraph, + }) { + if (!enabled || files.length < 2) return []; + + 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), + }; + return _findCycles(graph); + } + + List _findCycles(Map> graph) { + final issues = []; + final color = {}; + final parent = {}; + + _Color getColor(String node) => color[node] ?? _Color.white; + + void dfs(String node) { + color[node] = _Color.gray; + + for (final neighbor in graph[node] ?? {}) { + if (!graph.containsKey(neighbor)) continue; + + if (getColor(neighbor) == _Color.gray) { + final cycle = _reconstructCycle(node, neighbor, parent, graph); + if (cycle.length >= 2) { + issues.add(_buildCycleIssue(cycle)); + } + } else if (getColor(neighbor) == _Color.white) { + parent[neighbor] = node; + dfs(neighbor); + } + } + + color[node] = _Color.black; + } + + for (final node in graph.keys) { + if (getColor(node) == _Color.white) { + parent[node] = null; + dfs(node); + } + } + + return issues; + } + + List _reconstructCycle( + String start, + String end, + Map parent, + Map> graph, + ) { + final cycle = [start]; + var current = start; + + while (current != end) { + final prev = parent[current]; + if (prev == null || prev == current) break; + cycle.add(prev); + current = prev; + } + cycle.add(end); + + return cycle.reversed.toList(); + } + + StaticIssue _buildCycleIssue(List cycle) { + final cycleStr = cycle.map((f) => p.basename(f)).join(' → '); + + return StaticIssue( + id: 'circular_dependency', + title: '循环依赖', + file: cycle.first, + line: null, + level: severity, + domain: IssueDomain.architecture, + message: '检测到循环依赖: $cycleStr', + detail: '依赖链:\n${cycle.map((f) => ' $f').join('\n')}', + suggestion: '将循环中公共的依赖提取到 core/ 层,或使用依赖反转(接口抽象)打破循环', + metadata: {'cycle': cycle}, + ); + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'circular_dependency', + name: '循环依赖', + domain: IssueDomain.architecture, + defaultSeverity: RiskLevel.medium, + purpose: '检测文件级别的循环 import', + riskReason: '循环依赖导致编译耦合、无法单独测试和复用', + badExample: 'a.dart → b.dart → c.dart → a.dart', + fixSuggestion: '将公共依赖提取到 core 层,或使用接口反转打破循环', + ); +} diff --git a/lib/src/rules/generic_state_management.dart b/lib/src/rules/generic_state_management.dart new file mode 100644 index 0000000..b3c1e6f --- /dev/null +++ b/lib/src/rules/generic_state_management.dart @@ -0,0 +1,449 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import '../config_loader.dart'; +import 'rule.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; +import 'state_management_utils.dart'; + +class SideEffectInBuildRule { + final RuleConfig config; + + const SideEffectInBuildRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + final source = sources.source(file); + if (source == null) continue; + for (final root in buildRoots(source.unit)) { + final visitor = _SideEffectVisitor(); + root.body.accept(visitor); + final evidence = limitedEvidence(visitor.evidence); + if (evidence.isEmpty) continue; + issues.add( + StaticIssue( + id: 'side_effect_in_build', + title: 'build 中执行副作用', + file: file, + line: sourceLine(source, root.anchor), + level: config.severity, + domain: IssueDomain.performance, + message: '${root.label} 在渲染阶段执行了状态或资源副作用', + detail: 'build 必须保持幂等;框架可能在一次界面更新中重复调用它。', + suggestion: '将副作用移至生命周期、listener 或用户事件回调中', + evidence: evidence, + metadata: {'buildRoot': root.label}, + ), + ); + } + } + return issues; + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'side_effect_in_build', + name: 'build 中的副作用', + domain: IssueDomain.performance, + defaultSeverity: RiskLevel.high, + purpose: '检测 Widget build 与 Consumer builder 中直接执行的状态和资源副作用', + riskReason: 'build 可被重复调用,副作用会造成重复连接、通知或状态更新', + badExample: 'Widget build(...) { ref.read(x.notifier).refresh(); }', + fixSuggestion: '把副作用移到生命周期、listener 或事件回调', + ); +} + +class _SideEffectVisitor extends RecursiveAstVisitor { + final evidence = []; + + @override + void visitFunctionExpression(FunctionExpression node) { + // A nested closure is an event/listener/timer boundary. Consumer builder + // closures are scanned separately as build roots. + } + + @override + void visitInstanceCreationExpression(InstanceCreationExpression node) { + final type = simpleTypeName(node.constructorName.type.toSource()); + if (type == 'Timer') { + evidence.add(compactEvidence(node)); + } + super.visitInstanceCreationExpression(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + final name = node.methodName.name; + final target = node.target?.toSource() ?? ''; + const directEffects = { + 'notifyListeners', + 'emit', + 'setState', + 'connect', + 'disconnect', + 'startScan', + }; + final managerAdd = + name == 'add' && + (target.toLowerCase().contains('bloc') || + target.toLowerCase().contains('cubit') || + target.toLowerCase().contains('notifier') || + target.contains('context.read') || + target.contains('ref.read')); + final notifierCommand = + target.contains('ref.read') && + target.contains('.notifier') && + name != 'read'; + final timerPeriodic = target == 'Timer' && name == 'periodic'; + final timerCreation = target.isEmpty && name == 'Timer'; + if (directEffects.contains(name) || + managerAdd || + notifierCommand || + timerPeriodic || + timerCreation) { + evidence.add(compactEvidence(node)); + } + super.visitMethodInvocation(node); + } +} + +class StateManagerCreatedInBuildRule { + final RuleConfig config; + + const StateManagerCreatedInBuildRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + final source = sources.source(file); + if (source == null) continue; + for (final root in buildRoots(source.unit)) { + final visitor = _StateManagerCreationVisitor(); + root.body.accept(visitor); + for (final creation in visitor.creations) { + final type = creation.type; + issues.add( + StaticIssue( + id: 'state_manager_created_in_build', + title: 'build 中创建状态管理对象', + file: file, + line: sourceLine(source, creation.node), + level: config.severity, + domain: IssueDomain.performance, + message: '$type 在 ${root.label} 中被重复创建', + suggestion: '把对象交给 State 生命周期或 Provider/BlocProvider 的 create 管理', + evidence: [compactEvidence(creation.node)], + metadata: {'type': type, 'buildRoot': root.label}, + ), + ); + } + } + } + return issues; + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'state_manager_created_in_build', + name: 'build 中创建状态管理对象', + domain: IssueDomain.performance, + defaultSeverity: RiskLevel.high, + purpose: '检测 build 中创建 Controller、Bloc、Cubit、Notifier 等持有型对象', + riskReason: '重复构建会重建对象并丢失状态或泄漏资源', + badExample: 'Widget build(...) { final c = DeviceController(); }', + fixSuggestion: '提升到生命周期字段或框架所有权 create 回调', + ); +} + +class _StateManagerCreationVisitor extends RecursiveAstVisitor { + final creations = <({AstNode node, String type})>[]; + + static const flutterControllers = { + 'AnimationController', + 'TextEditingController', + 'ScrollController', + 'PageController', + 'TabController', + 'FocusNode', + }; + + @override + void visitFunctionExpression(FunctionExpression node) {} + + @override + void visitInstanceCreationExpression(InstanceCreationExpression node) { + final type = simpleTypeName(node.constructorName.type.toSource()); + _record(node, type); + super.visitInstanceCreationExpression(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.target == null && + node.methodName.name.startsWith(RegExp('[A-Z]'))) { + _record(node, simpleTypeName(node.methodName.name)); + } + super.visitMethodInvocation(node); + } + + void _record(AstNode node, String type) { + final owned = + stateOwnerSuffixes.any(type.endsWith) || + flutterControllers.contains(type); + if (owned) { + creations.add((node: node, type: type)); + } + } +} + +class MutableStateExposedRule { + final RuleConfig config; + + const MutableStateExposedRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + final source = sources.source(file); + if (source == null) continue; + for (final cls + in source.unit.declarations.whereType()) { + if (!isStateOwnerClass(cls)) continue; + issues.addAll(_inspectClass(file, source, cls)); + } + } + return issues; + } + + List _inspectClass( + String file, + SourceUnit source, + ClassDeclaration cls, + ) { + final findings = <({AstNode node, String key, String evidence})>[]; + final privateCollections = {}; + for (final field in cls.members.whereType()) { + if (field.isStatic) continue; + final type = typeName(field.fields.type); + for (final variable in field.fields.variables) { + final name = variable.name.lexeme; + if (!isPublicName(name) && isCollectionType(type)) { + privateCollections.add(name); + } + if (!isPublicName(name)) continue; + if (!field.fields.isFinal) { + findings.add(( + node: variable, + key: 'field:$name:non_final', + evidence: '$type $name is public and non-final', + )); + } else if (isCollectionType(type) && + !isUnmodifiableExpression(variable.initializer)) { + findings.add(( + node: variable, + key: 'field:$name:collection', + evidence: '$type $name exposes a mutable collection reference', + )); + } + } + } + + for (final method in cls.members.whereType()) { + final name = method.name.lexeme; + if (!method.isGetter || !isPublicName(name)) continue; + final returned = _returnedIdentifier(method.body); + if (returned != null && privateCollections.contains(returned)) { + findings.add(( + node: method, + key: 'getter:$name:$returned', + evidence: 'getter $name returns mutable $returned directly', + )); + } + } + + final mutationVisitor = _StateCollectionMutationVisitor(); + cls.accept(mutationVisitor); + for (final mutation in mutationVisitor.mutations) { + findings.add(( + node: mutation, + key: 'state_mutation:${mutation.offset}', + evidence: compactEvidence(mutation), + )); + } + + final seen = {}; + return [ + for (final finding in findings) + if (seen.add(finding.key)) + StaticIssue( + id: 'mutable_state_exposed', + title: '可变状态被公开', + file: file, + line: sourceLine(source, finding.node), + level: config.severity, + domain: IssueDomain.architecture, + message: '${cls.name.lexeme} 暴露或原地修改了可变状态', + suggestion: '公开不可变视图/副本,并用 copyWith 或新集合更新状态', + evidence: [finding.evidence], + metadata: {'className': cls.name.lexeme, 'rootCause': finding.key}, + ), + ]; + } + + String? _returnedIdentifier(FunctionBody body) { + if (body is ExpressionFunctionBody) { + final expression = body.expression; + if (expression is SimpleIdentifier) return expression.name; + } + if (body is BlockFunctionBody) { + for (final statement + in body.block.statements.whereType()) { + final expression = statement.expression; + if (expression is SimpleIdentifier) return expression.name; + } + } + return null; + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'mutable_state_exposed', + name: '公开可变状态', + domain: IssueDomain.architecture, + defaultSeverity: RiskLevel.medium, + purpose: '检测状态 owner 的公开可变字段、集合 getter 与原地 state 集合修改', + riskReason: '外部调用方可绕过状态通知并破坏单向数据流', + badExample: 'final List items = [];', + fixSuggestion: '返回不可变视图或副本,并替换整个状态值', + ); +} + +class _StateCollectionMutationVisitor extends RecursiveAstVisitor { + final mutations = []; + + @override + void visitMethodInvocation(MethodInvocation node) { + final target = node.target?.toSource() ?? ''; + const mutators = { + 'add', + 'addAll', + 'remove', + 'removeWhere', + 'clear', + 'sort', + }; + if ((target == 'state' || target.startsWith('state.')) && + mutators.contains(node.methodName.name)) { + mutations.add(node); + } + super.visitMethodInvocation(node); + } + + @override + void visitAssignmentExpression(AssignmentExpression node) { + final left = node.leftHandSide.toSource(); + if (left.startsWith('state[') || left.startsWith('state.')) { + mutations.add(node); + } + super.visitAssignmentExpression(node); + } +} + +class StateLayerUiDependencyRule { + final RuleConfig config; + + const StateLayerUiDependencyRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + final source = sources.source(file); + if (source == null) continue; + for (final cls + in source.unit.declarations.whereType()) { + if (!isStateOwnerClass(cls)) continue; + final visitor = _UiDependencyVisitor(); + cls.accept(visitor); + final evidence = limitedEvidence(visitor.evidence); + if (evidence.isEmpty) continue; + issues.add( + StaticIssue( + id: 'state_layer_ui_dependency', + title: '状态层依赖 UI API', + file: file, + line: sourceLine(source, cls), + level: config.severity, + domain: IssueDomain.architecture, + message: '${cls.name.lexeme} 直接依赖 Flutter UI 上下文或导航 API', + suggestion: '从状态层输出事件/数据,由 Widget 层执行导航、弹窗和主题访问', + evidence: evidence, + metadata: {'className': cls.name.lexeme}, + ), + ); + } + } + return issues; + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'state_layer_ui_dependency', + name: '状态层依赖 UI', + domain: IssueDomain.architecture, + defaultSeverity: RiskLevel.high, + purpose: '检测状态 owner 对 BuildContext、Widget、Navigator、Theme 等 UI API 的依赖', + riskReason: '状态逻辑与 UI 生命周期耦合,难以测试和复用', + badExample: + 'class DeviceController { void open(BuildContext context) { ... } }', + fixSuggestion: '输出状态或事件,并在 Widget 层处理 UI 行为', + ); +} + +class _UiDependencyVisitor extends RecursiveAstVisitor { + final evidence = []; + + static const uiTypes = {'BuildContext', 'Widget'}; + static const uiCalls = { + 'showDialog', + 'showModalBottomSheet', + 'maybePop', + 'push', + 'pushNamed', + 'of', + }; + + @override + void visitNamedType(NamedType node) { + final type = node.toSource().replaceAll('?', ''); + final simple = simpleTypeName(type); + final nestedTypeOnly = node.parent is TypeArgumentList; + if (!nestedTypeOnly && uiTypes.contains(simple)) { + evidence.add('type dependency: $type'); + } + super.visitNamedType(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + final target = node.target?.toSource() ?? ''; + final name = node.methodName.name; + final uiTarget = const { + 'Navigator', + 'ScaffoldMessenger', + 'MediaQuery', + 'Theme', + }.any((value) => target.contains(value)); + if ((uiTarget && uiCalls.contains(name)) || + name == 'showDialog' || + name == 'showModalBottomSheet') { + evidence.add(compactEvidence(node)); + } + super.visitMethodInvocation(node); + } +} diff --git a/lib/src/rules/iot_security.dart b/lib/src/rules/iot_security.dart new file mode 100644 index 0000000..36e6351 --- /dev/null +++ b/lib/src/rules/iot_security.dart @@ -0,0 +1,183 @@ +import '../config_loader.dart'; +import 'rule.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; + +final _secretPattern = RegExp( + r"""(password|token|secret|api[_]?key)\s*[:=]\s*["']""", + caseSensitive: false, +); +const _cleartextMqttPatterns = ['tcp://', 'port: 1883', 'port:1883']; +const _insecureBleKeywords = ['withoutBonding', 'withoutPairing']; +final _httpUrlPattern = RegExp(r"""['"]http://[^'"]+['"]"""); + +class IotSecurityRule { + final RuleConfig config; + + const IotSecurityRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!config.enabled) return []; + + final issues = []; + final sources = workspace ?? SourceWorkspace(); + + for (final file in files) { + final source = sources.source(file); + if (source == null) continue; + issues.addAll(_checkFile(file, source.content)); + } + + return issues; + } + + List _checkFile(String file, String content) { + final issues = []; + final lines = content.split('\n'); + + _checkHardcodedSecrets(file, lines, issues); + + if (config.boolOption('requireTls')) { + _checkCleartextMqtt(file, lines, issues); + _checkCleartextHttp(file, lines, issues); + } + + _checkInsecureBle(file, lines, issues); + + return issues; + } + + void _checkHardcodedSecrets( + String file, + List lines, + List issues, + ) { + for (var i = 0; i < lines.length; i++) { + if (_secretPattern.hasMatch(lines[i])) { + issues.add( + StaticIssue( + id: 'iot_security', + title: 'IoT 安全 — 硬编码凭证', + file: file, + line: i + 1, + level: config.severity, + domain: IssueDomain.architecture, + message: '检测到可疑的硬编码凭证', + detail: + '行 ${i + 1}: ${lines[i].trim()}\n' + '硬编码凭证可能导致安全泄露,应使用环境变量或安全存储', + suggestion: '使用环境变量或安全存储方案替代硬编码凭证', + metadata: {'securityCheck': 'hardcoded_secret', 'line': i + 1}, + ), + ); + } + } + } + + void _checkCleartextMqtt( + String file, + List lines, + List issues, + ) { + for (var i = 0; i < lines.length; i++) { + final lower = lines[i].toLowerCase(); + for (final pattern in _cleartextMqttPatterns) { + if (lower.contains(pattern)) { + issues.add( + StaticIssue( + id: 'iot_security', + title: 'IoT 安全 — 明文 MQTT 连接', + file: file, + line: i + 1, + level: config.severity, + domain: IssueDomain.architecture, + message: '检测到明文 MQTT 连接配置: "$pattern"', + detail: + '行 ${i + 1}: ${lines[i].trim()}\n' + '明文 MQTT 连接不安全,应使用 mqtts:// (TLS) 或端口 8883', + suggestion: '将 MQTT 连接升级为 mqtts:// 并使用端口 8883', + metadata: { + 'securityCheck': 'cleartext_mqtt', + 'pattern': pattern, + 'line': i + 1, + }, + ), + ); + } + } + } + } + + void _checkCleartextHttp( + String file, + List lines, + List issues, + ) { + for (var i = 0; i < lines.length; i++) { + for (final match in _httpUrlPattern.allMatches(lines[i])) { + final url = match.group(0) ?? ''; + if (url.contains('localhost') || url.contains('127.0.0.1')) continue; + + issues.add( + StaticIssue( + id: 'iot_security', + title: 'IoT 安全 — 明文 HTTP 连接', + file: file, + line: i + 1, + level: config.severity, + domain: IssueDomain.architecture, + message: '检测到明文 HTTP URL: $url', + detail: '行 ${i + 1}: ${lines[i].trim()}\n明文 HTTP 不安全,应使用 HTTPS', + suggestion: '将 HTTP 连接升级为 HTTPS', + metadata: {'securityCheck': 'cleartext_http', 'url': url}, + ), + ); + } + } + } + + void _checkInsecureBle( + String file, + List lines, + List issues, + ) { + for (var i = 0; i < lines.length; i++) { + for (final keyword in _insecureBleKeywords) { + if (lines[i].contains(keyword)) { + issues.add( + StaticIssue( + id: 'iot_security', + title: 'IoT 安全 — 不安全 BLE 配置', + file: file, + line: i + 1, + level: config.severity, + domain: IssueDomain.architecture, + message: '检测到不安全的 BLE 连接配置: "$keyword"', + detail: + '行 ${i + 1}: ${lines[i].trim()}\n' + 'BLE 连接应启用配对和加密 (bond / pair)', + suggestion: '启用 BLE 配对和加密配置', + metadata: { + 'securityCheck': 'insecure_ble', + 'keyword': keyword, + 'line': i + 1, + }, + ), + ); + } + } + } + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'iot_security', + name: 'IoT 安全风险', + domain: IssueDomain.architecture, + defaultSeverity: RiskLevel.high, + purpose: '检测硬编码凭据、明文 MQTT/HTTP、不安全的 BLE 配置', + riskReason: '硬编码凭据泄露导致设备被入侵;明文传输导致数据窃听', + badExample: 'password: "123456";tcp://broker:1883;BLE 使用 withoutBonding', + fixSuggestion: '使用环境变量或安全存储管理凭据;使用 TLS 加密通信', + defaultOptions: {'requireTls': true}, + ); +} diff --git a/lib/src/rules/lifecycle_resource.dart b/lib/src/rules/lifecycle_resource.dart new file mode 100644 index 0000000..b724acc --- /dev/null +++ b/lib/src/rules/lifecycle_resource.dart @@ -0,0 +1,128 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/source/line_info.dart'; + +import '../config_loader.dart'; +import 'rule.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; + +const _resourceTypes = { + 'StreamSubscription': 'cancel', + 'Timer': 'cancel', + 'AnimationController': 'dispose', + 'TextEditingController': 'dispose', + 'ScrollController': 'dispose', + 'FocusNode': 'dispose', + 'MqttClient': 'disconnect', + 'BluetoothDevice': 'disconnect', + 'StreamController': 'close', +}; + +class LifecycleResourceRule { + final RuleConfig config; + + const LifecycleResourceRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!config.enabled) return []; + + final issues = []; + final sources = workspace ?? SourceWorkspace(); + + for (final file in files) { + final source = sources.source(file); + if (source == null) continue; + issues.addAll(_checkFile(file, source.unit, source.lineInfo)); + } + + return issues; + } + + List _checkFile( + String file, + CompilationUnit unit, + LineInfo lineInfo, + ) { + final issues = []; + final classes = unit.declarations.whereType(); + + for (final cls in classes) { + final fieldDeclarations = cls.members + .whereType() + .where((f) => !f.isStatic) + .toList(); + + if (fieldDeclarations.isEmpty) continue; + + final disposeMethod = cls.members + .whereType() + .where((m) => m.name.lexeme == 'dispose') + .firstOrNull; + + for (final field in fieldDeclarations) { + final type = field.fields.type; + if (type == null) continue; + + final typeStr = type.toString(); + for (final resourceType in _resourceTypes.keys) { + if (typeStr == resourceType || typeStr.endsWith('<$resourceType>')) { + final fieldName = field.fields.variables.first.name.lexeme; + final expectedCall = _resourceTypes[resourceType]!; + + bool isDisposed = false; + if (disposeMethod != null) { + final disposeBody = disposeMethod.toString(); + isDisposed = disposeBody.contains('$fieldName.$expectedCall'); + } + + if (!isDisposed) { + final line = lineNumberForOffset( + lineInfo, + field.fields.variables.first.name.offset, + ); + + issues.add( + StaticIssue( + id: 'lifecycle_resource_not_disposed', + title: '资源未释放', + file: file, + line: line, + level: config.severity, + domain: IssueDomain.performance, + message: + '$resourceType 类型字段 "$fieldName" 在 "${cls.name.lexeme}" 中未在 dispose() 中释放', + detail: + '字段: $fieldName ($resourceType)\n' + '类: ${cls.name.lexeme}\n' + '预期释放调用: $fieldName.$expectedCall()', + suggestion: + '在 dispose() 方法中添加 "$fieldName.$expectedCall()" 调用', + metadata: { + 'className': cls.name.lexeme, + 'resourceType': resourceType, + 'fieldName': fieldName, + 'expectedDisposeCall': expectedCall, + }, + ), + ); + } + } + } + } + } + + return issues; + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'lifecycle_resource_not_disposed', + name: '资源未释放', + domain: IssueDomain.performance, + defaultSeverity: RiskLevel.medium, + purpose: + '检测 StreamSubscription/Timer/AnimationController 等资源在 dispose 中未释放', + riskReason: '未释放的资源导致内存泄漏和性能下降', + badExample: '在 State 中创建 StreamSubscription 但 dispose() 中未调用 cancel()', + fixSuggestion: '在 dispose() 中对每个资源调用对应的 cancel()/dispose()/close()', + ); +} diff --git a/lib/src/rules/provider_state_management.dart b/lib/src/rules/provider_state_management.dart new file mode 100644 index 0000000..efd4b63 --- /dev/null +++ b/lib/src/rules/provider_state_management.dart @@ -0,0 +1,326 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import '../config_loader.dart'; +import 'rule.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; +import 'state_management_utils.dart'; + +class ProviderValueLifecycleMisuseRule { + final RuleConfig config; + + const ProviderValueLifecycleMisuseRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + final source = sources.source(file); + if (source == null || + !frameworkAllowed(source.unit, StateManagementFramework.provider)) { + continue; + } + final visitor = _ProviderOwnershipVisitor(); + source.unit.accept(visitor); + for (final finding in visitor.findings) { + issues.add( + StaticIssue( + id: 'provider_value_lifecycle_misuse', + title: 'Provider 所有权模式误用', + file: file, + line: sourceLine(source, finding.node), + level: config.severity, + domain: IssueDomain.performance, + message: finding.message, + suggestion: finding.kind == 'value_creates' + ? '新对象使用 create 构造;.value 只传入已有实例' + : '已有实例使用 .value,避免 Provider 错误释放或忽略所有权', + framework: StateManagementFramework.provider, + evidence: [compactEvidence(finding.node)], + metadata: {'ownershipError': finding.kind}, + ), + ); + } + } + return issues; + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'provider_value_lifecycle_misuse', + name: 'Provider 所有权误用', + domain: IssueDomain.performance, + defaultSeverity: RiskLevel.medium, + purpose: '检测 .value 创建新对象和 create 返回已有对象的反向所有权用法', + riskReason: '错误的 Provider 构造方式会导致对象未释放、提前释放或状态复用错误', + badExample: 'ChangeNotifierProvider.value(value: DeviceController())', + fixSuggestion: '新对象用 create,已有对象用 .value', + framework: 'provider', + ); +} + +class _ProviderOwnershipFinding { + final AstNode node; + final String kind; + final String message; + + const _ProviderOwnershipFinding(this.node, this.kind, this.message); +} + +class _ProviderOwnershipVisitor extends RecursiveAstVisitor { + final findings = <_ProviderOwnershipFinding>[]; + + @override + void visitInstanceCreationExpression(InstanceCreationExpression node) { + _inspectCall( + node: node, + providerType: simpleTypeName(node.constructorName.type.toSource()), + constructor: node.constructorName.name?.name, + arguments: node.argumentList, + ); + super.visitInstanceCreationExpression(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + final target = node.target?.toSource(); + final targetType = target == null ? null : simpleTypeName(target); + final targetIsProvider = targetType?.endsWith('Provider') ?? false; + final providerType = targetIsProvider + ? targetType! + : simpleTypeName(node.methodName.name); + final constructor = targetIsProvider ? node.methodName.name : null; + _inspectCall( + node: node, + providerType: providerType, + constructor: constructor, + arguments: node.argumentList, + ); + super.visitMethodInvocation(node); + } + + void _inspectCall({ + required AstNode node, + required String providerType, + required String? constructor, + required ArgumentList arguments, + }) { + if (!providerType.endsWith('Provider')) return; + if (constructor == 'value') { + final value = _argument(arguments, 'value'); + final createdType = _createdType(value); + final isConst = value is InstanceCreationExpression && value.isConst; + if (createdType != null && !isConst && !_isImmutableType(createdType)) { + findings.add( + _ProviderOwnershipFinding( + node, + 'value_creates', + '$providerType.value 创建了新对象,Provider 不会按 create 所有权管理它', + ), + ); + } + } + + final create = + _argument(arguments, 'create') ?? + (constructor == 'create' ? _firstPositional(arguments) : null); + if (create is FunctionExpression) { + final returned = _returnedExpression(create.body); + if (returned != null && _isExistingReference(returned)) { + findings.add( + _ProviderOwnershipFinding( + node, + 'create_reuses', + '$providerType.create 返回已有实例,可能错误取得其释放所有权', + ), + ); + } + } + } + + static String? _createdType(Expression? expression) { + if (expression is InstanceCreationExpression) { + return simpleTypeName(expression.constructorName.type.toSource()); + } + if (expression is MethodInvocation && + expression.target == null && + expression.methodName.name.startsWith(RegExp('[A-Z]'))) { + return simpleTypeName(expression.methodName.name); + } + return null; + } + + bool _isImmutableType(String type) { + return type.endsWith('Value') || + type.endsWith('Data') || + type.endsWith('Dto') || + type == 'String' || + type == 'int' || + type == 'double' || + type == 'bool'; + } + + static Expression? _argument(ArgumentList list, String name) { + for (final argument in list.arguments) { + if (argument is NamedExpression && argument.name.label.name == name) { + return argument.expression; + } + } + return null; + } + + static Expression? _firstPositional(ArgumentList list) { + for (final argument in list.arguments) { + if (argument is! NamedExpression) return argument; + } + return null; + } + + static Expression? _returnedExpression(FunctionBody body) { + if (body is ExpressionFunctionBody) return body.expression; + if (body is BlockFunctionBody) { + for (final statement in body.block.statements) { + if (statement is ReturnStatement) return statement.expression; + } + } + return null; + } + + static bool _isExistingReference(Expression expression) => + expression is SimpleIdentifier || + expression is PrefixedIdentifier || + expression is PropertyAccess; +} + +class NotifyListenersInLoopRule { + final RuleConfig config; + + const NotifyListenersInLoopRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + final source = sources.source(file); + if (source == null || + !frameworkAllowed(source.unit, StateManagementFramework.provider)) { + continue; + } + final visitor = _NotifyLoopVisitor(); + source.unit.accept(visitor); + for (final finding in visitor.findings) { + issues.add( + StaticIssue( + id: 'notify_listeners_in_loop', + title: '循环中调用 notifyListeners', + file: file, + line: sourceLine(source, finding.root), + level: config.severity, + domain: IssueDomain.performance, + message: '循环每次迭代都可能触发监听者重建', + suggestion: '在循环内完成批量修改后,只调用一次 notifyListeners()', + framework: StateManagementFramework.provider, + evidence: limitedEvidence( + finding.notifications.map(compactEvidence), + ), + ), + ); + } + } + return issues; + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'notify_listeners_in_loop', + name: '循环中通知监听者', + domain: IssueDomain.performance, + defaultSeverity: RiskLevel.medium, + purpose: '检测 for/while/do-while/forEach 中的 notifyListeners', + riskReason: '循环内通知会触发重复重建并暴露中间状态', + badExample: + 'for (final item in items) { update(item); notifyListeners(); }', + fixSuggestion: '循环结束后统一通知一次', + framework: 'provider', + ); +} + +class _NotifyLoopFinding { + final AstNode root; + final List notifications; + + const _NotifyLoopFinding(this.root, this.notifications); +} + +class _NotifyLoopVisitor extends RecursiveAstVisitor { + final findings = <_NotifyLoopFinding>[]; + + @override + void visitForStatement(ForStatement node) { + final parts = node.forLoopParts; + final provablyShort = _provablyShortFor(parts); + if (!provablyShort) _inspect(node, node.body); + super.visitForStatement(node); + } + + @override + void visitWhileStatement(WhileStatement node) { + _inspect(node, node.body); + super.visitWhileStatement(node); + } + + @override + void visitDoStatement(DoStatement node) { + _inspect(node, node.body); + super.visitDoStatement(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.methodName.name == 'forEach') { + final callback = node.argumentList.arguments + .whereType() + .firstOrNull; + final target = node.target; + final provablyShort = + target is ListLiteral && target.elements.length <= 1 || + target is SetOrMapLiteral && target.elements.length <= 1; + if (callback != null && !provablyShort) _inspect(node, callback.body); + } + super.visitMethodInvocation(node); + } + + void _inspect(AstNode root, AstNode body) { + final visitor = _NotifyInvocationVisitor(); + body.accept(visitor); + if (visitor.nodes.isNotEmpty) { + findings.add(_NotifyLoopFinding(root, visitor.nodes)); + } + } + + bool _provablyShortFor(ForLoopParts parts) { + if (parts is ForEachParts) { + final iterable = parts.iterable; + return iterable is ListLiteral && iterable.elements.length <= 1 || + iterable is SetOrMapLiteral && iterable.elements.length <= 1; + } + final source = parts.toSource().replaceAll(RegExp(r'\s+'), ' '); + return RegExp(r'= 0; [A-Za-z_$][\w$]* < 1;').hasMatch(source) || + RegExp(r'= 0; [A-Za-z_$][\w$]* <= 0;').hasMatch(source); + } +} + +class _NotifyInvocationVisitor extends RecursiveAstVisitor { + final nodes = []; + + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.methodName.name == 'notifyListeners') nodes.add(node); + super.visitMethodInvocation(node); + } +} + +extension _FirstOrNull on Iterable { + T? get firstOrNull => isEmpty ? null : first; +} diff --git a/lib/src/rules/registry.dart b/lib/src/rules/registry.dart new file mode 100644 index 0000000..024e51f --- /dev/null +++ b/lib/src/rules/registry.dart @@ -0,0 +1,197 @@ +import '../import_graph.dart'; +import '../scan_context.dart'; +import '../static_issue.dart'; +import 'ble_scanning.dart'; +import 'bloc_state_management.dart'; +import 'boundary_rule.dart'; +import 'circular_dependency.dart'; +import 'generic_state_management.dart'; +import 'iot_security.dart'; +import 'lifecycle_resource.dart'; +import 'provider_state_management.dart'; +import 'riverpod_state_management.dart'; +import 'rule.dart'; +import 'state_dependency_cycle.dart'; + +/// The single source of truth for rule metadata, defaults, and execution. +class RuleRegistry { + static final List registrations = List.unmodifiable([ + RuleRegistration( + definition: LifecycleResourceRule.describe(), + execute: (context, config, _) => LifecycleResourceRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + definition: BoundaryRule.definition(BoundaryKind.layer), + execute: (context, config, graph) { + if (!config.enabled || context.config.architecture.layers.isEmpty) { + return []; + } + return BoundaryRule( + kind: BoundaryKind.layer, + boundaries: context.config.architecture.layers, + severity: config.severity, + projectPath: context.projectPath, + ).analyze( + context.targetFiles, + allFiles: context.allFiles, + workspace: context.sources, + importGraph: graph!, + ); + }, + ), + RuleRegistration( + definition: BoundaryRule.definition(BoundaryKind.module), + execute: (context, config, graph) { + if (!config.enabled || context.config.architecture.modules.isEmpty) { + return []; + } + return BoundaryRule( + kind: BoundaryKind.module, + boundaries: context.config.architecture.modules, + severity: config.severity, + projectPath: context.projectPath, + ).analyze( + context.targetFiles, + allFiles: context.allFiles, + workspace: context.sources, + importGraph: graph!, + ); + }, + ), + RuleRegistration( + definition: CircularDependencyRule.describe(), + execute: (context, config, graph) => + CircularDependencyRule( + enabled: + config.enabled && + !context.isChanged && + context.config.architecture.detectCycles, + severity: config.severity, + projectPath: context.projectPath, + ).analyze( + context.targetFiles, + workspace: context.sources, + importGraph: graph, + ), + ), + RuleRegistration( + definition: BleScanningRule.describe(), + execute: (context, config, _) => BleScanningRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + definition: IotSecurityRule.describe(), + execute: (context, config, _) => IotSecurityRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + definition: SideEffectInBuildRule.describe(), + execute: (context, config, _) => SideEffectInBuildRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + definition: StateManagerCreatedInBuildRule.describe(), + execute: (context, config, _) => StateManagerCreatedInBuildRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + definition: MutableStateExposedRule.describe(), + execute: (context, config, _) => MutableStateExposedRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + definition: StateLayerUiDependencyRule.describe(), + execute: (context, config, _) => StateLayerUiDependencyRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + definition: StateDependencyCycleRule.describe(), + execute: (context, config, _) => + StateDependencyCycleRule( + config, + projectPath: context.projectPath, + ).analyze( + context.allFiles, + targetFiles: context.targetFiles, + changedOnly: context.isChanged, + workspace: context.sources, + ), + ), + RuleRegistration( + definition: RiverpodReadUsedForRenderRule.describe(), + execute: (context, config, _) => RiverpodReadUsedForRenderRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + definition: RiverpodWatchInCallbackRule.describe(), + execute: (context, config, _) => RiverpodWatchInCallbackRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + definition: BlocEquatablePropsIncompleteRule.describe(), + execute: (context, config, _) => BlocEquatablePropsIncompleteRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + definition: ProviderValueLifecycleMisuseRule.describe(), + execute: (context, config, _) => ProviderValueLifecycleMisuseRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + RuleRegistration( + definition: NotifyListenersInLoopRule.describe(), + execute: (context, config, _) => NotifyListenersInLoopRule( + config, + ).analyze(context.targetFiles, workspace: context.sources), + ), + ]); + + static List all() => [ + for (final registration in registrations) registration.definition, + ]; + + static RuleDefinition? find(String id) { + for (final definition in all()) { + if (definition.id == id) return definition; + } + return null; + } + + static List analyze(ScanContext context) { + final needsImportGraph = + context.config.architecture.layers.isNotEmpty || + context.config.architecture.modules.isNotEmpty || + (!context.isChanged && context.config.architecture.detectCycles); + final graph = needsImportGraph + ? ImportGraph.build( + files: context.allFiles, + sourceFiles: context.targetFiles, + workspace: context.sources, + projectPath: context.projectPath, + ) + : null; + + final issues = []; + for (final registration in registrations) { + final definition = registration.definition; + final config = context.config.rule( + definition.id, + defaultSeverity: definition.defaultSeverity, + defaultOptions: definition.defaultOptions, + ); + issues.addAll(registration.execute(context, config, graph)); + } + return issues; + } +} diff --git a/lib/src/rules/riverpod_state_management.dart b/lib/src/rules/riverpod_state_management.dart new file mode 100644 index 0000000..8e0a1e7 --- /dev/null +++ b/lib/src/rules/riverpod_state_management.dart @@ -0,0 +1,276 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import '../config_loader.dart'; +import 'rule.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; +import 'state_management_utils.dart'; + +class RiverpodReadUsedForRenderRule { + final RuleConfig config; + + const RiverpodReadUsedForRenderRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + final source = sources.source(file); + if (source == null || + !frameworkAllowed(source.unit, StateManagementFramework.riverpod)) { + continue; + } + for (final root in buildRoots(source.unit)) { + final reads = _RiverpodReadCollector(root.body)..collect(); + for (final finding in reads.findings) { + issues.add( + StaticIssue( + id: 'riverpod_read_used_for_render', + title: '使用 ref.read 驱动渲染', + file: file, + line: sourceLine(source, finding.read), + level: config.severity, + domain: IssueDomain.performance, + message: 'ref.read 的结果进入了渲染路径,后续状态变化不会触发重建', + suggestion: '在渲染路径使用 ref.watch;命令调用继续使用 ref.read', + framework: StateManagementFramework.riverpod, + evidence: limitedEvidence(finding.sinks), + metadata: {'provider': finding.provider}, + ), + ); + } + } + } + return issues; + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'riverpod_read_used_for_render', + name: 'ref.read 驱动渲染', + domain: IssueDomain.performance, + defaultSeverity: RiskLevel.medium, + purpose: '检测 build 中使用 ref.read 的值构造 Widget 或控制条件渲染', + riskReason: 'read 不订阅 Provider,状态变化后界面可能保持陈旧', + badExample: 'Text(ref.read(deviceProvider).name)', + fixSuggestion: '渲染数据改用 ref.watch', + framework: 'riverpod', + ); +} + +class _ReadFinding { + final MethodInvocation read; + final String provider; + final List sinks; + + const _ReadFinding(this.read, this.provider, this.sinks); +} + +class _RiverpodReadCollector { + final AstNode root; + final findings = <_ReadFinding>[]; + + _RiverpodReadCollector(this.root); + + void collect() { + final visitor = _ReadInvocationVisitor(root); + root.accept(visitor); + for (final read in visitor.reads) { + final provider = read.argumentList.arguments.isEmpty + ? '' + : read.argumentList.arguments.first.toSource(); + final sinks = []; + if (_isRenderSink(read, root)) { + sinks.add( + 'render sink: ${compactEvidence(_nearestRenderNode(read, root))}', + ); + } + final variable = _assignedVariable(read); + if (variable != null) { + final usages = _IdentifierUsageVisitor(variable, root)..scan(); + for (final usage in usages.renderUsages) { + sinks.add( + 'render use of $variable: ${compactEvidence(_nearestRenderNode(usage, root))}', + ); + } + } + if (sinks.isNotEmpty) findings.add(_ReadFinding(read, provider, sinks)); + } + } + + static String? _assignedVariable(MethodInvocation read) { + AstNode? current = read.parent; + while (current != null) { + if (current is VariableDeclaration && current.initializer != null) { + return current.name.lexeme; + } + if (current is Statement || current is FunctionBody) break; + current = current.parent; + } + return null; + } + + static bool _isRenderSink(AstNode node, AstNode root) { + AstNode? current = node; + while (current != null && current != root) { + if (current is FunctionExpression) return false; + if (current is ReturnStatement || + current is IfStatement || + current is ConditionalExpression || + current is IfElement || + current is ForElement) { + return true; + } + current = current.parent; + } + return false; + } + + static AstNode _nearestRenderNode(AstNode node, AstNode root) { + AstNode current = node; + while (current.parent != null && current.parent != root) { + final parent = current.parent!; + if (parent is ReturnStatement || + parent is IfStatement || + parent is ConditionalExpression || + parent is IfElement || + parent is ForElement) { + return parent; + } + current = parent; + } + return current; + } +} + +class _ReadInvocationVisitor extends RecursiveAstVisitor { + final AstNode root; + final reads = []; + + _ReadInvocationVisitor(this.root); + + @override + void visitFunctionExpression(FunctionExpression node) { + if (node == root) super.visitFunctionExpression(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.methodName.name == 'read' && node.target?.toSource() == 'ref') { + reads.add(node); + } + super.visitMethodInvocation(node); + } +} + +class _IdentifierUsageVisitor extends RecursiveAstVisitor { + final String name; + final AstNode root; + final renderUsages = []; + + _IdentifierUsageVisitor(this.name, this.root); + + void scan() => root.accept(this); + + @override + void visitFunctionExpression(FunctionExpression node) {} + + @override + void visitSimpleIdentifier(SimpleIdentifier node) { + if (node.name == name && + node.parent is! VariableDeclaration && + _RiverpodReadCollector._isRenderSink(node, root)) { + renderUsages.add(node); + } + super.visitSimpleIdentifier(node); + } +} + +class RiverpodWatchInCallbackRule { + final RuleConfig config; + + const RiverpodWatchInCallbackRule(this.config); + + List analyze(List files, {SourceWorkspace? workspace}) { + if (!stateRuleEnabled(config)) return []; + final sources = workspace ?? SourceWorkspace(); + final issues = []; + for (final file in files) { + final source = sources.source(file); + if (source == null || + !frameworkAllowed(source.unit, StateManagementFramework.riverpod)) { + continue; + } + final visitor = _WatchCallbackVisitor(); + source.unit.accept(visitor); + for (final callback in visitor.callbacks) { + final watches = callback.watches; + if (watches.isEmpty) continue; + issues.add( + StaticIssue( + id: 'riverpod_watch_in_callback', + title: '回调中调用 ref.watch', + file: file, + line: sourceLine(source, callback.function), + level: config.severity, + domain: IssueDomain.performance, + message: '事件或异步回调中调用 ref.watch,订阅不会形成有效渲染依赖', + suggestion: '回调中使用 ref.read;只在 build/provider 声明中使用 ref.watch', + framework: StateManagementFramework.riverpod, + evidence: limitedEvidence(watches.map(compactEvidence)), + ), + ); + } + } + return issues; + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'riverpod_watch_in_callback', + name: '回调中使用 ref.watch', + domain: IssueDomain.performance, + defaultSeverity: RiskLevel.medium, + purpose: '检测事件、listener、timer 和异步回调中的 ref.watch', + riskReason: '回调不是响应式构建范围,watch 的订阅语义无效或误导', + badExample: 'onPressed: () => ref.watch(deviceProvider)', + fixSuggestion: '回调改用 ref.read', + framework: 'riverpod', + ); +} + +class _WatchCallbackFinding { + final FunctionExpression function; + final List watches; + + const _WatchCallbackFinding(this.function, this.watches); +} + +class _WatchCallbackVisitor extends RecursiveAstVisitor { + final callbacks = <_WatchCallbackFinding>[]; + + @override + void visitFunctionExpression(FunctionExpression node) { + if (isCallbackFunction(node)) { + final watches = _WatchInvocationVisitor(); + node.body.accept(watches); + if (watches.nodes.isNotEmpty) { + callbacks.add(_WatchCallbackFinding(node, watches.nodes)); + } + return; + } + super.visitFunctionExpression(node); + } +} + +class _WatchInvocationVisitor extends RecursiveAstVisitor { + final nodes = []; + + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.methodName.name == 'watch' && node.target?.toSource() == 'ref') { + nodes.add(node); + } + super.visitMethodInvocation(node); + } +} diff --git a/lib/src/rules/rule.dart b/lib/src/rules/rule.dart new file mode 100644 index 0000000..0cdb844 --- /dev/null +++ b/lib/src/rules/rule.dart @@ -0,0 +1,64 @@ +import '../config_loader.dart'; +import '../import_graph.dart'; +import '../scan_context.dart'; +import '../static_issue.dart'; + +typedef RuleExecutor = + List Function( + ScanContext context, + RuleConfig config, + ImportGraph? importGraph, + ); + +class RuleDefinition { + final String id; + final String name; + final IssueDomain domain; + final RiskLevel defaultSeverity; + final String purpose; + final String riskReason; + final String badExample; + final String fixSuggestion; + final Map defaultOptions; + final String framework; + + const RuleDefinition({ + required this.id, + required this.name, + required this.domain, + required this.defaultSeverity, + required this.purpose, + required this.riskReason, + required this.badExample, + required this.fixSuggestion, + this.defaultOptions = const {}, + this.framework = 'generic', + }); + + List get configKeys => [ + 'enabled', + 'severity', + ...defaultOptions.keys, + ]; + + Map toJson() => { + 'id': id, + 'name': name, + 'domain': domain.name, + 'severity': defaultSeverity.name, + 'purpose': purpose, + 'riskReason': riskReason, + 'badExample': badExample, + 'fixSuggestion': fixSuggestion, + 'configKeys': configKeys, + 'options': defaultOptions, + 'framework': framework, + }; +} + +class RuleRegistration { + final RuleDefinition definition; + final RuleExecutor execute; + + const RuleRegistration({required this.definition, required this.execute}); +} diff --git a/lib/src/rules/state_dependency_cycle.dart b/lib/src/rules/state_dependency_cycle.dart new file mode 100644 index 0000000..e9dc54e --- /dev/null +++ b/lib/src/rules/state_dependency_cycle.dart @@ -0,0 +1,400 @@ +import 'dart:collection'; + +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import '../config_loader.dart'; +import '../import_utils.dart'; +import '../path_utils.dart'; +import 'rule.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; +import 'state_management_utils.dart'; + +class StateDependencyCycleRule { + final RuleConfig config; + final String projectPath; + + const StateDependencyCycleRule(this.config, {required this.projectPath}); + + List analyze( + List allFiles, { + List? targetFiles, + bool changedOnly = false, + SourceWorkspace? workspace, + }) { + if (!stateRuleEnabled(config)) return []; + final sources = workspace ?? SourceWorkspace(); + final normalizedFiles = allFiles.map(normalizePath).toSet(); + final units = <({String file, SourceUnit source})>[]; + final nodes = {}; + final names = >{}; + + void register(_StateGraphNode node) { + nodes[node.id] = node; + names.putIfAbsent(node.name, () => []).add(node.id); + } + + for (final file in normalizedFiles) { + final source = sources.source(file); + if (source == null) continue; + units.add((file: source.path, source: source)); + for (final cls + in source.unit.declarations.whereType()) { + register( + _StateGraphNode( + id: _nodeId(source.path, 'class', cls.name.lexeme), + name: cls.name.lexeme, + file: source.path, + anchor: cls, + isState: isStateOwnerClass(cls), + ), + ); + } + for (final declaration + in source.unit.declarations + .whereType()) { + for (final variable in declaration.variables.variables) { + final initializer = variable.initializer; + if (initializer != null && _isProviderDeclaration(initializer)) { + register( + _StateGraphNode( + id: _nodeId(source.path, 'provider', variable.name.lexeme), + name: variable.name.lexeme, + file: source.path, + anchor: variable, + isState: true, + ), + ); + } + } + } + } + + for (final candidates in names.values) { + candidates.sort(); + } + final graph = >{ + for (final id in nodes.keys) id: {}, + }; + final knownNames = names.keys.toSet(); + + for (final item in units) { + final importedFiles = _resolvedImports( + item.source.unit, + item.file, + normalizedFiles, + ); + for (final cls + in item.source.unit.declarations.whereType()) { + final ownerId = _nodeId(item.file, 'class', cls.name.lexeme); + if (!nodes.containsKey(ownerId)) continue; + final collector = _DependencyCollector(knownNames); + cls.accept(collector); + _addResolvedEdges( + graph: graph, + ownerId: ownerId, + dependencyNames: collector.dependencies, + sourceFile: item.file, + importedFiles: importedFiles, + nodes: nodes, + names: names, + ); + } + for (final declaration + in item.source.unit.declarations + .whereType()) { + for (final variable in declaration.variables.variables) { + final initializer = variable.initializer; + final ownerId = _nodeId(item.file, 'provider', variable.name.lexeme); + if (initializer == null || !nodes.containsKey(ownerId)) continue; + final collector = _DependencyCollector(knownNames); + initializer.accept(collector); + _addResolvedEdges( + graph: graph, + ownerId: ownerId, + dependencyNames: collector.dependencies, + sourceFile: item.file, + importedFiles: importedFiles, + nodes: nodes, + names: names, + ); + } + } + } + + final targetSet = (targetFiles ?? allFiles).map(normalizePath).toSet(); + final issues = []; + for (final component in _stronglyConnectedComponents(graph)) { + if (component.length < 2 || !component.any((id) => nodes[id]!.isState)) { + continue; + } + final changedNodes = + component.where((id) => targetSet.contains(nodes[id]!.file)).toList() + ..sort(); + if (changedOnly && changedNodes.isEmpty) continue; + final cycleIds = _shortestCycle(component, graph); + if (cycleIds.isEmpty) continue; + final anchorId = changedOnly ? changedNodes.first : cycleIds.first; + final anchor = nodes[anchorId]!; + final cycle = [ + for (final id in cycleIds) _displayName(nodes[id]!, names), + ]; + final componentNames = [ + for (final id in component) _displayName(nodes[id]!, names), + ]..sort(); + final source = sources.source(anchor.file); + if (source == null) continue; + issues.add( + StaticIssue( + id: 'state_dependency_cycle', + title: '状态依赖环', + file: anchor.file, + line: sourceLine(source, anchor.anchor), + level: config.severity, + domain: IssueDomain.architecture, + message: '状态依赖形成环: ${cycle.join(' -> ')}', + suggestion: '提取单向协调器或接口,移除环中的一条状态依赖边', + evidence: [cycle.join(' -> ')], + metadata: {'cycle': cycle, 'nodes': componentNames}, + ), + ); + } + return issues; + } + + static String _nodeId(String file, String kind, String name) => + '${normalizePath(file)}::$kind::$name'; + + Set _resolvedImports( + CompilationUnit unit, + String file, + Set allFiles, + ) { + final result = {}; + for (final directive in unit.directives.whereType()) { + final uri = directive.uri.stringValue; + if (uri == null) continue; + final resolved = resolveImport( + file, + uri, + allFiles, + projectPath: projectPath, + ); + if (resolved != null) result.add(normalizePath(resolved)); + } + return result; + } + + static void _addResolvedEdges({ + required Map> graph, + required String ownerId, + required Set dependencyNames, + required String sourceFile, + required Set importedFiles, + required Map nodes, + required Map> names, + }) { + for (final name in dependencyNames) { + final dependencyId = _resolveNode( + name: name, + sourceFile: sourceFile, + importedFiles: importedFiles, + nodes: nodes, + names: names, + ); + if (dependencyId != null && dependencyId != ownerId) { + graph[ownerId]!.add(dependencyId); + } + } + } + + static String? _resolveNode({ + required String name, + required String sourceFile, + required Set importedFiles, + required Map nodes, + required Map> names, + }) { + final candidates = names[name] ?? const []; + if (candidates.isEmpty) return null; + final normalizedSource = normalizePath(sourceFile); + final local = candidates + .where((id) => nodes[id]!.file == normalizedSource) + .toList(); + if (local.length == 1) return local.single; + final imported = candidates + .where((id) => importedFiles.contains(nodes[id]!.file)) + .toList(); + if (imported.length == 1) return imported.single; + return candidates.length == 1 ? candidates.single : null; + } + + String _displayName(_StateGraphNode node, Map> names) { + if ((names[node.name]?.length ?? 0) <= 1) return node.name; + final path = projectRelativePath( + node.file, + projectPath, + ).replaceAll('\\', '/'); + return '$path::${node.name}'; + } + + static bool _isProviderDeclaration(Expression initializer) { + final source = initializer.toSource(); + return RegExp(r'(^|\.)[A-Za-z]*Provider(?:<[^>]+>)?\s*\(').hasMatch(source); + } + + static List> _stronglyConnectedComponents( + Map> graph, + ) { + var index = 0; + final indices = {}; + final lowlink = {}; + final stack = []; + final onStack = {}; + final result = >[]; + + void connect(String node) { + indices[node] = index; + lowlink[node] = index; + index++; + stack.add(node); + onStack.add(node); + final neighbors = (graph[node] ?? const {}).toList()..sort(); + for (final next in neighbors) { + if (!indices.containsKey(next)) { + connect(next); + lowlink[node] = lowlink[node]! < lowlink[next]! + ? lowlink[node]! + : lowlink[next]!; + } else if (onStack.contains(next)) { + lowlink[node] = lowlink[node]! < indices[next]! + ? lowlink[node]! + : indices[next]!; + } + } + if (lowlink[node] != indices[node]) return; + final component = {}; + while (stack.isNotEmpty) { + final current = stack.removeLast(); + onStack.remove(current); + component.add(current); + if (current == node) break; + } + result.add(component); + } + + final ids = graph.keys.toList()..sort(); + for (final id in ids) { + if (!indices.containsKey(id)) connect(id); + } + return result; + } + + static List _shortestCycle( + Set component, + Map> graph, + ) { + List? best; + final starts = component.toList()..sort(); + for (final start in starts) { + final queue = Queue>()..add([start]); + final shortestDepth = {start: 0}; + while (queue.isNotEmpty) { + final path = queue.removeFirst(); + final current = path.last; + final neighbors = + (graph[current] ?? const {}) + .where(component.contains) + .toList() + ..sort(); + for (final next in neighbors) { + if (next == start && path.length > 1) { + final candidate = [...path, start]; + if (best == null || + candidate.length < best.length || + candidate.length == best.length && + candidate.join('\u0000').compareTo(best.join('\u0000')) < + 0) { + best = candidate; + } + continue; + } + if (path.contains(next)) continue; + final depth = path.length; + if ((shortestDepth[next] ?? 1 << 30) < depth) continue; + shortestDepth[next] = depth; + queue.add([...path, next]); + } + } + } + return best ?? const []; + } + + static RuleDefinition describe() => const RuleDefinition( + id: 'state_dependency_cycle', + name: '状态依赖环', + domain: IssueDomain.architecture, + defaultSeverity: RiskLevel.high, + purpose: '检测 Provider、状态 owner 与其项目内依赖形成的强连通环', + riskReason: '依赖环导致初始化顺序不确定、递归更新和难以隔离的测试', + badExample: 'AuthController -> SessionProvider -> AuthController', + fixSuggestion: '提取协调器或接口,使依赖保持单向', + ); +} + +class _StateGraphNode { + final String id; + final String name; + final String file; + final AstNode anchor; + final bool isState; + + const _StateGraphNode({ + required this.id, + required this.name, + required this.file, + required this.anchor, + required this.isState, + }); +} + +class _DependencyCollector extends RecursiveAstVisitor { + final Set knownNames; + final dependencies = {}; + + _DependencyCollector(this.knownNames); + + @override + void visitNamedType(NamedType node) { + final type = simpleTypeName(node.toSource()); + if (knownNames.contains(type)) dependencies.add(type); + super.visitNamedType(node); + } + + @override + void visitInstanceCreationExpression(InstanceCreationExpression node) { + final type = simpleTypeName(node.constructorName.type.toSource()); + if (knownNames.contains(type)) dependencies.add(type); + super.visitInstanceCreationExpression(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + final name = node.methodName.name; + if (name == 'watch' || name == 'read') { + if (node.argumentList.arguments.isNotEmpty) { + final dependency = node.argumentList.arguments.first.toSource(); + final simple = dependency.split('.').first; + if (knownNames.contains(simple)) dependencies.add(simple); + } + } + final typeArguments = + node.typeArguments?.arguments ?? const []; + for (final argument in typeArguments) { + final type = simpleTypeName(argument.toSource()); + if (knownNames.contains(type)) dependencies.add(type); + } + super.visitMethodInvocation(node); + } +} diff --git a/lib/src/rules/state_management_utils.dart b/lib/src/rules/state_management_utils.dart new file mode 100644 index 0000000..4479f9d --- /dev/null +++ b/lib/src/rules/state_management_utils.dart @@ -0,0 +1,205 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import '../config_loader.dart'; +import '../source_workspace.dart'; +import '../static_issue.dart'; + +const stateOwnerSuffixes = [ + 'State', + 'Notifier', + 'Controller', + 'Cubit', + 'Bloc', + 'ChangeNotifier', +]; + +bool stateRuleEnabled(RuleConfig rule) => rule.enabled; + +bool hasFrameworkImport( + CompilationUnit unit, + StateManagementFramework framework, +) { + final imports = unit.directives + .whereType() + .map((directive) => directive.uri.stringValue ?? '') + .toList(); + return switch (framework) { + StateManagementFramework.riverpod => imports.any( + (uri) => uri.contains('riverpod') || uri.contains('hooks_riverpod'), + ), + StateManagementFramework.bloc => imports.any( + (uri) => + uri.contains('package:bloc/') || + uri.contains('package:flutter_bloc/'), + ), + StateManagementFramework.provider => imports.any( + (uri) => uri.contains('package:provider/'), + ), + StateManagementFramework.generic => true, + }; +} + +bool frameworkAllowed( + CompilationUnit unit, + StateManagementFramework framework, +) => hasFrameworkImport(unit, framework); + +bool isStateOwnerClass(ClassDeclaration declaration) { + final name = declaration.name.lexeme; + final supertype = declaration.extendsClause?.superclass.toSource() ?? ''; + final mixins = + declaration.withClause?.mixinTypes + .map((type) => type.toSource()) + .join(' ') ?? + ''; + if (supertype.startsWith('State<')) return false; + return stateOwnerSuffixes.any(name.endsWith) || + stateOwnerSuffixes.any(supertype.endsWith) || + stateOwnerSuffixes.any(mixins.contains); +} + +String typeName(TypeAnnotation? type) => type?.toSource() ?? ''; + +String simpleTypeName(String type) { + final withoutGenerics = type.split('<').first; + return withoutGenerics.split('.').last.replaceAll('?', ''); +} + +bool isCollectionType(String type) { + final simple = simpleTypeName(type); + return simple == 'List' || + simple == 'Set' || + simple == 'Map' || + simple.startsWith('Iterable'); +} + +bool isUnmodifiableExpression(Expression? expression) { + if (expression == null) return false; + final source = expression.toSource(); + return source.contains('unmodifiable') || + source.contains('Unmodifiable') || + source.contains('List.unmodifiable') || + source.contains('Set.unmodifiable') || + source.contains('Map.unmodifiable'); +} + +bool isPublicName(String name) => !name.startsWith('_'); + +String compactEvidence(AstNode node) { + final value = node.toSource().replaceAll(RegExp(r'\s+'), ' ').trim(); + return value.length <= 140 ? value : '${value.substring(0, 137)}...'; +} + +List limitedEvidence(Iterable evidence) => + evidence.toSet().take(5).toList(); + +int sourceLine(SourceUnit source, AstNode node) => + source.lineInfo.getLocation(node.offset).lineNumber; + +class BuildRoot { + final AstNode body; + final AstNode anchor; + final String label; + + const BuildRoot(this.body, this.anchor, this.label); +} + +List buildRoots(CompilationUnit unit) { + final collector = _BuildRootCollector(); + unit.accept(collector); + final roots = []; + final seen = {}; + for (final root in collector.roots) { + if (seen.add(root.anchor.offset)) roots.add(root); + } + return roots; +} + +class _BuildRootCollector extends RecursiveAstVisitor { + final roots = []; + + @override + void visitMethodDeclaration(MethodDeclaration node) { + if (node.name.lexeme == 'build') { + roots.add(BuildRoot(node.body, node, 'build')); + } + super.visitMethodDeclaration(node); + } + + @override + void visitNamedExpression(NamedExpression node) { + if (node.name.label.name == 'builder' && + node.expression is FunctionExpression && + _isConsumerBuilder(node)) { + final function = node.expression as FunctionExpression; + roots.add(BuildRoot(function.body, function, 'Consumer.builder')); + } + super.visitNamedExpression(node); + } + + bool _isConsumerBuilder(NamedExpression node) { + final arguments = node.parent; + final call = arguments?.parent; + if (call is InstanceCreationExpression) { + return call.constructorName.type.toSource().contains('Consumer'); + } + if (call is MethodInvocation) { + return call.methodName.name.toLowerCase().contains('consumer'); + } + return false; + } +} + +bool isCallbackFunction(FunctionExpression node) { + final parent = node.parent; + if (parent is NamedExpression) { + const callbackNames = { + 'onPressed', + 'onTap', + 'onChanged', + 'onSubmitted', + 'listener', + 'listenWhen', + 'callback', + 'onData', + 'onError', + 'onDone', + 'timer', + }; + return callbackNames.contains(parent.name.label.name); + } + final arguments = parent is NamedExpression ? parent.parent : parent; + final invocation = arguments?.parent; + if (invocation is MethodInvocation) { + return const { + 'forEach', + 'listen', + 'then', + 'catchError', + 'whenComplete', + 'delayed', + 'microtask', + }.contains(invocation.methodName.name); + } + if (invocation is InstanceCreationExpression) { + final type = simpleTypeName(invocation.constructorName.type.toSource()); + return type == 'Timer' || type == 'Future'; + } + return false; +} + +bool isInsideNestedFunction(AstNode node, AstNode root) { + AstNode? current = node.parent; + while (current != null && current != root) { + if (current is FunctionExpression) return true; + current = current.parent; + } + return false; +} + +bool hasEquatableImport(CompilationUnit unit) => + unit.directives.whereType().any( + (directive) => + (directive.uri.stringValue ?? '').contains('package:equatable/'), + ); diff --git a/lib/src/sarif_report.dart b/lib/src/sarif_report.dart new file mode 100644 index 0000000..524ae82 --- /dev/null +++ b/lib/src/sarif_report.dart @@ -0,0 +1,87 @@ +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': _level(rule.defaultSeverity), + }, + 'properties': {'framework': rule.framework}, + }, + ], + }, + }, + 'results': [ + for (final issue in issues) + { + 'ruleId': issue.id, + 'level': _level(issue.level), + 'message': {'text': issue.message}, + 'properties': { + 'framework': issue.framework.name, + 'evidence': issue.evidence.take(5).toList(), + }, + 'locations': [ + { + 'physicalLocation': { + 'artifactLocation': { + 'uri': _relativeUri(issue.file, projectPath), + }, + 'region': {'startLine': issue.line ?? 1}, + }, + }, + ], + }, + ], + }, + ], + }; + + 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'; + } + } +} diff --git a/lib/src/scan_context.dart b/lib/src/scan_context.dart new file mode 100644 index 0000000..bf2abed --- /dev/null +++ b/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/lib/src/scanner.dart b/lib/src/scanner.dart new file mode 100644 index 0000000..7bc7ce8 --- /dev/null +++ b/lib/src/scanner.dart @@ -0,0 +1,211 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'baseline.dart'; +import 'config_loader.dart'; +import 'file_collector.dart'; +import 'path_utils.dart'; +import 'project_resolver.dart'; +import 'report_generator.dart'; +import 'scan_context.dart'; +import 'rules/registry.dart'; +import 'sarif_report.dart'; +import 'static_issue.dart'; +import 'source_workspace.dart'; +import 'suppression.dart'; + +class ScanException implements Exception { + final String message; + + const ScanException(this.message); + + @override + String toString() => message; +} + +class ScanResult { + final String projectPath; + final String reportDir; + final List files; + final List rawIssues; + final List issues; + final int suppressedCount; + final int suppressedByBaselineCount; + final String scanMode; + final List diagnostics; + final SourceWorkspace sources; + + const ScanResult({ + required this.projectPath, + required this.reportDir, + required this.files, + required this.rawIssues, + required this.issues, + required this.suppressedCount, + required this.suppressedByBaselineCount, + required this.scanMode, + required this.sources, + this.diagnostics = const [], + }); +} + +class FlutterGuardScanner { + static ScanResult scan({ + required String projectPath, + String? configPath, + String outputDir = '.flutterguard', + bool writeJson = false, + bool writeSarif = false, + bool changedOnly = false, + String base = 'main', + String? baselinePath, + bool applySuppression = true, + }) { + final resolvedProjectPath = ProjectResolver.resolveProjectPath(projectPath); + if (!Directory(resolvedProjectPath).existsSync()) { + throw ScanException( + 'Project path "$resolvedProjectPath" does not exist.', + ); + } + + final resolvedConfigPath = ProjectResolver.resolveConfigPath( + projectPath: resolvedProjectPath, + explicitConfig: configPath, + ); + final config = ScanConfig.fromFile( + resolvedConfigPath, + requireFile: configPath != null, + ); + final files = FileCollector.collect(resolvedProjectPath, config); + if (files.isEmpty) { + throw const ScanException( + 'No Dart files matched the configured include/exclude patterns.', + ); + } + var scanMode = ScanMode.full; + var filesToScan = files; + var changedFiles = {}; + + if (changedOnly) { + try { + final changed = FileCollector.getChangedFiles( + resolvedProjectPath, + base, + ); + if (changed != null) { + changedFiles = changed.map(normalizePath).toSet(); + final changedDart = changedFiles + .where((f) => f.endsWith('.dart')) + .toSet(); + filesToScan = files.where((f) => changedDart.contains(f)).toList(); + scanMode = ScanMode.changed; + } + } on ChangedFilesException catch (e) { + throw ScanException(e.message); + } + } + + final reportDir = p.isAbsolute(outputDir) + ? outputDir + : p.join(resolvedProjectPath, outputDir); + + final sources = SourceWorkspace(); + final context = ScanContext( + projectPath: resolvedProjectPath, + config: config, + allFiles: files, + targetFiles: filesToScan, + mode: scanMode, + sources: sources, + changedFiles: changedFiles, + ); + final rawIssues = _analyze(context); + + var issues = rawIssues; + var suppressedCount = 0; + if (applySuppression) { + final suppression = SuppressionFilter(filesToScan, workspace: sources); + final visible = []; + for (final issue in issues) { + if (suppression.isSuppressed(issue)) { + suppressedCount++; + } else { + visible.add(issue); + } + } + issues = visible; + } + + var suppressedByBaselineCount = 0; + if (baselinePath != null) { + final resolvedBaselinePath = p.isAbsolute(baselinePath) + ? baselinePath + : p.join(resolvedProjectPath, baselinePath); + final baseline = Baseline.load(resolvedBaselinePath); + final visible = []; + for (final issue in issues) { + if (baseline.contains(issue, resolvedProjectPath)) { + suppressedByBaselineCount++; + } else { + visible.add(issue); + } + } + issues = visible; + } + + if (writeJson || writeSarif) { + Directory(reportDir).createSync(recursive: true); + } + if (writeJson) { + final json = ReportGenerator.generateJson( + projectPath: resolvedProjectPath, + issues: issues, + scanMode: scanMode.name, + suppressedCount: suppressedCount, + suppressedByBaselineCount: suppressedByBaselineCount, + diagnostics: sources.diagnostics, + ); + File(p.join(reportDir, 'report.json')).writeAsStringSync(json); + } + if (writeSarif) { + final sarif = SarifReport.generate( + projectPath: resolvedProjectPath, + issues: issues, + ); + File(p.join(reportDir, 'report.sarif')).writeAsStringSync(sarif); + } + + return ScanResult( + projectPath: resolvedProjectPath, + reportDir: reportDir, + files: filesToScan, + rawIssues: rawIssues, + issues: issues, + suppressedCount: suppressedCount, + suppressedByBaselineCount: suppressedByBaselineCount, + scanMode: scanMode.name, + sources: sources, + diagnostics: sources.diagnostics, + ); + } + + static List _analyze(ScanContext context) { + final allIssues = RuleRegistry.analyze(context); + + allIssues.sort((a, b) { + final levelOrder = { + RiskLevel.high: 0, + RiskLevel.medium: 1, + RiskLevel.low: 2, + }; + final levelCompare = levelOrder[a.level]!.compareTo(levelOrder[b.level]!); + if (levelCompare != 0) return levelCompare; + final fileCompare = a.file.compareTo(b.file); + if (fileCompare != 0) return fileCompare; + return (a.line ?? 0).compareTo(b.line ?? 0); + }); + + return allIssues; + } +} diff --git a/lib/src/source_workspace.dart b/lib/src/source_workspace.dart new file mode 100644 index 0000000..189c856 --- /dev/null +++ b/lib/src/source_workspace.dart @@ -0,0 +1,102 @@ +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'; + +int lineNumberForOffset(LineInfo lineInfo, int offset) => + lineInfo.getLocation(offset).lineNumber; + +enum ScanDiagnosticSeverity { warning, error } + +class ScanDiagnostic { + final String stage; + final String message; + final String? file; + final ScanDiagnosticSeverity severity; + + const ScanDiagnostic({ + required this.stage, + required this.message, + this.file, + this.severity = ScanDiagnosticSeverity.warning, + }); + + Map toJson() => { + 'stage': stage, + 'message': message, + 'file': file, + 'severity': severity.name, + }; +} + +class SourceUnit { + final String path; + final String content; + final List lines; + final CompilationUnit unit; + final LineInfo lineInfo; + + const SourceUnit({ + required this.path, + required this.content, + required this.lines, + required this.unit, + required this.lineInfo, + }); +} + +/// Per-scan source cache shared by every rule. +/// +/// A source file is read and parsed at most once. Failures are retained as +/// diagnostics instead of being silently swallowed by each rule. +class SourceWorkspace { + final Map _sources = {}; + final List _diagnostics = []; + + List get diagnostics => List.unmodifiable(_diagnostics); + + SourceUnit? source(String filePath) { + final path = normalizePath(filePath); + if (_sources.containsKey(path)) return _sources[path]; + + try { + final content = File(path).readAsStringSync(); + final parsed = parseString(content: content, path: path); + final source = SourceUnit( + path: path, + content: content, + lines: const LineSplitter().convert(content), + unit: parsed.unit, + lineInfo: parsed.lineInfo, + ); + _sources[path] = source; + return source; + } on FileSystemException catch (error) { + _recordFailure(path, 'read', error.message); + } on Object catch (error) { + _recordFailure(path, 'parse', error.toString()); + } + + _sources[path] = null; + return null; + } + + void addDiagnostic(ScanDiagnostic diagnostic) { + _diagnostics.add(diagnostic); + } + + void _recordFailure(String path, String stage, String message) { + _diagnostics.add( + ScanDiagnostic( + stage: stage, + file: path, + message: message, + severity: ScanDiagnosticSeverity.error, + ), + ); + } +} diff --git a/lib/src/static_issue.dart b/lib/src/static_issue.dart new file mode 100644 index 0000000..99dc1e2 --- /dev/null +++ b/lib/src/static_issue.dart @@ -0,0 +1,50 @@ +enum IssueDomain { architecture, performance, standards } + +enum RiskLevel { low, medium, high } + +enum StateManagementFramework { riverpod, bloc, provider, generic } + +class StaticIssue { + final String id; + final String title; + final String file; + final int? line; + final RiskLevel level; + final IssueDomain domain; + final String message; + final String detail; + final String suggestion; + final Map metadata; + final StateManagementFramework framework; + final List evidence; + + const StaticIssue({ + required this.id, + required this.title, + required this.file, + this.line, + required this.level, + required this.domain, + required this.message, + this.detail = '', + required this.suggestion, + this.metadata = const {}, + this.framework = StateManagementFramework.generic, + this.evidence = const [], + }); + + Map toJson() => { + 'ruleId': id, + 'title': title, + 'file': file, + 'line': line, + 'severity': level.name, + 'domain': domain.name, + 'message': message, + 'detail': detail, + 'suggestion': suggestion, + 'metadata': metadata, + 'framework': framework.name, + 'evidence': evidence.take(5).toList(), + }; +} diff --git a/lib/src/suppression.dart b/lib/src/suppression.dart new file mode 100644 index 0000000..91e3c0e --- /dev/null +++ b/lib/src/suppression.dart @@ -0,0 +1,65 @@ +import 'dart:io'; + +import 'source_workspace.dart'; +import 'static_issue.dart'; + +class SuppressionFilter { + final Map>> _rulesByFileAndLine = {}; + + SuppressionFilter(Iterable files, {SourceWorkspace? workspace}) { + for (final file in files) { + _rulesByFileAndLine[file] = _parseFile(file, workspace: workspace); + } + } + + bool isSuppressed(StaticIssue issue) { + final line = issue.line; + if (line == null) return false; + final byLine = _rulesByFileAndLine[issue.file]; + if (byLine == null) return false; + final rules = byLine[line]; + if (rules == null) return false; + return rules.contains('all') || rules.contains(issue.id); + } + + static Map> _parseFile( + String path, { + SourceWorkspace? workspace, + }) { + final List lines; + if (workspace == null) { + final file = File(path); + if (!file.existsSync()) return const {}; + lines = file.readAsLinesSync(); + } else { + final source = workspace.source(path); + if (source == null) return const {}; + lines = source.lines; + } + + final result = >{}; + for (var index = 0; index < lines.length; index++) { + final parsed = _parseLine(lines[index]); + if (parsed == null) continue; + final lineNumber = index + 1; + result.putIfAbsent(lineNumber, () => {}).addAll(parsed); + result.putIfAbsent(lineNumber + 1, () => {}).addAll(parsed); + } + return result; + } + + static Set? _parseLine(String line) { + final match = RegExp( + r'flutterguard:\s*ignore\s+([A-Za-z0-9_,\s-]+)', + ).firstMatch(line); + if (match == null) return null; + final raw = match.group(1) ?? ''; + final ids = raw + .split(',') + .map((part) => part.trim()) + .where((part) => part.isNotEmpty) + .toSet(); + if (ids.isEmpty) return null; + return ids; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 535975c..b0538e5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,12 +1,28 @@ -name: flutterguard -publish_to: none +name: flutterguard_cli +description: IoT Flutter static analysis CLI for architecture, lifecycle, state, security, and CI enforcement. +version: 0.7.0 +repository: https://github.com/lizy-coding/flutterguard +issue_tracker: https://github.com/lizy-coding/flutterguard/issues +topics: + - static-analysis + - flutter + - iot + - architecture + - cli environment: sdk: ^3.11.5 dependencies: - flutterguard_cli: - path: packages/flutterguard_cli + analyzer: ^7.3.0 + args: ^2.5.0 + glob: ^2.1.2 + path: ^1.9.0 + yaml: ^3.1.2 + +executables: + flutterguard: flutterguard dev_dependencies: - melos: ^6.3.2 + lints: ^3.0.0 + test: ^1.25.8 diff --git a/test/AGENTS.md b/test/AGENTS.md new file mode 100644 index 0000000..bdedddf --- /dev/null +++ b/test/AGENTS.md @@ -0,0 +1,13 @@ +# Tests + +Tests are organized by contract rather than one monolithic suite: + +- `cli_test.dart`: process-level exit/path/report behavior. +- `config_test.dart`: generic config and registry-driven templates. +- `rules_test.dart`: detector ownership and positive cases. +- `scanner_test.dart`: orchestration, reports, baseline, and CI gates. +- `fixtures/`: parser inputs excluded from repository analysis. + +Prefer temporary directories for project/config/Git behavior. Every external +contract change must update a focused assertion; do not preserve deleted +features merely to keep the historical test count. diff --git a/test/cli_test.dart b/test/cli_test.dart new file mode 100644 index 0000000..2254499 --- /dev/null +++ b/test/cli_test.dart @@ -0,0 +1,193 @@ +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('CLI version matches package version', () { + final pubspec = File( + p.join(Directory.current.path, 'pubspec.yaml'), + ).readAsStringSync(); + final version = RegExp( + r'^version:\s*(\S+)', + multiLine: true, + ).firstMatch(pubspec)!.group(1); + final entrypoint = p.join( + Directory.current.path, + 'bin', + 'flutterguard.dart', + ); + final result = Process.runSync(Platform.resolvedExecutable, [ + entrypoint, + '--version', + ], workingDirectory: Directory.current.path); + + expect(result.exitCode, 0); + expect((result.stdout as String).trim(), 'flutterguard $version'); + }); + + test( + 'scan exits with setup error when the project matches no Dart files', + () { + final project = Directory.systemTemp.createTempSync( + 'flutterguard_cli_empty_project_', + ); + addTearDown(() => project.deleteSync(recursive: true)); + + final entrypoint = p.join( + Directory.current.path, + 'bin', + 'flutterguard.dart', + ); + final result = Process.runSync(Platform.resolvedExecutable, [ + entrypoint, + 'scan', + project.path, + '--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/test/config_test.dart b/test/config_test.dart new file mode 100644 index 0000000..6cd4d56 --- /dev/null +++ b/test/config_test.dart @@ -0,0 +1,103 @@ +import 'dart:io'; + +import 'package:flutterguard_cli/src/config_loader.dart'; +import 'package:flutterguard_cli/src/config_tools.dart'; +import 'package:flutterguard_cli/src/rules/registry.dart'; +import 'package:flutterguard_cli/src/static_issue.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + test('zero-config defaults remain small and deterministic', () { + final config = ScanConfig.defaults(); + expect(config.include, ['lib/**']); + expect(config.architecture.layers, isEmpty); + expect(config.configuredRuleIds, isEmpty); + }); + + test('generic rule config parses severity and typed options', () { + final directory = Directory.systemTemp.createTempSync('fg_config_'); + addTearDown(() => directory.deleteSync(recursive: true)); + final file = File(p.join(directory.path, 'flutterguard.yaml')) + ..writeAsStringSync(''' +rules: + iot_security: + enabled: true + severity: high + requireTls: false +'''); + final config = ScanConfig.fromFile(file.path); + final definition = RuleRegistry.find('iot_security')!; + final rule = config.rule( + definition.id, + defaultSeverity: definition.defaultSeverity, + defaultOptions: definition.defaultOptions, + ); + expect(rule.severity, RiskLevel.high); + expect(rule.boolOption('requireTls'), isFalse); + }); + + test('rule registry drives the generated starter config', () { + final template = ConfigTools.initTemplate(withArchitecture: false); + expect(RuleRegistry.all(), hasLength(16)); + for (final rule in RuleRegistry.all()) { + expect(template, contains(' ${rule.id}:')); + } + expect(template, isNot(contains('confidence_threshold'))); + expect(template, isNot(contains('large_file:'))); + expect(template, isNot(contains('mqtt_connection:'))); + expect(template, isNot(contains('pubspec_security:'))); + }); + + test('invalid config values fail fast', () { + final directory = Directory.systemTemp.createTempSync('fg_bad_config_'); + addTearDown(() => directory.deleteSync(recursive: true)); + final file = File(p.join(directory.path, 'flutterguard.yaml')) + ..writeAsStringSync('rules:\n ble_scanning: false\n'); + expect(() => ScanConfig.fromFile(file.path), throwsFormatException); + }); + + test('config check rejects unknown rule IDs', () { + final project = Directory.systemTemp.createTempSync('fg_doctor_'); + addTearDown(() => project.deleteSync(recursive: true)); + Directory(p.join(project.path, 'lib')).createSync(); + File( + p.join(project.path, 'lib', 'main.dart'), + ).writeAsStringSync('class A {}'); + File(p.join(project.path, 'flutterguard.yaml')).writeAsStringSync(''' +rules: + removed_rule: + enabled: true +'''); + final result = ConfigTools.doctor(projectPath: project.path); + expect(result.hasErrors, isTrue); + expect(result.messages.single.message, contains('removed_rule')); + }); + + test('config check rejects options absent from the rule definition', () { + final project = Directory.systemTemp.createTempSync('fg_options_'); + addTearDown(() => project.deleteSync(recursive: true)); + Directory(p.join(project.path, 'lib')).createSync(); + File( + p.join(project.path, 'lib', 'main.dart'), + ).writeAsStringSync('class A {}'); + File(p.join(project.path, 'flutterguard.yaml')).writeAsStringSync(''' +rules: + ble_scanning: + allowlist: [LegacyScanner] +'''); + expect( + () => ConfigTools.doctor(projectPath: project.path), + throwsFormatException, + ); + + File(p.join(project.path, 'flutterguard.yaml')).writeAsStringSync(''' +rules: + ble_scanning: + timeoutAlias: 5000 +'''); + final result = ConfigTools.doctor(projectPath: project.path); + expect(result.hasErrors, isTrue); + expect(result.messages.single.message, contains('timeoutAlias')); + }); +} diff --git a/test/fixtures/AGENTS.md b/test/fixtures/AGENTS.md new file mode 100644 index 0000000..825948b --- /dev/null +++ b/test/fixtures/AGENTS.md @@ -0,0 +1,13 @@ +# Rule fixtures + +Fixtures are intentionally small parser inputs and may reference Flutter or +third-party types that are not dependencies of this Dart CLI package. + +- architecture and cycle files exercise import graphs and boundaries; +- lifecycle/BLE/security files exercise IoT ownership; +- generic/Riverpod/Bloc/Provider files exercise state rules; +- `state_suppression.dart` exercises inline suppression. + +Keep one behavior per fixture where practical. Add imports when framework +auto-detection depends on them. Do not add generated outputs or full sample +applications here. diff --git a/test/fixtures/architecture_config.yaml b/test/fixtures/architecture_config.yaml new file mode 100644 index 0000000..70978a4 --- /dev/null +++ b/test/fixtures/architecture_config.yaml @@ -0,0 +1,29 @@ +include: + - "**/*.dart" +exclude: [] +rules: + lifecycle_resource_not_disposed: + enabled: true + severity: medium + layer_violation: + enabled: true + severity: high + module_violation: + enabled: true + severity: high +architecture: + layers: + - name: ui + path: "**/boundary_issue.dart" + allowed_deps: [] + - name: model + path: "**/forbidden_file.dart" + allowed_deps: [] + modules: + - name: feature_a + path: "**/boundary_issue.dart" + allowed_deps: [] + - name: feature_b + path: "**/forbidden_file.dart" + allowed_deps: [] + detect_cycles: false diff --git a/test/fixtures/architecture_disabled.yaml b/test/fixtures/architecture_disabled.yaml new file mode 100644 index 0000000..6a5f3c8 --- /dev/null +++ b/test/fixtures/architecture_disabled.yaml @@ -0,0 +1,24 @@ +include: + - "**/*.dart" +exclude: [] +rules: + layer_violation: + enabled: false + module_violation: + enabled: false +architecture: + layers: + - name: ui + path: "**/boundary_issue.dart" + allowed_deps: [] + - name: model + path: "**/forbidden_file.dart" + allowed_deps: [] + modules: + - name: feature_a + path: "**/boundary_issue.dart" + allowed_deps: [] + - name: feature_b + path: "**/forbidden_file.dart" + allowed_deps: [] + detect_cycles: false diff --git a/test/fixtures/ble_scanning_issue.dart b/test/fixtures/ble_scanning_issue.dart new file mode 100644 index 0000000..62b5a57 --- /dev/null +++ b/test/fixtures/ble_scanning_issue.dart @@ -0,0 +1,17 @@ +// ignore_for_file: unused_field +// Fixture: BLE scanning issues +class BleDevice {} + +class BleService { + late BleDevice _device; + + void startScan() { + // scanning indefinitely + } + + void connect() { + // connecting to device + } + + // stopScan() and disconnect() are missing +} diff --git a/test/fixtures/bloc_state.dart b/test/fixtures/bloc_state.dart new file mode 100644 index 0000000..9467542 --- /dev/null +++ b/test/fixtures/bloc_state.dart @@ -0,0 +1,37 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class DeviceState extends Equatable { + const DeviceState(this.name, this.connected); + + final String name; + final bool connected; + + List get props => [name]; +} + +class ReadingState extends Equatable { + const ReadingState(this.temperature, this.humidity); + + final double temperature; + final double humidity; + + List get props => [temperature]; +} + +class CompleteState extends Equatable { + const CompleteState(this.a, this.b); + + final int a; + final int b; + + List get props => [a, b]; +} + +class EmptyState extends Equatable { + const EmptyState(); + + static const version = 1; + int get computed => 1; + List get props => const []; +} diff --git a/test/fixtures/boundary_issue.dart b/test/fixtures/boundary_issue.dart new file mode 100644 index 0000000..dd675dc --- /dev/null +++ b/test/fixtures/boundary_issue.dart @@ -0,0 +1,5 @@ +// ignore_for_file: unused_element +import 'forbidden_file.dart'; + +// Fixture: boundary import violation +class BoundaryIssueWidget extends ForbiddenWidget {} diff --git a/test/fixtures/cycle_a.dart b/test/fixtures/cycle_a.dart new file mode 100644 index 0000000..690c60b --- /dev/null +++ b/test/fixtures/cycle_a.dart @@ -0,0 +1,7 @@ +// Fixture: circular dependency (part of cycle) +import 'cycle_b.dart'; + +class CycleA { + final CycleB b; + CycleA(this.b); +} diff --git a/test/fixtures/cycle_b.dart b/test/fixtures/cycle_b.dart new file mode 100644 index 0000000..440a173 --- /dev/null +++ b/test/fixtures/cycle_b.dart @@ -0,0 +1,7 @@ +// Fixture: circular dependency (part of cycle) +import 'cycle_c.dart'; + +class CycleB { + final CycleC c; + CycleB(this.c); +} diff --git a/test/fixtures/cycle_c.dart b/test/fixtures/cycle_c.dart new file mode 100644 index 0000000..62d0556 --- /dev/null +++ b/test/fixtures/cycle_c.dart @@ -0,0 +1,7 @@ +// Fixture: circular dependency (part of cycle) +import 'cycle_a.dart'; + +class CycleC { + final CycleA a; + CycleC(this.a); +} diff --git a/test/fixtures/forbidden_file.dart b/test/fixtures/forbidden_file.dart new file mode 100644 index 0000000..dfa55fa --- /dev/null +++ b/test/fixtures/forbidden_file.dart @@ -0,0 +1,2 @@ +// Forbidden file that boundary rules should catch +class ForbiddenWidget {} diff --git a/test/fixtures/generic_state.dart b/test/fixtures/generic_state.dart new file mode 100644 index 0000000..0ff3f95 --- /dev/null +++ b/test/fixtures/generic_state.dart @@ -0,0 +1,92 @@ +class Widget { + Widget({Object? onPressed}); +} + +class BuildContext {} + +class StatelessWidget {} + +class DeviceController { + DeviceController(); + + final List _items = []; + List get items => _items; + + void mutate() { + state.items.add(1); + } +} + +class DeviceBloc {} + +class BadBuildWidget extends StatelessWidget { + Widget build(BuildContext context) { + notifyListeners(); + final controller = DeviceController(); + return Widget(); + } +} + +class SecondBadBuildWidget extends StatelessWidget { + Widget build(BuildContext context) { + setState(() {}); + final bloc = DeviceBloc(); + return Widget(); + } +} + +class SafeBuildWidget extends StatelessWidget { + Widget build(BuildContext context) { + final values = []; + values.add(1); + return Widget( + onPressed: () { + notifyListeners(); + final controller = DeviceController(); + }, + ); + } +} + +class DeviceState { + int count = 0; + final List items = []; +} + +class SafeState { + final List items = List.unmodifiable(const []); +} + +class State {} + +class Page {} + +class PageState extends State { + int widgetCounter = 0; +} + +class NavigationController { + NavigationController(this.context); + + final BuildContext context; + + void open() { + Navigator.of(context).push('/device'); + } +} + +class ThemeController { + void update(Widget widget) {} +} + +class CycleController { + CycleController(this.service); + + final CycleService service; +} + +class CycleService { + CycleService(this.controller); + + final CycleController controller; +} diff --git a/test/fixtures/iot_security_issue.dart b/test/fixtures/iot_security_issue.dart new file mode 100644 index 0000000..422822f --- /dev/null +++ b/test/fixtures/iot_security_issue.dart @@ -0,0 +1,17 @@ +// ignore_for_file: unused_local_variable +// Fixture: IoT security issues +class IotSecurityWidget { + void connect() { + // hardcoded credential + final password = "admin123"; + + // cleartext MQTT + final brokerUrl = "tcp://192.168.1.100:1883"; + + // cleartext HTTP + final apiUrl = "http://iot.example.com/api/data"; + + // insecure BLE + final bleConfig = "withoutBonding"; + } +} diff --git a/test/fixtures/lifecycle_issue.dart b/test/fixtures/lifecycle_issue.dart new file mode 100644 index 0000000..8c902c0 --- /dev/null +++ b/test/fixtures/lifecycle_issue.dart @@ -0,0 +1,18 @@ +// ignore_for_file: unused_field, inference_failure_on_instance_creation +import 'dart:async'; + +// Fixture: lifecycle resource not disposed +class LifecycleIssueWidget { + late StreamSubscription _subscription; + late Timer _timer; + + LifecycleIssueWidget() { + _subscription = const Stream.empty().listen((_) {}); + _timer = Timer.periodic(const Duration(seconds: 1), (_) {}); + } + + void dispose() { + // _subscription.cancel() is missing + // _timer.cancel() is missing + } +} diff --git a/test/fixtures/provider_state.dart b/test/fixtures/provider_state.dart new file mode 100644 index 0000000..7cd4c71 --- /dev/null +++ b/test/fixtures/provider_state.dart @@ -0,0 +1,42 @@ +import 'package:provider/provider.dart'; + +class DeviceController {} + +class ImmutableValue { + const ImmutableValue(); +} + +final existingController = DeviceController(); + +final badValue = ChangeNotifierProvider.value( + value: DeviceController(), +); + +final badCreate = ChangeNotifierProvider(create: (_) => existingController); + +final safeCreate = ChangeNotifierProvider(create: (_) => DeviceController()); + +final safeValue = ChangeNotifierProvider.value(value: existingController); + +final safeImmutable = Provider.value(value: const ImmutableValue()); + +class DeviceNotifier { + void updateAll(List values) { + for (final value in values) { + notifyListeners(); + } + values.forEach((value) { + notifyListeners(); + }); + } + + void safe() { + for (final value in [1]) { + notifyListeners(); + } + for (final value in [1, 2]) { + consume(value); + } + notifyListeners(); + } +} diff --git a/test/fixtures/riverpod_state.dart b/test/fixtures/riverpod_state.dart new file mode 100644 index 0000000..fd222d3 --- /dev/null +++ b/test/fixtures/riverpod_state.dart @@ -0,0 +1,47 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class Widget { + Widget({Object? value, Object? onPressed, Object? onTap}); +} + +class BuildContext {} + +class ConsumerWidget { + Widget build(BuildContext context, dynamic ref) => Widget(); +} + +final deviceProvider = Provider((ref) => 1); + +class DirectReadWidget extends ConsumerWidget { + Widget build(BuildContext context, dynamic ref) { + return Widget(value: ref.read(deviceProvider)); + } +} + +class ConditionalReadWidget extends ConsumerWidget { + Widget build(BuildContext context, dynamic ref) { + final enabled = ref.read(deviceProvider); + if (enabled) return Widget(); + return Widget(); + } +} + +class SafeReadWidget extends ConsumerWidget { + Widget build(BuildContext context, dynamic ref) { + ref.read(deviceProvider.notifier).refresh(); + return Widget(onPressed: () => ref.read(deviceProvider)); + } +} + +class WatchCallbackWidget extends ConsumerWidget { + Widget build(BuildContext context, dynamic ref) { + final value = ref.watch(deviceProvider); + return Widget( + value: value, + onPressed: () => ref.watch(deviceProvider), + onTap: () async { + ref.watch(deviceProvider); + }, + ); + } +} diff --git a/test/fixtures/state_suppression.dart b/test/fixtures/state_suppression.dart new file mode 100644 index 0000000..b744710 --- /dev/null +++ b/test/fixtures/state_suppression.dart @@ -0,0 +1,66 @@ +class SuppressedBuildWidget { + // flutterguard: ignore side_effect_in_build + Object build(Object context) { + notifyListeners(); + // flutterguard: ignore state_manager_created_in_build + final controller = SuppressedController(); + return controller; + } +} + +class SuppressedController { + // flutterguard: ignore mutable_state_exposed + int counter = 0; +} + +// flutterguard: ignore state_layer_ui_dependency +class SuppressedNavigationController { + void open(BuildContext context) { + Navigator.of(context).push('/device'); + } +} + +// flutterguard: ignore state_dependency_cycle +class SuppressedCycleController { + final SuppressedCycleService service; +} + +class SuppressedCycleService { + final SuppressedCycleController controller; +} + +class SuppressedRiverpodWidget { + Object build(Object context, dynamic ref) { + // flutterguard: ignore riverpod_read_used_for_render + return Text(ref.read(deviceProvider)); + } + + Object callbacks(dynamic ref) { + return Button( + // flutterguard: ignore riverpod_watch_in_callback + onPressed: () => ref.watch(deviceProvider), + ); + } +} + +// flutterguard: ignore bloc_equatable_props_incomplete +class SuppressedEquatableState extends Equatable { + final String name; + final bool connected; + + List get props => [name]; +} + +// flutterguard: ignore provider_value_lifecycle_misuse +final suppressedProvider = ChangeNotifierProvider.value( + value: SuppressedNotifier(), +); + +class SuppressedNotifier { + void update(List values) { + // flutterguard: ignore notify_listeners_in_loop + for (final value in values) { + notifyListeners(); + } + } +} diff --git a/test/rules_test.dart b/test/rules_test.dart new file mode 100644 index 0000000..1af59a3 --- /dev/null +++ b/test/rules_test.dart @@ -0,0 +1,148 @@ +import 'dart:io'; + +import 'package:flutterguard_cli/src/config_loader.dart'; +import 'package:flutterguard_cli/src/import_graph.dart'; +import 'package:flutterguard_cli/src/rules/ble_scanning.dart'; +import 'package:flutterguard_cli/src/rules/bloc_state_management.dart'; +import 'package:flutterguard_cli/src/rules/boundary_rule.dart'; +import 'package:flutterguard_cli/src/rules/circular_dependency.dart'; +import 'package:flutterguard_cli/src/rules/generic_state_management.dart'; +import 'package:flutterguard_cli/src/rules/iot_security.dart'; +import 'package:flutterguard_cli/src/rules/lifecycle_resource.dart'; +import 'package:flutterguard_cli/src/rules/provider_state_management.dart'; +import 'package:flutterguard_cli/src/rules/riverpod_state_management.dart'; +import 'package:flutterguard_cli/src/rules/state_dependency_cycle.dart'; +import 'package:flutterguard_cli/src/source_workspace.dart'; +import 'package:flutterguard_cli/src/static_issue.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +String get _fixtures => p.join(Directory.current.path, 'test', 'fixtures'); + +RuleConfig _config( + RiskLevel severity, { + Map options = const {}, +}) => RuleConfig(enabled: true, severity: severity, options: options); + +void main() { + group('IoT and lifecycle rules', () { + test('resource lifecycle finds undisposed fields', () { + final issues = LifecycleResourceRule( + _config(RiskLevel.medium), + ).analyze([p.join(_fixtures, 'lifecycle_issue.dart')]); + expect(issues, hasLength(2)); + expect( + issues.every((issue) => issue.id == 'lifecycle_resource_not_disposed'), + isTrue, + ); + }); + + test('BLE rule only owns scan timeout', () { + final issues = BleScanningRule( + _config(RiskLevel.medium), + ).analyze([p.join(_fixtures, 'ble_scanning_issue.dart')]); + expect(issues, hasLength(1)); + expect(issues.single.metadata['check'], 'scan_without_timeout'); + }); + + test('IoT security detects transport and credential risks', () { + final issues = IotSecurityRule( + _config(RiskLevel.high, options: {'requireTls': true}), + ).analyze([p.join(_fixtures, 'iot_security_issue.dart')]); + expect(issues.length, greaterThanOrEqualTo(3)); + expect(issues.every((issue) => issue.level == RiskLevel.high), isTrue); + }); + }); + + group('Architecture rules', () { + test('one boundary engine serves layer and module rules', () { + final files = [ + p.join(_fixtures, 'boundary_issue.dart'), + p.join(_fixtures, 'forbidden_file.dart'), + ]; + final workspace = SourceWorkspace(); + final graph = ImportGraph.build( + files: files, + sourceFiles: files, + workspace: workspace, + projectPath: Directory.current.path, + ); + final boundaries = [ + (name: 'ui', path: '**/boundary_issue.dart', allowedDeps: const []), + (name: 'model', path: '**/forbidden_file.dart', allowedDeps: const []), + ]; + for (final kind in BoundaryKind.values) { + final issues = + BoundaryRule( + kind: kind, + boundaries: boundaries, + severity: RiskLevel.high, + projectPath: Directory.current.path, + ).analyze( + files, + allFiles: files, + workspace: workspace, + importGraph: graph, + ); + expect(issues, hasLength(1)); + expect(issues.single.id, '${kind.name}_violation'); + } + }); + + test('circular dependency uses the shared import graph', () { + final files = [ + p.join(_fixtures, 'cycle_a.dart'), + p.join(_fixtures, 'cycle_b.dart'), + p.join(_fixtures, 'cycle_c.dart'), + ]; + final issues = CircularDependencyRule( + projectPath: Directory.current.path, + ).analyze(files); + expect(issues, isNotEmpty); + expect(issues.first.id, 'circular_dependency'); + }); + }); + + group('State architecture rules', () { + test('generic state rules detect build and mutable-state problems', () { + final file = p.join(_fixtures, 'generic_state.dart'); + final scans = Function()>[ + () => SideEffectInBuildRule(_config(RiskLevel.high)).analyze([file]), + () => StateManagerCreatedInBuildRule( + _config(RiskLevel.high), + ).analyze([file]), + () => + MutableStateExposedRule(_config(RiskLevel.medium)).analyze([file]), + () => + StateLayerUiDependencyRule(_config(RiskLevel.high)).analyze([file]), + ]; + for (final scan in scans) { + expect(scan(), isNotEmpty); + } + }); + + test('framework-specific rules are activated by imports', () { + final riverpod = RiverpodReadUsedForRenderRule( + _config(RiskLevel.medium), + ).analyze([p.join(_fixtures, 'riverpod_state.dart')]); + final bloc = BlocEquatablePropsIncompleteRule( + _config(RiskLevel.medium), + ).analyze([p.join(_fixtures, 'bloc_state.dart')]); + final provider = ProviderValueLifecycleMisuseRule( + _config(RiskLevel.medium), + ).analyze([p.join(_fixtures, 'provider_state.dart')]); + expect(riverpod, isNotEmpty); + expect(bloc, isNotEmpty); + expect(provider, isNotEmpty); + }); + + test('state dependency cycles remain project-wide', () { + final issues = StateDependencyCycleRule( + _config(RiskLevel.high), + projectPath: Directory.current.path, + ).analyze([p.join(_fixtures, 'generic_state.dart')]); + expect(issues, isNotEmpty); + expect(issues.first.id, 'state_dependency_cycle'); + }); + }); +} diff --git a/test/scanner_test.dart b/test/scanner_test.dart new file mode 100644 index 0000000..d61f212 --- /dev/null +++ b/test/scanner_test.dart @@ -0,0 +1,110 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutterguard_cli/src/baseline.dart'; +import 'package:flutterguard_cli/src/report_generator.dart'; +import 'package:flutterguard_cli/src/sarif_report.dart'; +import 'package:flutterguard_cli/src/scanner.dart'; +import 'package:flutterguard_cli/src/static_issue.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +Directory _project() { + final project = Directory.systemTemp.createTempSync('fg_project_'); + Directory(p.join(project.path, 'lib')).createSync(); + File(p.join(project.path, 'pubspec.yaml')).writeAsStringSync(''' +name: fixture_project +environment: + sdk: ^3.11.0 +'''); + File(p.join(project.path, 'lib', 'device.dart')).writeAsStringSync(''' +class Device { + void connect() { + final password = 'admin123'; + final broker = 'tcp://192.168.1.2:1883'; + } +} +'''); + return project; +} + +void main() { + test('scanner uses built-in defaults and sorts findings', () { + final project = _project(); + addTearDown(() => project.deleteSync(recursive: true)); + final result = FlutterGuardScanner.scan(projectPath: project.path); + expect(result.files, hasLength(1)); + expect(result.issues, isNotEmpty); + expect(result.issues.first.level, RiskLevel.high); + }); + + test('JSON report uses the compact 2.0 contract', () { + final project = _project(); + addTearDown(() => project.deleteSync(recursive: true)); + final result = FlutterGuardScanner.scan( + projectPath: project.path, + writeJson: true, + ); + final report = File(p.join(result.reportDir, 'report.json')); + final json = jsonDecode(report.readAsStringSync()) as Map; + expect(json['schemaVersion'], '2.0.0'); + final issue = (json['issues'] as List).first as Map; + expect(issue, contains('ruleId')); + expect(issue, contains('severity')); + expect(issue, isNot(contains('id'))); + expect(issue, isNot(contains('priority'))); + expect(issue, isNot(contains('confidence'))); + }); + + test('severity is the only CI gate dimension', () { + final issues = [ + StaticIssue( + id: 'test', + title: 'test', + file: '/tmp/test.dart', + level: RiskLevel.medium, + domain: IssueDomain.architecture, + message: 'test', + suggestion: 'fix', + ), + ]; + expect(ReportGenerator.shouldFail(issues, 'high'), isFalse); + expect(ReportGenerator.shouldFail(issues, 'medium'), isTrue); + expect(ReportGenerator.shouldFail(issues, 'none'), isFalse); + }); + + test('baseline fingerprints remain stable for the compact issue model', () { + final issue = StaticIssue( + id: 'test', + title: 'test', + file: '/project/lib/test.dart', + line: 3, + level: RiskLevel.medium, + domain: IssueDomain.architecture, + message: 'message', + suggestion: 'fix', + ); + final encoded = Baseline.encode(projectPath: '/project', issues: [issue]); + final baseline = Baseline.loadFromString(encoded); + expect(baseline.contains(issue, '/project'), isTrue); + }); + + test('SARIF contains the same registry and finding IDs', () { + final issue = StaticIssue( + id: 'iot_security', + title: 'security', + file: '/project/lib/test.dart', + level: RiskLevel.high, + domain: IssueDomain.architecture, + message: 'message', + suggestion: 'fix', + ); + final sarif = + jsonDecode( + SarifReport.generate(projectPath: '/project', issues: [issue]), + ) + as Map; + expect(jsonEncode(sarif), contains('iot_security')); + expect(jsonEncode(sarif), isNot(contains('confidence'))); + }); +} From da5e1bc34278045ffeded828b31898049812017c Mon Sep 17 00:00:00 2001 From: lizy Date: Sun, 19 Jul 2026 11:59:19 +0800 Subject: [PATCH 14/16] =?UTF-8?q?refactor:=20=E6=9B=B4=E6=96=B0=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E3=80=81CI=E3=80=81=E8=84=9A=E6=9C=AC=E5=92=8C?= =?UTF-8?q?=E6=A0=B9=E7=9B=AE=E5=BD=95=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/flutterguard.yml | 19 +- .github/workflows/release.yml | 6 +- .pubignore | 15 + AGENTS.md | 170 +++---- CHANGELOG.md | 26 + EVOLUTION_PLAN.md | 230 +++++++++ README.md | 659 ++++--------------------- doc/ARCHITECTURE.md | 109 ++++ doc/FLUTTERGUARD_SPEC.md | 218 ++++++++ example/README.md | 11 + example/flutterguard.yaml | 19 + example/lib/main.dart | 14 + example/lib/services/user_service.dart | 51 ++ example/lib/widgets/profile_card.dart | 62 +++ example/pubspec.yaml | 6 + scripts/package_release.ps1 | 4 +- scripts/package_release.sh | 4 +- 17 files changed, 938 insertions(+), 685 deletions(-) create mode 100644 .pubignore create mode 100644 EVOLUTION_PLAN.md create mode 100644 doc/ARCHITECTURE.md create mode 100644 doc/FLUTTERGUARD_SPEC.md create mode 100644 example/README.md create mode 100644 example/flutterguard.yaml create mode 100644 example/lib/main.dart create mode 100644 example/lib/services/user_service.dart create mode 100644 example/lib/widgets/profile_card.dart create mode 100644 example/pubspec.yaml diff --git a/.github/workflows/flutterguard.yml b/.github/workflows/flutterguard.yml index 3351164..4ed152c 100644 --- a/.github/workflows/flutterguard.yml +++ b/.github/workflows/flutterguard.yml @@ -19,27 +19,24 @@ jobs: - uses: dart-lang/setup-dart@v1 with: - sdk: stable + sdk: "3.11.5" - - name: Install workspace dependencies + - name: Install dependencies run: dart pub get - - name: Bootstrap workspace - run: dart run melos bootstrap + - name: Analyze + run: dart analyze - - name: Analyze workspace - run: dart run melos run analyze - - - name: Test CLI - run: dart run melos run test:cli + - name: Test + run: dart test - name: Scan demo with current checkout - run: dart run flutterguard_cli:flutterguard scan examples/scan_demo --format json --output .flutterguard/ci --fail-on high --min-score 80 --no-color + run: dart run bin/flutterguard.dart scan example --format json --output .flutterguard/ci --fail-on high --no-color - name: Upload report if: always() uses: actions/upload-artifact@v4 with: name: flutterguard-report-${{ matrix.os }} - path: examples/scan_demo/.flutterguard/ci/report.json + path: example/.flutterguard/ci/report.json if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1623f5..a51dea9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,11 +17,9 @@ jobs: with: sdk: "3.11.5" - run: dart pub get - - run: dart run melos bootstrap - - run: dart run melos run analyze - - run: dart run melos run test:cli + - run: dart analyze + - run: dart test - run: dart pub publish --server https://pub.dev --dry-run - working-directory: packages/flutterguard_cli build: needs: validate diff --git a/.pubignore b/.pubignore new file mode 100644 index 0000000..4d21799 --- /dev/null +++ b/.pubignore @@ -0,0 +1,15 @@ +.github/ +.idea/ +.dart_tool/ +.flutterguard/ +**/AGENTS.md +scripts/ +test/ +/flutterguard.yaml +analysis_options.yaml +pubspec.lock +dist/ +/flutterguard +/flutterguard.exe +*.iml +CONTEXT.md diff --git a/AGENTS.md b/AGENTS.md index 27b5a66..ac2d80b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,96 +1,84 @@ -# flutterguard — IoT Flutter Static Analysis CLI +# flutterguard — Agent Guide ## Identity -IoT/smart home Flutter project static analysis CLI plugin. NOT an observability SDK or APM tool. - -## Architecture -- **Monorepo**: melos, 1 package + 1 example -- **Path A (ACTIVE)**: `flutterguard_cli` — all new features -- **Path B (ARCHIVED)**: `flutterguard_core`, `flutterguard_dio`, `flutterguard_flutter` — in `archive/` for future reference - -## Package Hierarchy -| Package | Status | Depends On | Depended By | -|---------|--------|------------|-------------| -| flutterguard_cli | ACTIVE | args, analyzer, glob, path, yaml | — | -| scan_demo | — | (none, scan target) | — | - -## Key Commands -| Command | Purpose | -|---------|---------| -| `dart run melos bootstrap` | Install workspace dependencies | -| `dart run melos run analyze` | dart analyze on all packages | -| `dart run melos run test:cli` | CLI tests only (83 tests) | -| `flutterguard scan []` | Run scan on a project (path defaults to current dir) | -| `flutterguard scan --format json --fail-on high` | JSON output with CI gate | -| `flutterguard scan --changed-only` | Incremental scan of git-changed files | -| `flutterguard rules` / `flutterguard explain ` | List/describe rules | -| `dart compile exe ... -o flutterguard` | Compile native binary | - -## CI & Automation -- `.github/workflows/flutterguard.yml` — CI with ubuntu/macos/windows matrix -- `scripts/compile.sh` / `scripts/compile.ps1` — cross-platform native binary compilation -- `scripts/scan_ci.sh` / `scripts/scan_ci.ps1` — local CI gate scripts - -## CLI Entry Point -`packages/flutterguard_cli/bin/flutterguard.dart` - -Supports positional path: `flutterguard scan ./my_project` (no `-p` required). Project auto-discovery walks up from CWD to find `flutterguard.yaml`, `pubspec.yaml`, or `lib/`. Supports --changed-only incremental scan and rule introspection (rules/explain). - -Wired rules (21 rule classes, 23 rule IDs): -- Standards: LargeUnitsRule (3 IDs), MissingConstConstructorRule, PubspecSecurityRule -- Performance: LifecycleResourceRule -- Architecture: LayerViolationRule, ModuleViolationRule, CircularDependencyRule -- IoT: DeviceLifecycleRule, MqttConnectionRule, BleScanningRule, IotSecurityRule -- State management: 5 generic, 2 Riverpod, 1 Bloc, and 2 Provider rules - -## Source Layout + +FlutterGuard is an executable-only static analysis CLI for IoT/smart-home +Flutter projects. It is not an observability SDK, APM product, hosted service, +or general-purpose style linter. + +## Repository + +This is one Dart package rooted at the repository root. + +```text +bin/ executable entry point +lib/src/ scan, config, report, and rule implementation +test/ contract and detector tests +example/ CI scan target +doc/ architecture and external contract +scripts/ native release packaging ``` -packages/flutterguard_cli/lib/src/ - config_loader.dart # YAML → typed config (20 rule configs + state management + architecture) - scan_context.dart # Project/all/target files and scan mode - source_workspace.dart # Shared source/AST cache + scan diagnostics - import_graph.dart # Shared resolved Dart import graph - boundary_engine.dart # Shared layer/module boundary analysis - file_collector.dart # Glob file discovery - project_resolver.dart # Project auto-discovery (walk-up flutterguard.yaml / pubspec.yaml / lib/) - static_issue.dart # StaticIssue + RiskLevel + IssueDomain + Priority - report_generator.dart # Table + JSON output + score, --no-color support - domain.dart # IssueDomain enum (architecture/performance/standards) - priority.dart # Priority enum (p0/p1/p2) - path_utils.dart # Cross-platform path/glob helpers (p.Context abstraction) - import_utils.dart # Dart import resolution against collected files - source_utils.dart # Analyzer offset → line number conversion - rule_meta.dart # Rule metadata for rules/explain - rules/ - catalog.dart # Rule metadata + execution source of truth - registry.dart # RuleRegistry for all 23 rule IDs - state_management_utils.dart # Shared AST/import/owner/build/callback helpers - generic_state_management.dart # Generic build/mutability/UI rules - state_dependency_cycle.dart # Project-wide state dependency SCC rule - riverpod_state_management.dart # Riverpod read/watch rules - bloc_state_management.dart # Equatable props rule - provider_state_management.dart # Provider ownership/notify rules - large_units.dart # large_file, large_class, large_build_method - lifecycle_resource.dart # lifecycle_resource_not_disposed - layer_violation.dart # layer_violation (architecture layer breaches) - module_violation.dart # module_violation (cross-module breaches) - circular_dependency.dart # circular_dependency (file-level cycles) - missing_const_constructor.dart # missing_const_constructor - iot_security.dart # iot_security (hardcoded secrets, cleartext MQTT/HTTP, insecure BLE) - device_lifecycle.dart # device_lifecycle (init/teardown pair checks) - mqtt_connection.dart # mqtt_connection (MQTT connect/disconnect, broker URLs) - ble_scanning.dart # ble_scanning (BLE startScan/stopScan, timeout) - pubspec_security.dart # pubspec_security (unbounded deps, deprecated packages) + +Do not reintroduce Melos, `packages/`, archived runtime packages, a plugin +system, or a public scanner library API. + +## Commands + +```bash +dart pub get +dart format bin lib test +dart analyze +dart test +dart run bin/flutterguard.dart scan example --format json --no-color +dart compile exe bin/flutterguard.dart -o flutterguard +dart pub publish --dry-run ``` -## Spec -Single source of truth: `docs/FLUTTERGUARD_SPEC.md` — read before implementing any feature. - -## Maintenance Rules -1. New rule: spec entry → config typedef → rule class → fixture → test → wire into rules/catalog.dart -2. Always run `melos run analyze` + `melos run test:cli` before committing -3. Do NOT modify archived packages (core/dio/flutter) — they are frozen references -4. Do NOT add Flutter widgets, web/cloud infra, or SaaS SDKs -5. Output format defaults to `table`. JSON available via `--format=json` -6. Architecture rules require explicit `architecture.layers` / `architecture.modules` in flutterguard.yaml -7. CLI supports positional path (`flutterguard scan ./project`) and `--no-color` flag +## Architecture invariants + +- `ScanContext` carries project/all/target files, scan mode, config, and the + shared `SourceWorkspace`. +- `SourceWorkspace` owns source reads, AST parsing, line info, and diagnostics. +- Architecture rules share one `ImportGraph`. +- Layer and module enforcement share `BoundaryRule` and + `DependencyBoundaryEngine`. +- `RuleRegistry.registrations` is the only metadata/default/execution registry. +- Rules receive effective generic `RuleConfig`; rule-specific defaults live in + `RuleDefinition.defaultOptions`. +- CLI and JSON/SARIF are the supported integration boundary. `lib/src` is + private implementation. + +## Product surface + +Supported command families are `scan`, `baseline create`, `config init|check`, +and `rules [rule-id]`. + +The canonical finding taxonomy is `ruleId + severity + domain`. Do not add +priority, score, confidence, or compatibility aliases. Framework is descriptive +metadata, not a global configuration switch. + +Keep changed-only scanning, inline suppression, baseline filtering, JSON, and +SARIF behavior covered by tests. + +## Adding or changing a rule + +1. Implement or extend a detector under `lib/src/rules/`. +2. Add exactly one registration and definition in `rules/registry.dart`. +3. Add positive, negative, disabled, and output-contract coverage as needed. +4. Update `doc/FLUTTERGUARD_SPEC.md` only for external contract changes. +5. Run the full verification commands above. + +Do not add generic size, formatting, or missing-const checks; those belong to +Dart lints or dedicated complexity tooling. + +## Documentation hierarchy + +- `EVOLUTION_PLAN.md` is the local checkpoint and next-step execution order. +- This file defines repository-wide constraints. +- Nested `AGENTS.md` files contain only directory-specific responsibilities. +- `doc/ARCHITECTURE.md` defines internal boundaries. +- `doc/FLUTTERGUARD_SPEC.md` defines external behavior. +- `README.md` is the user onboarding document. + +When behavior changes, update the narrowest authoritative document rather than +copying the same contract across every level. diff --git a/CHANGELOG.md b/CHANGELOG.md index ac3dcef..ac1a8ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## 0.7.0 (2026-07-17) + +### Breaking simplification + +- Flattened the repository and publishable package into one Dart package root; + removed Melos, archived runtime packages, duplicate package metadata, IDE + project files, development wrappers, and historical planning documents. +- Reduced the CLI to `scan`, `baseline create`, `config init|check`, and + `rules [rule-id]`. +- Replaced typed per-rule config records and profile YAML copies with generic + registry-driven `RuleConfig` settings. +- Merged rule metadata, defaults, and execution into one `RuleRegistry` and + merged layer/module implementations into `BoundaryRule`. +- Removed generic size rules, `missing_const_constructor`, and the overlapping + `device_lifecycle` rule. +- Assigned resource cleanup to the lifecycle rule and scan timeout checks to + `ble_scanning`; removed overlapping MQTT configuration and dependency-version + rules in favor of application configuration and ecosystem tooling. +- Removed score, priority, confidence, framework switches, JSON compatibility + aliases, install diagnostics, issue export, config profiles/print, and + baseline stats/prune/check. +- JSON reports now use schema `2.0.0` with canonical `ruleId`, `severity`, and + `domain` fields. +- Reorganized the test suite by CLI, config, rules, and scanner contracts and + updated every hierarchical `AGENTS.md` description. + ## 0.6.0 (2026-07-16) - **cli:** Added 10 AST-first state-management maintainability rules for generic Flutter, Riverpod, Bloc, and Provider projects (23 total rule IDs). diff --git a/EVOLUTION_PLAN.md b/EVOLUTION_PLAN.md new file mode 100644 index 0000000..b9b7177 --- /dev/null +++ b/EVOLUTION_PLAN.md @@ -0,0 +1,230 @@ +--- +checkpoint_date: 2026-07-17 +timezone: Asia/Shanghai +branch: develop +base: origin/develop +head: 3aa32bb +target_version: 0.7.0 +target_json_schema: 2.0.0 +target_rule_count: 16 +checkpoint_state: implementation_complete_commits_pending +--- + +# FlutterGuard 演进路线与续跑检查点 + +## 用途 + +本文件是当前重构的续跑入口。后续执行者应先读取根目录及目标目录的 +`AGENTS.md`,再读取本文件和 `git status`,从第一个未完成阶段继续,不要重新 +推导已经确认的架构方向。 + +## 当前结论 + +FlutterGuard 正从 Melos 历史多包仓库收敛为一个根目录 Dart CLI 包。目标不是 +保留旧内部 API,而是减少维护面,同时稳定 CLI、JSON/SARIF、changed-only、 +baseline 和抑制注释等用户可见能力。 + +目标形态: + +- 根目录是唯一可发布的 `flutterguard_cli` 包; +- 仅保留 `bin/`、`lib/`、`test/`、`example/`、`doc/` 和发布脚本; +- CLI 只有 `scan`、`baseline create`、`config init|check`、`rules` 四个命令族; +- `RuleRegistry.registrations` 是规则说明、默认值和执行入口的唯一数据源; +- `ScanContext`、`SourceWorkspace` 和共享 `ImportGraph` 是扫描内核边界; +- JSON 使用 schema `2.0.0`,只保留规范字段名; +- 规则集合收敛到 16 个低重复、工程相关的规则。 + +## 已完成 + +### 已提交 + +- [x] `3aa32bb chore: remove obsolete architecture assets` + - 删除冻结的 runtime archive、IDE 工程文件、过期规划/重复文档和废弃入口; + - 50 个文件,删除 4,290 行; + - 当前分支相对 `origin/develop` 领先 1 个提交。 + +### 已实现但尚未提交 + +- [x] 将活跃包从 `packages/flutterguard_cli/` 扁平化到仓库根目录; +- [x] 将 `examples/scan_demo/` 收敛为 `example/`; +- [x] 删除 Melos、开发 wrapper 和重复编译/扫描脚本; +- [x] 将 CLI 收敛为四个命令族并移除重复 `--path`; +- [x] 用通用 `RuleConfig` 替换大量逐规则配置类型; +- [x] 合并 catalog、metadata 和 executor registry; +- [x] 合并 layer/module 检测实现并共享 import graph; +- [x] 删除 score、priority、confidence、兼容别名及无执行语义元数据; +- [x] 删除 generic size、missing const、device lifecycle、MQTT 配置和依赖版本规则; +- [x] 去除规则级 allowlist/ignore-path 旁路并拒绝未知规则参数; +- [x] 将测试重组为 CLI、配置、规则和扫描器契约测试; +- [x] 更新 README、外部规格、内部架构和分层 `AGENTS.md`; +- [x] 新增 `.pubignore`,发布包不再包含测试夹具、本地二进制和工程元数据。 + +## 当前工作区快照 + +记录时的 `git status --porcelain=v1 --untracked-files=all`: + +- 110 个已跟踪文件变更或删除; +- 70 个未跟踪新文件; +- 合计 180 项; +- 暂存区为空; +- 生产 Dart 代码约 4,836 行; +- 测试 Dart 代码约 918 行。 + +Git 将根目录迁移暂时显示为旧路径删除加新路径未跟踪。不要单独恢复 +`packages/flutterguard_cli/`、`examples/scan_demo/` 或 `docs/`;它们分别由 +根目录包、`example/` 和 `doc/` 替代。 + +## 已通过验证 + +- [x] `dart format --output=none --set-exit-if-changed bin lib test` +- [x] `dart analyze`:无问题 +- [x] `dart test`:24 项全部通过 +- [x] `dart run bin/flutterguard.dart config check example` +- [x] 示例扫描:3 个文件、0 个问题、`--fail-on high` 通过 +- [x] `rules --format json` 输出 16 个规则 +- [x] 原生 executable 编译并输出 `flutterguard 0.7.0` +- [x] `git diff --check` +- [x] 干净临时副本 `dart pub publish --dry-run` + - 退出码 0; + - 0 warnings; + - 发布压缩包约 44 KB; + - 仅有已发布版本从 `0.1.0` 跳到当前版本的非阻塞 hint。 + +## 下一步:拆分剩余提交 + +按顺序执行,每批提交前后都确认 `git diff --cached --name-only`,不要混入下一批 +文件。 + +### 1. 根包与内核收敛 + +建议提交信息: + +```text +refactor: flatten and simplify the flutterguard CLI +``` + +范围: + +- 根 `pubspec.yaml`、`analysis_options.yaml`、`.gitignore`、`.pubignore`、 + `flutterguard.yaml`; +- 删除 `melos.yaml`; +- 删除旧 `packages/flutterguard_cli/**`,新增 `bin/**`、`lib/**`、`test/**`; +- 删除 `examples/scan_demo/**`,新增 `example/**`; +- 暂不包含 `.github/**`、`scripts/**`、根文档和 `doc/**`。 + +提交前门槛: + +```bash +dart format --output=none --set-exit-if-changed bin lib test +dart analyze +dart test +dart run bin/flutterguard.dart config check example +dart run bin/flutterguard.dart scan example --format json --no-color --fail-on high +git diff --cached --check +``` + +### 2. CI 与发布脚本收敛 + +建议提交信息: + +```text +ci: align workflows with the root Dart package +``` + +范围: + +- `.github/workflows/flutterguard.yml`; +- `.github/workflows/release.yml`; +- `scripts/package_release.sh`、`scripts/package_release.ps1`; +- 删除 `scripts/compile.*`、`scripts/flutterguard-dev*`、`scripts/scan_ci.*`。 + +提交前门槛: + +```bash +bash -n scripts/package_release.sh +dart compile exe bin/flutterguard.dart -o /tmp/flutterguard-check +/tmp/flutterguard-check --version +git diff --cached --check +``` + +Windows PowerShell 包装脚本需要由 Windows workflow 做最终宿主验证。 + +### 3. 文档与检查点 + +建议提交信息: + +```text +docs: document the simplified v0.7 architecture +``` + +范围: + +- `README.md`、`CHANGELOG.md`、根 `AGENTS.md`; +- 删除旧 `docs/ARCHITECTURE.md`、`docs/FLUTTERGUARD_SPEC.md`; +- 新增 `doc/ARCHITECTURE.md`、`doc/FLUTTERGUARD_SPEC.md`; +- 本文件 `EVOLUTION_PLAN.md`。 + +提交前门槛: + +```bash +rg -n 'melos|packages/flutterguard_cli|examples/scan_demo|docs/' \ + README.md AGENTS.md doc bin lib test example .github scripts || true +git diff --cached --check +``` + +历史内容允许出现在 `CHANGELOG.md`,活跃说明中不得存在旧路径或已删除能力。 + +## 所有提交完成后的发布门槛 + +```bash +dart pub get +dart format --output=none --set-exit-if-changed bin lib test +dart analyze +dart test +dart run bin/flutterguard.dart rules --format json +dart run bin/flutterguard.dart config check . +dart run bin/flutterguard.dart config check example +dart run bin/flutterguard.dart scan example \ + --format json --output .flutterguard/ci --fail-on high --no-color +dart compile exe bin/flutterguard.dart -o /tmp/flutterguard-0.7.0 +/tmp/flutterguard-0.7.0 --version +dart pub publish --dry-run +git diff --check +git status --short +``` + +只有在工作区干净时,根目录 publish dry-run 的 Git 状态检查才代表最终发布结果。 +工作区尚未提交时,使用无 `.git` 元数据的干净临时副本验证包内容。 + +## 后续产品演进 + +完成当前三批提交和发布门槛后,再进入规则质量阶段: + +1. 为 `OverlayEntry` 增加 `remove()` / `dispose()` 生命周期安全检测; +2. 将仍依赖字符串的方法/类型识别逐步升级为 AST 和可解析类型语义; +3. 为新增规则先锁定所有权、低误报边界、changed-only 行为和抑制契约; +4. 每个规则必须有 positive、negative、disabled、suppression 和报告契约覆盖; +5. 保持一次扫描只读/解析一次源文件,并复用 `SourceWorkspace`; +6. 保持 layer/module/cycle 共享项目 import graph。 + +## 明确不做 + +- 不恢复 runtime SDK、APM、云服务、Flutter Widget 或动态插件系统; +- 不恢复 Melos、多包发布结构或公共 Dart scanner API; +- 不恢复 score、priority、confidence 或 JSON 兼容别名; +- 不恢复 generic size、missing const、依赖版本或 broker 配置规则; +- 不为降低误报而恢复每条规则的宽泛 allowlist; +- 不在当前收敛提交中夹带新的规则功能。 + +## 后续会话启动协议 + +```bash +git log -3 --oneline --decorate +git status --short +dart analyze +dart test +``` + +然后读取本文件“下一步”部分,从第一个未完成提交开始。每完成一个阶段,更新 +front matter 的 `head`、`checkpoint_state`,勾选对应项目,并在“已通过验证”中 +记录最新实际结果。 diff --git a/README.md b/README.md index a77e4cf..9cf97f1 100644 --- a/README.md +++ b/README.md @@ -1,256 +1,60 @@ # FlutterGuard -> IoT Flutter project static analysis CLI for architecture enforcement, code quality, and CI gating. - -[English](README.md) | [中文](README.zh.md) - -FlutterGuard scans Flutter/Dart source code and reports architecture boundary breaches, lifecycle/resource leaks, dependency cycles, and size-related code quality issues. The active path is `packages/flutterguard_cli/`; the legacy runtime-tracing packages are archived under `archive/`. - -**Platforms**: macOS, Windows, Linux — pure Dart CLI, no native dependencies. - -**Docs**: [Usage Guide](docs/USAGE.md) | [Configuration Strategy](CONFIGURATION_STRATEGY.md) | [Windows Assessment](docs/WINDOWS_ASSESSMENT.md) | [Spec](docs/FLUTTERGUARD_SPEC.md) | [Architecture](docs/ARCHITECTURE.md) - -## What It Is - -- A CLI for static analysis of Flutter/Dart projects -- A YAML-driven architecture enforcement tool -- An IoT/smart-home aware rule set for Flutter codebases -- A CI gate that can fail builds on severity thresholds or score thresholds - -## What It Is Not - -- Not a runtime observability or APM SDK -- Not a crash reporter -- Not a general-purpose Dart linter -- Not a web dashboard or Flutter widget library -- Does not require an API key and does not upload APKs - -## Requirements - -- Dart SDK 3.11.5 or newer -- `melos` for workspace bootstrap when running from source -- Supported OS: macOS, Windows, Linux - ---- +FlutterGuard is an executable-only static analysis CLI for IoT and smart-home +Flutter projects. It checks architecture boundaries, lifecycle/resource safety, +IoT transport security, and state-management maintainability. It does not add a +runtime SDK to the scanned application. ## Install -### Option A: pub.dev install (recommended) - -
-macOS / Linux - ```bash dart pub global activate flutterguard_cli - -# Verify flutterguard --version ``` -Ensure `$HOME/.pub-cache/bin` is on your `PATH`: - -```bash -export PATH="$PATH:$HOME/.pub-cache/bin" # add to ~/.zshrc or ~/.bashrc -``` -
- -
-Windows (PowerShell) - -```powershell -dart pub global activate flutterguard_cli - -# Verify -flutterguard --version -``` - -If the command is not found, ensure `%USERPROFILE%\AppData\Local\Pub\Cache\bin` is on your `PATH`: - -```powershell -$env:Path += ";$env:USERPROFILE\AppData\Local\Pub\Cache\bin" -``` -
- -### Option B: GitHub Release binary (no Dart SDK at runtime) - -Download the matching binary from the GitHub Releases page, then run it -directly: - -
-macOS / Linux - -```bash -chmod +x flutterguard -./flutterguard --version -./flutterguard scan . -``` -
- -
-Windows (PowerShell) - -```powershell -.\flutterguard.exe --version -.\flutterguard.exe scan . -``` -
- -### Option C: source checkout for development - -Use the local launcher when you want to run the current checkout without -installing or replacing the global `flutterguard` command. - -
-macOS / Linux +From a source checkout: ```bash -git clone https://github.com/lizy-coding/flutterguard.git -cd flutterguard dart pub get -./scripts/flutterguard-dev --version -./scripts/flutterguard-dev scan . +dart run bin/flutterguard.dart scan example ``` -
-
-Windows (PowerShell) +## Commands -```powershell -git clone https://github.com/lizy-coding/flutterguard.git -cd flutterguard -dart pub get -.\scripts\flutterguard-dev.ps1 --version -.\scripts\flutterguard-dev.ps1 scan . +```text +flutterguard scan [path] [options] +flutterguard baseline create [path] +flutterguard config init [path] +flutterguard config check [path] +flutterguard rules [rule-id] [--format table|json] ``` -
- ---- - -## Quick Start - -```bash -# Scan the current directory -flutterguard scan - -# Create and validate a starter config -flutterguard init --profile migration -flutterguard config doctor -flutterguard doctor install - -# Inspect the merged effective config -flutterguard config print -# Scan a specific project -flutterguard scan ./my_flutter_app # macOS / Linux -flutterguard scan .\my_flutter_app # Windows +The core scan options are: -# Scan with explicit path flag -flutterguard scan -p /path/to/project # macOS / Linux -flutterguard scan -p D:\path\to\project # Windows +- `--config`, `-c`: explicit configuration file. +- `--format`, `-f`: `table`, `json`, or `sarif`. +- `--output`, `-o`: report directory; default `.flutterguard`. +- `--fail-on`: `none`, `high`, `medium`, or `low`. +- `--changed-only --base `: scan Git-changed Dart files. +- `--baseline `: hide findings already recorded in a baseline. +- `--verbose`: include diagnostics, detail, and evidence. +- `--no-color`: disable ANSI terminal color. -# JSON output with CI gate -flutterguard scan . --format json --fail-on high - -# Baseline existing issues before enabling a hard CI gate -flutterguard baseline create . -flutterguard baseline stats -flutterguard baseline check . --baseline .flutterguard/baseline.json --no-growth -flutterguard scan . --baseline .flutterguard/baseline.json --fail-on high +Exit code `0` means the scan completed and the gate passed. Exit code `1` means +the severity gate failed. Exit code `2` means the command or scan setup was +invalid. -# GitHub Code Scanning output -flutterguard scan . --format sarif --baseline .flutterguard/baseline.json - -# Export one finding for false-positive feedback -flutterguard issue export --rule mqtt_connection --file lib/device/mqtt.dart --line 42 - -# Show help -flutterguard --help -flutterguard scan --help -``` +## Configuration -### Demo target +Configuration is optional. Generate a complete starter file from the rule +registry: ```bash -flutterguard scan examples/scan_demo +flutterguard config init . +flutterguard config check . ``` ---- - -## CLI Reference - -Commands: - -| Command | Description | -|---------|-------------| -| `flutterguard scan []` | Scan a project (path defaults to current directory) | -| `flutterguard baseline create []` | Create a baseline JSON file for existing issues | -| `flutterguard baseline stats` | Show baseline fingerprint counts | -| `flutterguard baseline prune []` | Remove fixed issues from a baseline | -| `flutterguard baseline check [] --no-growth` | Fail when current issues are missing from baseline | -| `flutterguard doctor install` | Diagnose executable version and PATH conflicts | -| `flutterguard init` | Create a starter `flutterguard.yaml` | -| `flutterguard init --profile migration` | Create a starter config from a profile | -| `flutterguard init --with-architecture` | Create config with architecture layer/module templates | -| `flutterguard config print` | Print the merged effective configuration | -| `flutterguard config doctor` | Validate config, globs, and architecture references | -| `flutterguard issue export` | Export one issue as a local feedback JSON bundle | -| `flutterguard rules` | List available rules | -| `flutterguard explain ` | Explain one rule | -| `flutterguard --help` / `-h` | Show usage | -| `flutterguard --version` / `-V` | Show version | - -### Scan options - -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `` | — | `.` | Positional project path (optional, before options) | -| `--path` | `-p` | `.` | Project path to scan (overridden by positional ``) | -| `--config` | `-c` | `flutterguard.yaml` | Config file path | -| `--format` | `-f` | `table` | Output format: `table`, `json`, or `sarif` | -| `--output` | `-o` | `.flutterguard` | Output directory for reports | -| `--verbose` | `-v` | off | Show detailed output with code context | -| `--no-color` | — | off | Disable ANSI terminal colors | -| `--changed-only` | — | off | Only scan Dart files changed since `--base` | -| `--base` | — | `main` | Git base ref for `--changed-only` | -| `--baseline` | — | unset | Baseline JSON file used to hide existing issues | -| `--fail-on` | — | `none` | CI gate: `none` / `high` / `medium` / `low` | -| `--min-score` | — | unset | Minimum score threshold 0–100 | -| `--help` | `-h` | — | Show scan usage | - -### Exit codes - -| Code | Meaning | -|------|---------| -| `0` | Success, including help/version and a changed-only scan with no relevant changes | -| `1` | CI gate failed (issues at/above `--fail-on` level, or score below `--min-score`) | -| `2` | Scan setup error (bad path, missing explicit config, invalid config, or no configured Dart files) | - -### Path resolution - -FlutterGuard auto-discovers the project root by walking up from the current directory, looking for `flutterguard.yaml`, `pubspec.yaml`, or a `lib/` directory. If none are found, it falls back to the current directory. - -The `--config` path is resolved against the target project: -1. Absolute paths are used as-is and must exist. -2. Relative paths are resolved from the target project root, never from CWD. -3. An omitted default `flutterguard.yaml` uses built-in defaults; any explicitly selected config must exist. - ---- - -## Configuration - -Create `flutterguard.yaml` in your project root. - -Recommended strategy: - -1. Start with zero config: `flutterguard scan`. -2. Run `flutterguard init` when you need custom thresholds or excludes. -3. Use `flutterguard config print` to inspect merged defaults. -4. Use `flutterguard config doctor` before enabling CI gates. -5. Add architecture layers/modules only after project boundaries are agreed. - -For the full decision model, see [Configuration Strategy](CONFIGURATION_STRATEGY.md). - -### Basic config (for most users) +Minimal example: ```yaml include: @@ -259,60 +63,18 @@ include: exclude: - lib/generated/** - lib/**.g.dart - - lib/**.freezed.dart - - lib/**.mocks.dart rules: - large_file: - enabled: true - maxLines: 500 - large_class: - enabled: true - maxLines: 300 - large_build_method: - enabled: true - maxLines: 80 - lifecycle_resource: - enabled: true - missing_const_constructor: - enabled: true - device_lifecycle: - enabled: true - mqtt_connection: - enabled: true - ble_scanning: - enabled: true - maxScanDurationMs: 10000 iot_security: - enabled: true - requireTls: true - pubspec_security: - enabled: true - side_effect_in_build: enabled: true severity: high - allowlist: [] - ignore_paths: [] - riverpod_read_used_for_render: + requireTls: true + ble_scanning: enabled: true severity: medium -state_management: - enabled: true - framework_auto_detect: true - confidence_threshold: certain -``` - -All ten state-management rules accept `enabled`, `severity`, `allowlist`, and -project-relative POSIX `ignore_paths`. The abbreviated example above shows the -shared shape; `flutterguard config print` prints every rule and effective value. - -### Full config (with architecture enforcement) - -```yaml -# ... include/exclude/rules from basic config above ... - architecture: + detect_cycles: true layers: - name: presentation path: lib/presentation/** @@ -320,339 +82,86 @@ architecture: - name: domain path: lib/domain/** allowed_deps: [core] - - name: data - path: lib/data/** - allowed_deps: [domain, core] - name: core path: lib/core/** allowed_deps: [] - - modules: - - name: device_mqtt - path: lib/device/mqtt/** - allowed_deps: [domain, core] - - name: device_ble - path: lib/device/ble/** - allowed_deps: [domain, core] - - detect_cycles: true - layer_violation: - enabled: true - module_violation: - enabled: true + modules: [] ``` -> **Important**: Architecture rules (`layer_violation`, `module_violation`, `circular_dependency`) require explicit `architecture.layers`, `architecture.modules`, and/or `architecture.detect_cycles` declarations in your config. They do not auto-discover project boundaries. - -> **Glob patterns**: Always use forward slashes (`/`) in YAML config, even on Windows. Do not use backslashes. - ---- +Every rule accepts `enabled` and `severity`. Rule-specific scalar options are +listed by `flutterguard rules `. ## Rules -| Rule ID | Level | Domain | Priority | What it checks | Config required | -|---------|-------|--------|----------|----------------|-----------------| -| `large_file` | LOW | standards | P2 | File line count over `maxLines` | — | -| `large_class` | LOW | standards | P2 | Class body line count over `maxLines` | — | -| `large_build_method` | MEDIUM | performance | P1 | `build()` method line count over `maxLines` | — | -| `lifecycle_resource_not_disposed` | MEDIUM | performance | P1 | Undisposed StreamSubscription, Timer, AnimationController, TextEditingController, ScrollController, FocusNode, MqttClient, BluetoothDevice, StreamController | — | -| `missing_const_constructor` | LOW | standards | P2 | Widget classes missing a `const` constructor | — | -| `layer_violation` | HIGH | architecture | P0 | Importing across forbidden architecture layers | `architecture.layers` * | -| `module_violation` | HIGH | architecture | P0 | Importing across forbidden business modules | `architecture.modules` * | -| `circular_dependency` | MEDIUM | architecture | P1 | File-level import cycles | `architecture.detect_cycles` * | -| `device_lifecycle` | HIGH | architecture | P0 | Unbalanced init/teardown pairs (initState↔dispose, connect↔disconnect, etc.) | — | -| `mqtt_connection` | HIGH | architecture | P0 | MQTT connect/disconnect pairing, hardcoded broker URLs | — | -| `iot_security` | HIGH | architecture | P0 | Hardcoded credentials, cleartext MQTT/HTTP, insecure BLE | `rules.iot_security.requireTls` | -| `ble_scanning` | MEDIUM | architecture | P1 | BLE startScan/stopScan pairing, scan timeout | `rules.ble_scanning.maxScanDurationMs` | -| `pubspec_security` | MEDIUM | standards | P2 | Unbounded deps, deprecated packages, outdated IoT dependencies | — | -| `side_effect_in_build` | HIGH | performance | P0 | State/resource side effects during build | — | -| `state_manager_created_in_build` | HIGH | performance | P0 | Controllers/Blocs/Notifiers created during build | — | -| `mutable_state_exposed` | MEDIUM | architecture | P1 | Public mutable state and in-place state collection mutation | — | -| `state_layer_ui_dependency` | HIGH | architecture | P0 | State owners depending on BuildContext, Widget, navigation or theme APIs | — | -| `state_dependency_cycle` | HIGH | architecture | P0 | Cycles among providers, state owners and reachable services | — | -| `riverpod_read_used_for_render` | MEDIUM | performance | P1 | `ref.read` values flowing into render output | Riverpod import * | -| `riverpod_watch_in_callback` | MEDIUM | performance | P1 | `ref.watch` inside event/async callbacks | Riverpod import * | -| `bloc_equatable_props_incomplete` | MEDIUM | standards | P1 | Equatable final fields missing from `props` | Bloc + Equatable imports * | -| `provider_value_lifecycle_misuse` | MEDIUM | performance | P1 | Reversed Provider `.value`/`create` ownership | Provider/Bloc import * | -| `notify_listeners_in_loop` | MEDIUM | performance | P1 | `notifyListeners()` inside repeated loops | Provider/Bloc import * | - -* Architecture entries require explicit YAML. Framework imports are auto-detected by default; set `state_management.framework_auto_detect: false` to use AST-only matching. - ---- - -## Output - -### Terminal table (default) - -Colored terminal report grouped by domain. Shows overall score, file count, issue count, and per-issue detail. - -### JSON report - -`--format json` writes `.flutterguard/report.json` under the output directory. - -Example shape: - -```json -{ - "version": "1.0.0", - "generatedAt": "2026-06-09T12:00:00.000Z", - "projectPath": "/path/to/project", - "score": 85, - "summary": { - "total": 3, - "high": 1, - "medium": 1, - "low": 1, - "suppressed": 0, - "suppressedByBaseline": 0, - "byDomain": { - "architecture": { "high": 1, "medium": 0, "low": 0, "total": 1 } - } - }, - "issues": [] -} -``` - -### SARIF report +The registry currently contains 16 rule IDs: -`--format sarif` writes `.flutterguard/report.sarif` for GitHub Code Scanning. High, medium, and low map to SARIF `error`, `warning`, and `note`. +- Architecture: `layer_violation`, `module_violation`, + `circular_dependency`, `state_layer_ui_dependency`, + `state_dependency_cycle`. +- Lifecycle and performance: `lifecycle_resource_not_disposed`, + `side_effect_in_build`, `state_manager_created_in_build`, + `riverpod_read_used_for_render`, `riverpod_watch_in_callback`, + `provider_value_lifecycle_misuse`, `notify_listeners_in_loop`. +- IoT security: `ble_scanning`, `iot_security`. +- State standards: `mutable_state_exposed`, + `bloc_equatable_props_incomplete`. -### Suppression and baseline +Generic file-size, missing-const, dependency-version, and broker-configuration +checks were removed in 0.7.0. Dart lints, `dart pub outdated`, dependency +security tools, and application configuration are better owners. -Use source suppression for known false positives: +## Reports and CI -```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: +JSON uses schema version `2.0.0` and exposes one canonical field name per +concept: `ruleId`, `severity`, and `domain`. SARIF 2.1.0 is suitable for GitHub +Code Scanning. ```bash -flutterguard config doctor flutterguard baseline create . -flutterguard baseline check . --baseline .flutterguard/baseline.json --no-growth -flutterguard scan . --baseline .flutterguard/baseline.json --format json --fail-on high -``` - -## Scoring - -``` -score = max(0, 100 - high×10 - medium×4 - low×1) -``` - -| Score | Rating | -|-------|--------| -| 80–100 | Excellent | -| 50–79 | Needs review | -| 0–49 | Needs action | - ---- - -## CI Integration - -### GitHub Actions - -```yaml -name: FlutterGuard - -on: [push, pull_request] - -jobs: - scan: - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - uses: dart-lang/setup-dart@v1 - with: - sdk: 3.11.5 - - name: Install FlutterGuard - run: dart pub global activate flutterguard_cli - - name: Scan - run: flutterguard scan . --format json --baseline .flutterguard/baseline.json --fail-on high --min-score 80 -``` - -### GitHub Code Scanning - -```yaml -name: FlutterGuard SARIF - -on: [push, pull_request] - -jobs: - code-scanning: - runs-on: ubuntu-latest - permissions: - security-events: write - contents: read - steps: - - uses: actions/checkout@v4 - - uses: dart-lang/setup-dart@v1 - with: - sdk: 3.11.5 - - run: dart pub global activate flutterguard_cli - - run: flutterguard scan . --format sarif --baseline .flutterguard/baseline.json - - uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: .flutterguard/report.sarif -``` - -### GitLab CI - -```yaml -flutterguard: - image: dart:3.11.5 - script: - - dart pub global activate flutterguard_cli - - flutterguard scan . --format json --fail-on high --min-score 80 - artifacts: - paths: - - .flutterguard/report.json - when: always -``` - -### pre-commit hook - -```yaml -# .pre-commit-config.yaml -repos: - - repo: local - hooks: - - id: flutterguard - name: FlutterGuard scan - entry: flutterguard scan . --fail-on high - language: system - pass_filenames: false - always_run: true -``` - -### Local scripts - -
-macOS / Linux - -```bash -#!/usr/bin/env bash -# scan_ci.sh -if flutterguard scan . --format json --fail-on high --min-score 80; then - echo "All checks passed!" -else - status=$? - echo "FlutterGuard failed with exit code $status." - exit "$status" -fi -``` -
- -
-Windows (PowerShell) - -```powershell -# scan_ci.ps1 -$ErrorActionPreference = "Stop" -flutterguard scan . --format json --fail-on high --min-score 80 -$status = $LASTEXITCODE - -if ($status -eq 0) { - Write-Host "All checks passed!" -ForegroundColor Green -} else { - Write-Host "FlutterGuard failed with exit code $status." -ForegroundColor Red - exit $status -} -``` -
- ---- - -## Troubleshooting - -### Windows: ANSI colors show as raw escape codes - -Use **Windows Terminal** (built into Windows 10/11) instead of legacy `cmd.exe`. Alternatively, add `--no-color` to disable ANSI output: - -```powershell -flutterguard scan . --no-color +flutterguard scan . \ + --baseline .flutterguard/baseline.json \ + --format sarif \ + --fail-on high ``` -### Windows: "API key required" error +Suppression comments remain available for precise false positives: -This means the shell is resolving an old globally-installed binary instead of this repository's static-analysis CLI. Run the local binary directly: - -```powershell -.\flutterguard.exe scan . -``` - -Or reinstall: - -```powershell -dart pub global deactivate flutterguard_cli -dart pub global activate flutterguard_cli -``` - -### Windows: garbled Chinese output - -```powershell -# In PowerShell, set UTF-8 output encoding -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 -# Or use Windows Terminal (recommended) which defaults to UTF-8 +```dart +// flutterguard: ignore iot_security +final endpoint = loadLocalDevelopmentEndpoint(); ``` -### Glob patterns: always use forward slashes +## Repository layout -In `flutterguard.yaml`, use `/` for all path patterns regardless of platform: - -```yaml -# Correct -path: lib/presentation/** - -# Wrong (even on Windows) -path: lib\presentation\** +```text +bin/ executable entry point +lib/src/cli/ command parsers and handlers +lib/src/rules/ registry, rule definitions, and detectors +lib/src/ scan/config/report shared kernel +test/ contract and detector tests +example/ scan target used by CI +doc/ architecture and external contract +scripts/ native release packaging only ``` ---- - -## Repository Layout - -``` -flutterguard/ -├── packages/ -│ └── flutterguard_cli/ Active CLI implementation -├── archive/ Frozen legacy runtime-tracing packages -└── examples/ - └── scan_demo/ Demo scan target -``` +FlutterGuard is a single Dart package. There is no Melos workspace, runtime +SDK, plugin system, or public Dart scanner API. ## Development ```bash -# All platforms -git clone https://github.com/lizy-coding/flutterguard.git -cd flutterguard dart pub get -dart pub global activate melos -melos bootstrap - -# Common commands -dart run melos run analyze # Static analysis -dart run melos run test:cli # Run tests -dart compile exe packages/flutterguard_cli/bin/flutterguard.dart -o flutterguard +dart format --output=none --set-exit-if-changed bin lib test +dart analyze +dart test +dart run bin/flutterguard.dart scan example --format json --no-color +dart pub publish --dry-run ``` -## Further Reading - -| Document | Content | -|----------|---------| -| [docs/USAGE.md](docs/USAGE.md) | Full usage guide (all platforms) | -| [docs/WINDOWS_ASSESSMENT.md](docs/WINDOWS_ASSESSMENT.md) | Windows compatibility assessment | -| [docs/FLUTTERGUARD_SPEC.md](docs/FLUTTERGUARD_SPEC.md) | Technical specification | -| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Architecture overview | +See [doc/ARCHITECTURE.md](doc/ARCHITECTURE.md) for internal boundaries and +[doc/FLUTTERGUARD_SPEC.md](doc/FLUTTERGUARD_SPEC.md) for the external +contract. ## License -MIT +MIT. See [LICENSE](LICENSE). diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md new file mode 100644 index 0000000..ec48e9f --- /dev/null +++ b/doc/ARCHITECTURE.md @@ -0,0 +1,109 @@ +# FlutterGuard Architecture + +## Shape + +FlutterGuard is one publishable Dart package and one executable. The repository +root is the package root. + +```text +bin/flutterguard.dart parse and route four command families +lib/src/cli/ command parsers and I/O adapters +lib/src/scanner.dart scan orchestration +lib/src/config_loader.dart YAML to compact generic rule settings +lib/src/source_workspace.dart per-scan source/AST cache and diagnostics +lib/src/import_graph.dart project-local resolved import graph +lib/src/boundary_engine.dart shared dependency-boundary evaluator +lib/src/rules/registry.dart only rule metadata/default/execution registry +lib/src/rules/rule.dart RuleDefinition and RuleRegistration +lib/src/rules/*.dart detectors +lib/src/report_generator.dart table and JSON output +lib/src/sarif_report.dart SARIF output +test/ external contract and detector tests +example/ CI scan target +``` + +There is no workspace orchestrator, runtime instrumentation package, dynamic +plugin loader, generated registry, or public Dart scanner API. + +## Scan flow + +```text +CLI + └─ FlutterGuardScanner.scan + ├─ ProjectResolver + ├─ ScanConfig.fromFile + ├─ FileCollector + ├─ ScanContext + │ ├─ SourceWorkspace + │ └─ changed/all/target file sets + ├─ RuleRegistry.analyze + │ ├─ one shared ImportGraph when needed + │ └─ explicit RuleRegistration executors + ├─ SuppressionFilter + ├─ optional Baseline + └─ table / JSON / SARIF report +``` + +`ScanContext` is the only scanner-to-rule scope carrier. `SourceWorkspace` +reads and parses each source file at most once. Architecture checks share one +`ImportGraph`; layer and module rules share `BoundaryRule` and +`DependencyBoundaryEngine`. + +## Rule model + +`RuleRegistry.registrations` is the single source of truth. Every registration +contains: + +- one `RuleDefinition`: ID, domain, default severity, documentation, options; +- one executor receiving `ScanContext`, effective `RuleConfig`, and an optional + shared import graph. + +Configuration defaults and `flutterguard rules` output are derived from these +definitions. Do not add another catalog, metadata registry, reflection layer, +or barrel export. + +All rules receive common `enabled` and `severity` settings. Special settings +belong in `RuleDefinition.defaultOptions`; the current example is `requireTls`. + +## Detection ownership + +- `lifecycle_resource_not_disposed` owns resource close/cancel/dispose checks. +- `ble_scanning` owns scan timeout findings, not generic disconnect pairing. +- `iot_security` owns credentials and insecure transport findings. +- Dependency version and broker placement policy stay with ecosystem tools and + application configuration rather than FlutterGuard rules. +- `BoundaryRule` owns both layer and module dependency policy. +- Framework-specific state rules activate from source imports; there is no + global framework or confidence switch. + +This ownership prevents the same source pattern from being reported by several +rule families. + +## External boundaries + +The supported integration boundary is the CLI plus JSON/SARIF. Code under +`lib/src` is package-private and may change without a Dart API migration. + +Stable capabilities are: + +- positional project path and explicit `--config`; +- changed-only scanning using a verified Git base; +- inline suppression and baseline filtering; +- severity-based CI exit codes; +- JSON schema versioning and SARIF 2.1.0. + +Scoring, priority, confidence, compatibility aliases, profile presets, install +diagnostics, issue export, and baseline management subcommands are intentionally +outside the architecture. + +## Change rules + +When adding a detector: + +1. implement or extend one detector file; +2. add one `RuleRegistration` with a `RuleDefinition`; +3. add positive, negative, disabled, and output-contract tests where relevant; +4. update the external spec only when the CLI/config/report contract changes. + +Before delivery run `dart analyze`, `dart test`, a demo scan, and +`dart pub publish --dry-run` for release-affecting changes. diff --git a/doc/FLUTTERGUARD_SPEC.md b/doc/FLUTTERGUARD_SPEC.md new file mode 100644 index 0000000..e575da4 --- /dev/null +++ b/doc/FLUTTERGUARD_SPEC.md @@ -0,0 +1,218 @@ +# FlutterGuard External Contract + +Version: 0.7 / JSON schema 2.0.0 + +This document defines supported user-facing behavior. Internal classes and file +layout are documented in `ARCHITECTURE.md` and are not compatibility contracts. + +## Identity and scope + +FlutterGuard is a local static analysis CLI for IoT and smart-home Flutter +projects. It detects architecture boundary violations, lifecycle/resource +risks, IoT transport/security problems, and state-management maintainability +problems. + +It is not a runtime SDK, observability agent, crash reporter, hosted service, +network proxy, widget library, or general formatting/style linter. + +## Commands + +```text +flutterguard scan [path] [options] +flutterguard baseline create [path] [options] +flutterguard config init [path] [options] +flutterguard config check [path] [options] +flutterguard rules [rule-id] [--format table|json] +``` + +Global options are `--help` and `--version`. + +### Scan options + +| Option | Contract | +|---|---| +| `--config`, `-c` | Explicit config; missing explicit files are errors | +| `--format`, `-f` | `table`, `json`, or `sarif`; default `table` | +| `--output`, `-o` | Report directory; default `.flutterguard` | +| `--verbose`, `-v` | Print diagnostics, detail, and evidence | +| `--no-color` | Disable ANSI colors | +| `--fail-on` | `none`, `high`, `medium`, or `low` | +| `--changed-only` | Restrict source rules to Git-changed Dart files | +| `--base` | Verified Git base ref; default `main` | +| `--baseline` | Hide findings present in a baseline file | + +### Exit codes + +| Code | Meaning | +|---|---| +| `0` | Command succeeded and the severity gate passed | +| `1` | Severity gate or config check failed | +| `2` | Invalid arguments, project, config, baseline, or Git base | + +## Project and files + +Project discovery walks upward from the requested path and recognizes +`flutterguard.yaml`, `pubspec.yaml`, or `lib/`. Default source selection is: + +```yaml +include: [lib/**] +exclude: + - lib/generated/** + - lib/**.g.dart + - lib/**.freezed.dart + - lib/**.mocks.dart +``` + +An ordinary full scan with zero matched Dart files is an error. A valid +changed-only scan with zero changed Dart files succeeds and writes an empty +requested report. + +## Configuration + +Top-level keys are `include`, `exclude`, `rules`, and `architecture`. + +```yaml +rules: + : + enabled: true + severity: high + : + +architecture: + detect_cycles: false + layers: [] + modules: [] +``` + +Rule severity is `low`, `medium`, or `high`. Unknown rule IDs and unknown +rule-specific options are errors in `config check`. Run `config init` to +generate current definitions and defaults. + +A boundary entry is: + +```yaml +- name: presentation + path: lib/presentation/** + allowed_deps: [domain, core] +``` + +Layer and module enforcement is inactive when its boundary list is empty. +Circular import detection requires `architecture.detect_cycles: true` and is +skipped in changed-only mode. + +## Rule inventory + +The registry contains 16 IDs: + +```text +ble_scanning +bloc_equatable_props_incomplete +circular_dependency +iot_security +layer_violation +lifecycle_resource_not_disposed +module_violation +mutable_state_exposed +notify_listeners_in_loop +provider_value_lifecycle_misuse +riverpod_read_used_for_render +riverpod_watch_in_callback +side_effect_in_build +state_dependency_cycle +state_layer_ui_dependency +state_manager_created_in_build +``` + +`rules [rule-id]` is the authoritative description of purpose, default +severity, framework, options, example, and remediation. + +## Findings + +The internal finding model exposes one canonical JSON representation: + +```json +{ + "ruleId": "iot_security", + "title": "IoT 安全风险", + "file": "/project/lib/device.dart", + "line": 10, + "severity": "high", + "domain": "architecture", + "message": "...", + "detail": "...", + "suggestion": "...", + "metadata": {}, + "framework": "generic", + "evidence": [] +} +``` + +There are no duplicate `id`/`ruleId` or `level`/`severity` fields, and no +priority, score, or confidence fields. + +## JSON report + +JSON reports use schema version `2.0.0`: + +```json +{ + "schemaVersion": "2.0.0", + "projectPath": "/project", + "scanMode": "full", + "summary": { + "total": 1, + "high": 1, + "medium": 0, + "low": 0, + "suppressed": 0, + "suppressedByBaseline": 0, + "diagnostics": 0, + "byDomain": {} + }, + "issues": [], + "diagnostics": [] +} +``` + +SARIF reports conform to SARIF 2.1.0 and include registry definitions and +finding locations. + +## Suppression and baseline + +Supported comments are same-line and immediately preceding-line forms: + +```dart +// flutterguard: ignore iot_security +final endpoint = localEndpoint; +``` + +Multiple IDs may be comma-separated; `ignore all` suppresses all findings at +the target line. + +`baseline create` stores stable fingerprints derived from rule ID, normalized +path, line, and message. A scan with `--baseline` hides matching findings after +inline suppression. + +## Changed-only behavior + +Changed-only mode verifies `^{commit}`, collects tracked changes and +untracked files, and filters them to configured Dart sources. Unchanged project +files remain available for import and state-graph resolution. Project-level +pubspec checks run only when `pubspec.yaml` changed. State dependency cycles +use the full graph and report cycles touching a changed source. + +## Release gates + +A release is valid only when all of the following pass from the repository +root: + +```bash +dart pub get +dart analyze +dart test +dart run bin/flutterguard.dart scan example --format json --no-color +dart pub publish --dry-run +``` + +Native release artifacts are produced by `scripts/package_release.sh` and +`scripts/package_release.ps1` on their respective operating systems. diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..fda363e --- /dev/null +++ b/example/README.md @@ -0,0 +1,11 @@ +# Scan example + +This small Dart target is scanned by CI with the current checkout. + +```bash +dart run bin/flutterguard.dart scan example --no-color +dart run bin/flutterguard.dart scan example --format json --output .flutterguard/demo +``` + +Its configuration demonstrates the IoT lifecycle/security options without +depending on Flutter or the FlutterGuard package. diff --git a/example/flutterguard.yaml b/example/flutterguard.yaml new file mode 100644 index 0000000..4a498fd --- /dev/null +++ b/example/flutterguard.yaml @@ -0,0 +1,19 @@ +include: + - lib/** + +rules: + lifecycle_resource_not_disposed: + enabled: true + severity: medium + ble_scanning: + enabled: true + severity: medium + iot_security: + enabled: true + severity: high + requireTls: true + +architecture: + layers: [] + modules: [] + detect_cycles: false diff --git a/example/lib/main.dart b/example/lib/main.dart new file mode 100644 index 0000000..a4ceba3 --- /dev/null +++ b/example/lib/main.dart @@ -0,0 +1,14 @@ +class UserManager { + final List _users = []; + + void addUser(String name) { + _users.add(name); + } + + String? findUser(String name) { + for (final user in _users) { + if (user == name) return user; + } + return null; + } +} diff --git a/example/lib/services/user_service.dart b/example/lib/services/user_service.dart new file mode 100644 index 0000000..9149f18 --- /dev/null +++ b/example/lib/services/user_service.dart @@ -0,0 +1,51 @@ +import '../widgets/profile_card.dart'; + +class UserService { + final List _cache = []; + + Future fetchUser(String id) async { + await Future.delayed(const Duration(milliseconds: 100)); + return _cache.firstWhere( + (u) => u.displayName.contains(id), + orElse: () => _cache.first, + ); + } + + Future syncUsers() async { + for (var i = 0; i < 10; i++) { + await Future.delayed(const Duration(milliseconds: 50)); + _cache.add(_createMockUser(i)); + } + } + + ProfileCard _createMockUser(int index) { + return ProfileCard( + name: 'User $index', + email: 'user$index@example.com', + age: 20 + index, + bio: 'Bio for user $index', + avatarUrl: 'https://example.com/avatars/$index.png', + location: 'Location $index', + website: 'https://user$index.example.com', + company: 'Company $index', + role: 'Role $index', + department: 'Department $index', + phone: '555-${index.toString().padLeft(4, '0')}', + address: '${100 + index} Main St', + city: 'City $index', + country: 'Country $index', + postalCode: '${10000 + index}', + timezone: 'UTC${index > 5 ? "+$index" : "-$index"}', + language: 'en', + theme: 'light', + notificationsEnabled: true, + emailVerified: index > 0, + twoFactorEnabled: index > 5, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + lastLoginAt: DateTime.now(), + tags: ['tag$index'], + metadata: {'index': index, 'source': 'mock'}, + ); + } +} diff --git a/example/lib/widgets/profile_card.dart b/example/lib/widgets/profile_card.dart new file mode 100644 index 0000000..9fa1655 --- /dev/null +++ b/example/lib/widgets/profile_card.dart @@ -0,0 +1,62 @@ +class ProfileCard { + String name; + String email; + int age; + String bio; + String avatarUrl; + String location; + String website; + String company; + String role; + String department; + String phone; + String address; + String city; + String country; + String postalCode; + String timezone; + String language; + String theme; + bool notificationsEnabled; + bool emailVerified; + bool twoFactorEnabled; + DateTime createdAt; + DateTime updatedAt; + DateTime lastLoginAt; + List tags; + Map metadata; + + ProfileCard({ + required this.name, + required this.email, + required this.age, + required this.bio, + required this.avatarUrl, + required this.location, + required this.website, + required this.company, + required this.role, + required this.department, + required this.phone, + required this.address, + required this.city, + required this.country, + required this.postalCode, + required this.timezone, + required this.language, + required this.theme, + required this.notificationsEnabled, + required this.emailVerified, + required this.twoFactorEnabled, + required this.createdAt, + required this.updatedAt, + required this.lastLoginAt, + required this.tags, + required this.metadata, + }); + + String get displayName => '$name ($role, $department)'; + + bool get isActive => + lastLoginAt.isAfter(DateTime.now().subtract(const Duration(days: 30))); +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml new file mode 100644 index 0000000..7fdf4c1 --- /dev/null +++ b/example/pubspec.yaml @@ -0,0 +1,6 @@ +name: scan_demo +description: Target project for flutterguard scan demonstration. +publish_to: none + +environment: + sdk: ">=3.3.0 <4.0.0" diff --git a/scripts/package_release.ps1 b/scripts/package_release.ps1 index 24a038f..9891da9 100644 --- a/scripts/package_release.ps1 +++ b/scripts/package_release.ps1 @@ -1,10 +1,10 @@ $ErrorActionPreference = "Stop" $rootDir = Split-Path -Parent $PSScriptRoot -$pubspec = Join-Path $rootDir "packages\flutterguard_cli\pubspec.yaml" +$pubspec = Join-Path $rootDir "pubspec.yaml" $versionLine = Select-String -Path $pubspec -Pattern "^version:" | Select-Object -First 1 $version = ($versionLine.Line -split "\s+")[1] -$src = Join-Path $rootDir "packages\flutterguard_cli\bin\flutterguard.dart" +$src = Join-Path $rootDir "bin\flutterguard.dart" $dist = Join-Path $rootDir "dist" $name = "flutterguard-$version-windows-x64" $outDir = Join-Path $dist $name diff --git a/scripts/package_release.sh b/scripts/package_release.sh index ac0caf7..289b7ed 100755 --- a/scripts/package_release.sh +++ b/scripts/package_release.sh @@ -2,8 +2,8 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -VERSION="$(grep '^version:' "$ROOT_DIR/packages/flutterguard_cli/pubspec.yaml" | awk '{print $2}')" -SRC="$ROOT_DIR/packages/flutterguard_cli/bin/flutterguard.dart" +VERSION="$(grep '^version:' "$ROOT_DIR/pubspec.yaml" | awk '{print $2}')" +SRC="$ROOT_DIR/bin/flutterguard.dart" DIST="$ROOT_DIR/dist" case "$(uname -s)" in From 3f8f0107a89b89505fb0034f05c9fece3e469004 Mon Sep 17 00:00:00 2001 From: lizy Date: Sun, 19 Jul 2026 12:14:00 +0800 Subject: [PATCH 15/16] =?UTF-8?q?chore:=20=E4=BB=8E=20.gitignore=20?= =?UTF-8?q?=E4=B8=AD=E7=A7=BB=E9=99=A4=20CONTEXT.md=EF=BC=8Cpub=20?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E6=97=B6=E7=94=B1=20.pubignore=20=E6=8E=92?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 1302ffc..61797ca 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,4 @@ doc/api/ # Coverage .coverage -# Project evolution spec -# Keep docs tracked: README and the published package link to these files. -CONTEXT.md + From 9f9be84a73dc4b99a956a8529b8c334849566b03 Mon Sep 17 00:00:00 2001 From: lizy Date: Wed, 22 Jul 2026 08:38:41 +0800 Subject: [PATCH 16/16] chore: clean up session artifacts and upgrade rule detection quality - Remove accidental session artifact markdown files (310KB each) - Add session artifacts to .gitignore and .pubignore - Add OverlayEntry to lifecycle resource detection - Upgrade iot_security from line-based regex to AST visitor traversal - Upgrade ble_scanning timeout check to parameter/NamedExpression inspection - Add negative, disabled, suppression, and report-contract tests (34 total) - Add release_preflight.sh and wire into release workflow - Add doc/EVOLUTION_ROADMAP.md - Bump version to 0.7.1 --- .github/workflows/release.yml | 5 +- .gitignore | 6 + .pubignore | 4 + CHANGELOG.md | 12 + EVOLUTION_PLAN.md | 20 +- README.md | 14 + doc/EVOLUTION_ROADMAP.md | 215 ++++++++++++ lib/src/rules/ble_scanning.dart | 160 ++++++--- lib/src/rules/iot_security.dart | 368 ++++++++++++-------- lib/src/rules/lifecycle_resource.dart | 3 +- pubspec.yaml | 2 +- scripts/release_preflight.sh | 91 +++++ test/fixtures/ble_scanning_negative.dart | 20 ++ test/fixtures/ble_scanning_suppression.dart | 14 + test/fixtures/iot_security_negative.dart | 15 + test/fixtures/iot_security_suppression.dart | 16 + test/fixtures/lifecycle_issue.dart | 5 + test/fixtures/lifecycle_negative.dart | 25 ++ test/rules_test.dart | 101 +++++- 19 files changed, 892 insertions(+), 204 deletions(-) create mode 100644 doc/EVOLUTION_ROADMAP.md create mode 100644 scripts/release_preflight.sh create mode 100644 test/fixtures/ble_scanning_negative.dart create mode 100644 test/fixtures/ble_scanning_suppression.dart create mode 100644 test/fixtures/iot_security_negative.dart create mode 100644 test/fixtures/iot_security_suppression.dart create mode 100644 test/fixtures/lifecycle_negative.dart diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a51dea9..d704bed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,10 +16,7 @@ jobs: - uses: dart-lang/setup-dart@v1 with: sdk: "3.11.5" - - run: dart pub get - - run: dart analyze - - run: dart test - - run: dart pub publish --server https://pub.dev --dry-run + - run: bash scripts/release_preflight.sh build: needs: validate diff --git a/.gitignore b/.gitignore index 61797ca..10c884e 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,10 @@ doc/api/ # Coverage .coverage +# Session artifacts +/flutterguardb.md +/session-ses_*.md + +# Duplicate local maintenance transcript; keep flutterguardb.md as canonical. +/session-ses_07b1.md diff --git a/.pubignore b/.pubignore index 4d21799..abe1c37 100644 --- a/.pubignore +++ b/.pubignore @@ -13,3 +13,7 @@ dist/ /flutterguard.exe *.iml CONTEXT.md +/flutterguardb.md +/session-ses_*.md +/flutterguardb.md +/session-ses_07b1.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ac1a8ef..ebb9283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.7.1 (2026-07-22) + +- **rule:** Added OverlayEntry to lifecycle resource detection. +- **rule:** Upgraded `iot_security` from line-based regex to AST visitor + traversal (`VariableDeclaration`, `NamedExpression`, `StringLiteral` nodes). +- **rule:** Upgraded `ble_scanning` timeout detection from string matching to + parameter inspection and `NamedExpression` AST traversal. +- **test:** Added negative, disabled, suppression, and report-contract coverage + for `iot_security`, `ble_scanning`, and `lifecycle_resource` rules (34 tests). +- **ci:** Added `scripts/release_preflight.sh` for compact release validation + and wired it into the release workflow. + ## 0.7.0 (2026-07-17) ### Breaking simplification diff --git a/EVOLUTION_PLAN.md b/EVOLUTION_PLAN.md index b9b7177..acb3fa5 100644 --- a/EVOLUTION_PLAN.md +++ b/EVOLUTION_PLAN.md @@ -1,13 +1,13 @@ --- -checkpoint_date: 2026-07-17 +checkpoint_date: 2026-07-21 timezone: Asia/Shanghai branch: develop base: origin/develop -head: 3aa32bb +head: 3f8f010 target_version: 0.7.0 target_json_schema: 2.0.0 target_rule_count: 16 -checkpoint_state: implementation_complete_commits_pending +checkpoint_state: convergence_committed_quality_phase2_implemented --- # FlutterGuard 演进路线与续跑检查点 @@ -67,8 +67,8 @@ baseline 和抑制注释等用户可见能力。 - 70 个未跟踪新文件; - 合计 180 项; - 暂存区为空; -- 生产 Dart 代码约 4,836 行; -- 测试 Dart 代码约 918 行。 +- 生产 Dart 代码约 4,974 行; +- 测试 Dart 代码约 1,365 行; Git 将根目录迁移暂时显示为旧路径删除加新路径未跟踪。不要单独恢复 `packages/flutterguard_cli/`、`examples/scan_demo/` 或 `docs/`;它们分别由 @@ -78,7 +78,7 @@ Git 将根目录迁移暂时显示为旧路径删除加新路径未跟踪。不 - [x] `dart format --output=none --set-exit-if-changed bin lib test` - [x] `dart analyze`:无问题 -- [x] `dart test`:24 项全部通过 +- [x] `dart test`:34 项全部通过(含 10 项新增质量测试) - [x] `dart run bin/flutterguard.dart config check example` - [x] 示例扫描:3 个文件、0 个问题、`--fail-on high` 通过 - [x] `rules --format json` 输出 16 个规则 @@ -200,10 +200,10 @@ git status --short 完成当前三批提交和发布门槛后,再进入规则质量阶段: -1. 为 `OverlayEntry` 增加 `remove()` / `dispose()` 生命周期安全检测; -2. 将仍依赖字符串的方法/类型识别逐步升级为 AST 和可解析类型语义; -3. 为新增规则先锁定所有权、低误报边界、changed-only 行为和抑制契约; -4. 每个规则必须有 positive、negative、disabled、suppression 和报告契约覆盖; +1. ~~为 `OverlayEntry` 增加 `remove()` / `dispose()` 生命周期安全检测~~ (已完成 2026-07-21) +2. ~~将仍依赖字符串的方法/类型识别逐步升级为 AST 和可解析类型语义~~ (已完成 2026-07-21: iot_security 升级为 AST Visitor; ble_scanning timeout 升级为参数/命名参数检测) +3. ~~为新增规则先锁定所有权、低误报边界、changed-only 行为和抑制契约~~ (已完成 2026-07-21) +4. ~~每个规则必须有 positive、negative、disabled、suppression 和报告契约覆盖~~ (已完成 2026-07-21: iot_security, ble_scanning, lifecycle 均已补全五项覆盖) 5. 保持一次扫描只读/解析一次源文件,并复用 `SourceWorkspace`; 6. 保持 layer/module/cycle 共享项目 import graph。 diff --git a/README.md b/README.md index 9cf97f1..8a93c86 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,20 @@ dart run bin/flutterguard.dart scan example --format json --no-color dart pub publish --dry-run ``` +### Release preflight + +Before creating a release tag, run the compact preflight command: + +```bash +bash scripts/release_preflight.sh +``` + +It resolves dependencies, checks formatting, analysis, tests, and the CLI +example, then runs `dart pub publish --dry-run` without uploading anything. +It finishes by printing the required human checks for package contents, legal +redistribution, version/tag, and publisher access. A nonzero exit means at +least one automated release gate failed. + See [doc/ARCHITECTURE.md](doc/ARCHITECTURE.md) for internal boundaries and [doc/FLUTTERGUARD_SPEC.md](doc/FLUTTERGUARD_SPEC.md) for the external contract. diff --git a/doc/EVOLUTION_ROADMAP.md b/doc/EVOLUTION_ROADMAP.md new file mode 100644 index 0000000..33ed26c --- /dev/null +++ b/doc/EVOLUTION_ROADMAP.md @@ -0,0 +1,215 @@ +# FlutterGuard 演进路线图 + +本文档描述 FlutterGuard v0.7 之后的架构演进方向,按优先级分为四个阶段。 +当前收敛重构的进度跟踪请参考仓库根目录下的 `EVOLUTION_PLAN.md`。 + +--- + +## 架构速览 + +``` +bin/flutterguard.dart 唯一入口,路由四个命令族 +lib/src/scanner.dart 扫描编配 +lib/src/scan_context.dart 不可变 scan 作用域载体 +lib/src/source_workspace.dart 单次读取/解析 AST 缓存 +lib/src/import_graph.dart 共享项目导入图 +lib/src/boundary_engine.dart layer/module 共享边界引擎 +lib/src/config_loader.dart YAML → 泛型 RuleConfig +lib/src/report_generator.dart 表格/JSON 输出 +lib/src/sarif_report.dart SARIF 2.1.0 输出 +lib/src/rules/registry.dart 规则元数据/默认值/执行的唯一数据源 +lib/src/rules/*.dart 16 个检测器实现 +``` + +--- + +## 第一阶段:收尾当前收敛重构(短期,已计划) ✅ 已完成 (2026-07-17) + +参考 `EVOLUTION_PLAN.md` 中定义的 3 批提交: + +### 1.1 根包与内核收敛 + +``` +refactor: flatten and simplify the flutterguard CLI +``` + +- 提交根 `pubspec.yaml`、`analysis_options.yaml`、`.gitignore`、`.pubignore` +- 删除 `melos.yaml`,删除旧 `packages/flutterguard_cli/**` +- 新增 `bin/**`、`lib/**`、`test/**`、`example/**` + +### 1.2 CI 与发布脚本收敛 + +``` +ci: align workflows with the root Dart package +``` + +- 对齐 `.github/workflows/flutterguard.yml`、`release.yml` +- 更新 `scripts/package_release.sh`、`scripts/package_release.ps1` +- 删除废弃编译/扫描脚本 + +### 1.3 文档与检查点 + +``` +docs: document the simplified v0.7 architecture +``` + +- 定稿 `README.md`、`CHANGELOG.md`、`AGENTS.md` +- 定稿 `doc/ARCHITECTURE.md`、`doc/FLUTTERGUARD_SPEC.md` +- 提交本文件 `doc/EVOLUTION_ROADMAP.md` + +--- + +## 第二阶段:检测质量提升(短期,已计划) + +### 2.1 OverlayEntry 生命周期检测 ✅ 已完成 (2026-07-21) + +- ~~检测 `OverlayEntry.remove()` 和 `OverlayEntry.dispose()` 的调用遗漏~~ (已添加 `OverlayEntry` → `remove` 到 `lifecycle_resource.dart` 的 `_resourceTypes`) +- ~~归属到 `lifecycle_resource.dart`~~ + +### 2.2 AST 化字符串检测 ✅ 已完成 (2026-07-21) + +**问题**:`iot_security.dart` 和 `ble_scanning.dart` 当前使用正则和字符串匹配: + +| 文件 | 方法 | 脆弱点 | +|---|---|---| +| `iot_security.dart` | `_secretPattern` 正则 | 误匹配注释/字符串常量中的模式 | +| `iot_security.dart` | `_cleartextMqttPatterns` 正则 | URL 格式变更需同步 | +| `ble_scanning.dart` | `body.contains('timeout')` | 方法名重命名后漏检 | + +**改进**:升级为 `analyzer` 库的 AST 节点遍历,通过类型信息和解析引用语义进行检测: + +1. 密钥检测 → 遍历 `VariableDeclaration` / `AssignmentExpression`,检查赋值右侧是否为明文密钥模式,排除注释和文档字符串 +2. 明文 MQTT/HTTP 检测 → 解析 URI 字符串常量的 scheme,利用 `Uri.parse` +3. BLE timeout 检测 → 遍历 `MethodInvocation`,查找 `FlutterBluePlus.startScan` 或 `ScanMode` 相关调用及其 `timeout` 命名参数 + +### 2.3 新规则质量门槛 ✅ 已完成 (2026-07-21) + +每条规则已覆盖五种测试场景: + +| 场景 | 含义 | 覆盖情况 | +|---|---|---| +| positive | 预期触发发现的代码,确认检测生效 | `iot_security_issue.dart`, `ble_scanning_issue.dart`, `lifecycle_issue.dart` | +| negative | 不会触发的合法代码,确认无误报 | `iot_security_negative.dart`, `ble_scanning_negative.dart`, `lifecycle_negative.dart` | +| disabled | `enabled: false` 后不产出发现 | rules_test.dart 中 `_disabled` 测试 | +| suppression | 行内注释 `// flutterguard:ignore` 可抑制 | `iot_security_suppression.dart`, `ble_scanning_suppression.dart` | +| report-contract | 验证 JSON 输出中 `ruleId`/`severity`/`domain`/`message` 正确 | `iot_security` report contract test | + +--- + +## 第三阶段:架构内聚深化(中期) + +### 3.1 大文件拆分 + +当前三个文件包含多条规则,与 `ble_scanning.dart` / `iot_security.dart` 等"一文件一规则"的惯例不一致: + +| 当前文件 | 行数 | 应拆分为 | +|---|---|---| +| `generic_state_management.dart` | 449 | `side_effect_in_build.dart`、`state_manager_created_in_build.dart`、`mutable_state_exposed.dart`、`state_layer_ui_dependency.dart` | +| `provider_state_management.dart` | 326 | `provider_value_lifecycle_misuse.dart`、`notify_listeners_in_loop.dart` | +| `riverpod_state_management.dart` | 276 | `riverpod_read_used_for_render.dart`、`riverpod_watch_in_callback.dart` | + +拆分后共享逻辑保留在 `state_management_utils.dart` 中。 + +### 3.2 消除工具函数重复 + +| 重复项 | 位置 | 解决方案 | +|---|---|---| +| `sourceLine` vs `lineNumberForOffset` | `state_management_utils.dart:97` / `source_workspace.dart:10` | 统一使用 `SourceWorkspace` 的 `lineNumberForOffset`,移除 `state_management_utils.dart` 中的版本 | +| `_FirstOrNull` 扩展 | `provider_state_management.dart` | Dart 3.x 已内置 `firstOrNull`,直接移除自定义扩展 | +| BLE 资源类型列表 | `lifecycle_resource.dart`、`ble_scanning.dart`、`generic_state_management.dart` | 抽取共享类型常量到 `flutter_resource_types.dart` 或统一通过 AST 类型判断 | + +### 3.3 `notify_listeners_in_loop` 的 `_provablyShortFor` AST 化 + +当前使用正则判断 for 循环是否"显然很短": + +```dart +RegExp(r'= 0; [A-Za-z_$][\w$]* < 1;') +``` + +对格式化差异和变量命名敏感。应改为: + +- 获取 `ForStatement` 的 `forLoopParts`,解析初始化表达式和条件表达式 +- 若初始化将循环变量设为常量 0,且条件为 `变量 < 1`,则判定为短循环 +- 排除循环体内包含 `await` 或嵌套循环的情况 + +### 3.4 共享类型注册表 + +建立 `lib/src/rules/flutter_resource_types.dart`,集中声明需要追踪生命周期的 Flutter 资源类型: + +```dart +// 示例结构 +const _disposableTypes = { + 'StreamSubscription', 'Timer', 'AnimationController', + 'TextEditingController', 'ScrollController', + 'BluetoothDevice', 'BluetoothCharacteristic', + // ... +}; +``` + +供 `lifecycle_resource.dart`、`ble_scanning.dart`、`generic_state_management.dart` 统一引用。 + +--- + +## 第四阶段:能力扩展(中长期) + +### 4.1 IoT 专项规则扩展 + +| 规则方向 | 检测内容 | 复杂度 | +|---|---|---| +| Wi-Fi 配置安全 | 检测代码中硬编码的 Wi-Fi SSID/密码,建议使用设备 provisioning API | 中 | +| 固件 OTA 校验 | 检测 `http.get(url)` 下载固件但未验证签名/哈希的模式 | 高 | +| MQTT QoS 检测 | 检测 QoS 0 发布关键消息(命令/配置)的场景 | 中 | +| 蓝牙配对模式 | 检测 `createBond` / `JustWorks` 配对且无用户确认 | 中 | + +### 4.2 状态管理规则扩展 + +| 规则方向 | 检测内容 | 复杂度 | +|---|---|---| +| Cubit 泄漏 | 检测 `BlocProvider` 创建但未在 dispose 中 `close()` 的 Cubit | 中 | +| GetX 滥用 | 检测 `Get.to` 在 build 中调用、`Get.find` 在 initState 外使用 | 中 | +| MobX reaction 泄漏 | 检测未 dispose 的 `ReactionDisposer` | 中 | +| ValueNotifier dispose | 检测 `ValueNotifier` / `ChangeNotifier` 未 dispose | 低 | + +### 4.3 测试体系增强 + +- 核心算法补充单元测试: + - `TarjanSccFinder._stronglyConnectedComponents`(`state_dependency_cycle.dart`) + - `_reconstructCycle` BFS 最短环路径重建 + - `_shortestCycle` 全节点对最短环 + - `ImportGraph.build` 多文件解析图构建 +- 添加规则级别的 golden-file 测试,验证 JSON/SARIF 报告输出结构 + +### 4.4 跨文件数据流分析 + +当前所有规则均为单文件分析。对 `iot_security` 规则引入有限的跨文件追踪: + +- 追踪 `static const` 密钥常量从一个文件被导出/导入到使用点的路径 +- 检测通过 `export` 间接暴露的密钥 +- 限制追踪深度为 1 层导入(通过 `ImportGraph` 即可实现,无需完整的数据流引擎) + +--- + +## 架构约束(不可逾越的红线) + +以下设计决策在 v0.7 收敛中已确认,后续演进不得逆转: + +| 约束 | 说明 | +|---|---| +| 不恢复 Melos | 仓库根目录就是唯一的 Dart 包 | +| 不恢复 runtime SDK | 没有运行时 instrumentation、APM、云服务 | +| 不暴露公共 API | `lib/src` 是私有实现,集成边界仅 CLI + JSON/SARIF | +| 不添加冗余元数据 | 无 score/priority/confidence/兼容别名 | +| 不添加通用风格规则 | 无 generic-size、missing-const、格式化类规则 | +| 单一注册点 | `RuleRegistry.registrations` 是唯一的规则注册源 | +| 资源复用 | `SourceWorkspace` 和 `ImportGraph` 扫描生命周期内各构建一次 | +| 每个 finding 有唯一 owner | 同一代码模式不会被多个规则重复报告 | +| 框架自动检测 | 框架规则通过 import 检测激活,无全局配置开关 | + +--- + +## 相关文档 + +- 仓库根 `EVOLUTION_PLAN.md` — 当前收敛重构的续跑检查点和提交拆分 +- `doc/ARCHITECTURE.md` — 内部架构边界和 scan 流程 +- `doc/FLUTTERGUARD_SPEC.md` — 外部契约(CLI / JSON / SARIF) +- 根 `AGENTS.md` — 仓库级约束和命令入口 diff --git a/lib/src/rules/ble_scanning.dart b/lib/src/rules/ble_scanning.dart index cd9672f..4792210 100644 --- a/lib/src/rules/ble_scanning.dart +++ b/lib/src/rules/ble_scanning.dart @@ -1,4 +1,5 @@ import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; import 'package:analyzer/source/line_info.dart'; import '../config_loader.dart'; @@ -6,7 +7,10 @@ import 'rule.dart'; import '../source_workspace.dart'; import '../static_issue.dart'; -const _bleTypePatterns = ['Ble', 'BluetoothDevice', 'Bluetooth']; +const _bleTypeNames = {'Ble', 'BluetoothDevice', 'Bluetooth'}; + +bool _isBleType(String typeName) => + _bleTypeNames.any((t) => typeName.contains(t)); class BleScanningRule { final RuleConfig config; @@ -36,65 +40,86 @@ 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 hasBleField = cls.members.whereType().any((f) { + final type = f.fields.type?.toSource() ?? ''; + return _isBleType(type); + }); final hasBleRef = cls.members.whereType().any((m) { - final body = m.toString().toLowerCase(); - return _bleTypePatterns.any((t) => body.contains(t.toLowerCase())); + final visitor = _BleTypeUsageVisitor(); + m.accept(visitor); + return visitor.usesBleType; }); if (!hasBleField && !hasBleRef) continue; - final methods = cls.members.whereType().toList(); - _checkScanTimeout( - file, - startScanMethod: methods, - lineInfo: lineInfo, - issues: issues, - ); + for (final method in cls.members.whereType()) { + if (!_isStartScan(method.name.lexeme)) continue; + _checkScanTimeout(file, method, lineInfo, issues); + } } return issues; } + bool _isStartScan(String name) => + name == 'startScan' || name.endsWith('StartScan'); + void _checkScanTimeout( - String file, { - required List startScanMethod, - required LineInfo lineInfo, - required List issues, - }) { - for (final method in startScanMethod) { - if (method.name.lexeme != 'startScan') continue; - - final body = method.toString().toLowerCase(); - final hasTimeout = - body.contains('timeout') || - body.contains('duration') || - body.contains('maxscanduration'); - - if (!hasTimeout) { - final line = lineNumberForOffset(lineInfo, method.name.offset); - issues.add( - StaticIssue( - id: 'ble_scanning', - title: 'BLE 扫描缺少超时配置', - file: file, - line: line, - level: config.severity, - domain: IssueDomain.architecture, - message: 'startScan() 调用未配置超时参数', - detail: - '方法: ${method.name.lexeme}\n' - 'BLE 扫描应设置超时以限制扫描时间,避免过度耗电', - suggestion: '为 startScan() 添加明确的 timeout 或 duration 参数', - metadata: {'check': 'scan_without_timeout'}, - ), - ); + String file, + MethodDeclaration method, + LineInfo lineInfo, + List issues, + ) { + if (_hasTimeoutParameter(method)) return; + if (_hasTimeoutInBody(method)) return; + + final line = lineNumberForOffset(lineInfo, method.name.offset); + issues.add( + StaticIssue( + id: 'ble_scanning', + title: 'BLE 扫描缺少超时配置', + file: file, + line: line, + level: config.severity, + domain: IssueDomain.architecture, + message: '${method.name.lexeme}() 调用未配置超时参数', + detail: + '方法: ${method.name.lexeme}\n' + 'BLE 扫描应设置超时以限制扫描时间,避免过度耗电', + suggestion: '为 startScan() 添加明确的 timeout 或 duration 参数', + metadata: {'check': 'scan_without_timeout'}, + ), + ); + } + + bool _hasTimeoutParameter(MethodDeclaration method) { + for (final param in method.parameters?.parameters ?? []) { + if (param is DefaultFormalParameter) { + final name = param.name?.lexeme ?? ''; + if (_isTimeoutName(name)) return true; } } + return false; + } + + bool _hasTimeoutInBody(MethodDeclaration method) { + final visitor = _TimeoutArgumentVisitor(); + method.body.accept(visitor); + return visitor.hasTimeout; + } + + bool _isTimeoutName(String name) { + const names = { + 'timeout', + 'duration', + 'maxScanDuration', + 'scanDuration', + 'scanTimeout', + }; + return names.any( + (n) => name.toLowerCase().replaceAll('_', '') == n.toLowerCase(), + ); } static RuleDefinition describe() => const RuleDefinition( @@ -108,3 +133,46 @@ class BleScanningRule { fixSuggestion: '为 startScan 添加明确超时,并由资源生命周期规则检查释放', ); } + +class _BleTypeUsageVisitor extends RecursiveAstVisitor { + bool usesBleType = false; + + @override + void visitNamedType(NamedType node) { + if (_isBleType(node.toSource())) { + usesBleType = true; + } + super.visitNamedType(node); + } + + @override + void visitSimpleIdentifier(SimpleIdentifier node) { + if (_isBleType(node.name)) { + usesBleType = true; + } + super.visitSimpleIdentifier(node); + } +} + +class _TimeoutArgumentVisitor extends RecursiveAstVisitor { + bool hasTimeout = false; + + static const _timeoutArgNames = { + 'timeout', + 'duration', + 'maxScanDuration', + 'scanDuration', + 'scanTimeout', + }; + + @override + void visitNamedExpression(NamedExpression node) { + final label = node.name.label.name; + if (_timeoutArgNames.any( + (n) => label.toLowerCase().replaceAll('_', '') == n.toLowerCase(), + )) { + hasTimeout = true; + } + super.visitNamedExpression(node); + } +} diff --git a/lib/src/rules/iot_security.dart b/lib/src/rules/iot_security.dart index 36e6351..2cac40a 100644 --- a/lib/src/rules/iot_security.dart +++ b/lib/src/rules/iot_security.dart @@ -1,15 +1,21 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/source/line_info.dart'; + import '../config_loader.dart'; import 'rule.dart'; import '../source_workspace.dart'; import '../static_issue.dart'; -final _secretPattern = RegExp( - r"""(password|token|secret|api[_]?key)\s*[:=]\s*["']""", +final _secretNamePattern = RegExp( + r'^(password|token|secret|api[_]?key)$', + caseSensitive: false, +); +final _cleartextMqttPattern = RegExp( + r'tcp://|port:\s*1883', caseSensitive: false, ); -const _cleartextMqttPatterns = ['tcp://', 'port: 1883', 'port:1883']; -const _insecureBleKeywords = ['withoutBonding', 'withoutPairing']; -final _httpUrlPattern = RegExp(r"""['"]http://[^'"]+['"]"""); +const _insecureBleValues = {'withoutBonding', 'withoutPairing'}; class IotSecurityRule { final RuleConfig config; @@ -25,159 +31,239 @@ class IotSecurityRule { for (final file in files) { final source = sources.source(file); if (source == null) continue; - issues.addAll(_checkFile(file, source.content)); + final visitor = _IotSecurityVisitor( + config, + file, + source.lineInfo, + issues, + ); + source.unit.accept(visitor); } return issues; } - List _checkFile(String file, String content) { - final issues = []; - final lines = content.split('\n'); + static RuleDefinition describe() => const RuleDefinition( + id: 'iot_security', + name: 'IoT 安全风险', + domain: IssueDomain.architecture, + defaultSeverity: RiskLevel.high, + purpose: '检测硬编码凭据、明文 MQTT/HTTP、不安全的 BLE 配置', + riskReason: '硬编码凭据泄露导致设备被入侵;明文传输导致数据窃听', + badExample: 'password: "123456";tcp://broker:1883;BLE 使用 withoutBonding', + fixSuggestion: '使用环境变量或安全存储管理凭据;使用 TLS 加密通信', + defaultOptions: {'requireTls': true}, + ); +} - _checkHardcodedSecrets(file, lines, issues); +class _IotSecurityVisitor extends RecursiveAstVisitor { + final RuleConfig config; + final String file; + final LineInfo lineInfo; + final List issues; - if (config.boolOption('requireTls')) { - _checkCleartextMqtt(file, lines, issues); - _checkCleartextHttp(file, lines, issues); - } + _IotSecurityVisitor(this.config, this.file, this.lineInfo, this.issues); - _checkInsecureBle(file, lines, issues); + @override + void visitVariableDeclaration(VariableDeclaration node) { + _checkSecret(node); + _checkCleartextMqtt(node); + _checkCleartextHttp(node); + _checkInsecureBle(node); + super.visitVariableDeclaration(node); + } - return issues; + @override + void visitNamedExpression(NamedExpression node) { + _checkSecretArg(node); + _checkCleartextMqttArg(node); + _checkInsecureBleArg(node); + super.visitNamedExpression(node); } - void _checkHardcodedSecrets( - String file, - List lines, - List issues, - ) { - for (var i = 0; i < lines.length; i++) { - if (_secretPattern.hasMatch(lines[i])) { - issues.add( - StaticIssue( - id: 'iot_security', - title: 'IoT 安全 — 硬编码凭证', - file: file, - line: i + 1, - level: config.severity, - domain: IssueDomain.architecture, - message: '检测到可疑的硬编码凭证', - detail: - '行 ${i + 1}: ${lines[i].trim()}\n' - '硬编码凭证可能导致安全泄露,应使用环境变量或安全存储', - suggestion: '使用环境变量或安全存储方案替代硬编码凭证', - metadata: {'securityCheck': 'hardcoded_secret', 'line': i + 1}, - ), - ); - } - } + void _checkSecret(VariableDeclaration node) { + final name = node.name.lexeme; + if (!_secretNamePattern.hasMatch(name)) return; + final value = node.initializer; + if (value is! SimpleStringLiteral || value.stringValue == null) return; + final stringValue = value.stringValue!; + if (stringValue.isEmpty) return; + + final line = lineNumberForOffset(lineInfo, node.name.offset); + issues.add( + StaticIssue( + id: 'iot_security', + title: 'IoT 安全 — 硬编码凭证', + file: file, + line: line, + level: config.severity, + domain: IssueDomain.architecture, + message: '检测到可疑的硬编码凭证', + detail: + '行 $line: $name = "***"\n' + '硬编码凭证可能导致安全泄露,应使用环境变量或安全存储', + suggestion: '使用环境变量或安全存储方案替代硬编码凭证', + metadata: {'securityCheck': 'hardcoded_secret', 'line': line}, + ), + ); } - void _checkCleartextMqtt( - String file, - List lines, - List issues, - ) { - for (var i = 0; i < lines.length; i++) { - final lower = lines[i].toLowerCase(); - for (final pattern in _cleartextMqttPatterns) { - if (lower.contains(pattern)) { - issues.add( - StaticIssue( - id: 'iot_security', - title: 'IoT 安全 — 明文 MQTT 连接', - file: file, - line: i + 1, - level: config.severity, - domain: IssueDomain.architecture, - message: '检测到明文 MQTT 连接配置: "$pattern"', - detail: - '行 ${i + 1}: ${lines[i].trim()}\n' - '明文 MQTT 连接不安全,应使用 mqtts:// (TLS) 或端口 8883', - suggestion: '将 MQTT 连接升级为 mqtts:// 并使用端口 8883', - metadata: { - 'securityCheck': 'cleartext_mqtt', - 'pattern': pattern, - 'line': i + 1, - }, - ), - ); - } - } - } + void _checkSecretArg(NamedExpression node) { + final label = node.name.label.name; + if (!_secretNamePattern.hasMatch(label)) return; + final value = node.expression; + if (value is! SimpleStringLiteral || value.stringValue == null) return; + if (value.stringValue!.isEmpty) return; + + final line = lineNumberForOffset(lineInfo, node.offset); + issues.add( + StaticIssue( + id: 'iot_security', + title: 'IoT 安全 — 硬编码凭证', + file: file, + line: line, + level: config.severity, + domain: IssueDomain.architecture, + message: '检测到可疑的硬编码凭证', + detail: + '行 $line: $label = "***"\n' + '硬编码凭证可能导致安全泄露,应使用环境变量或安全存储', + suggestion: '使用环境变量或安全存储方案替代硬编码凭证', + metadata: {'securityCheck': 'hardcoded_secret', 'line': line}, + ), + ); } - void _checkCleartextHttp( - String file, - List lines, - List issues, - ) { - for (var i = 0; i < lines.length; i++) { - for (final match in _httpUrlPattern.allMatches(lines[i])) { - final url = match.group(0) ?? ''; - if (url.contains('localhost') || url.contains('127.0.0.1')) continue; - - issues.add( - StaticIssue( - id: 'iot_security', - title: 'IoT 安全 — 明文 HTTP 连接', - file: file, - line: i + 1, - level: config.severity, - domain: IssueDomain.architecture, - message: '检测到明文 HTTP URL: $url', - detail: '行 ${i + 1}: ${lines[i].trim()}\n明文 HTTP 不安全,应使用 HTTPS', - suggestion: '将 HTTP 连接升级为 HTTPS', - metadata: {'securityCheck': 'cleartext_http', 'url': url}, - ), - ); - } - } + void _checkCleartextMqtt(VariableDeclaration node) { + if (!config.boolOption('requireTls')) return; + final value = node.initializer; + if (value is! SimpleStringLiteral || value.stringValue == null) return; + if (!_cleartextMqttPattern.hasMatch(value.stringValue!)) return; + + final line = lineNumberForOffset(lineInfo, node.name.offset); + issues.add( + StaticIssue( + id: 'iot_security', + title: 'IoT 安全 — 明文 MQTT 连接', + file: file, + line: line, + level: config.severity, + domain: IssueDomain.architecture, + message: '检测到明文 MQTT 连接配置', + detail: + '行 $line: ${value.toSource()}\n' + '明文 MQTT 连接不安全,应使用 mqtts:// (TLS) 或端口 8883', + suggestion: '将 MQTT 连接升级为 mqtts:// 并使用端口 8883', + metadata: {'securityCheck': 'cleartext_mqtt', 'line': line}, + ), + ); } - void _checkInsecureBle( - String file, - List lines, - List issues, - ) { - for (var i = 0; i < lines.length; i++) { - for (final keyword in _insecureBleKeywords) { - if (lines[i].contains(keyword)) { - issues.add( - StaticIssue( - id: 'iot_security', - title: 'IoT 安全 — 不安全 BLE 配置', - file: file, - line: i + 1, - level: config.severity, - domain: IssueDomain.architecture, - message: '检测到不安全的 BLE 连接配置: "$keyword"', - detail: - '行 ${i + 1}: ${lines[i].trim()}\n' - 'BLE 连接应启用配对和加密 (bond / pair)', - suggestion: '启用 BLE 配对和加密配置', - metadata: { - 'securityCheck': 'insecure_ble', - 'keyword': keyword, - 'line': i + 1, - }, - ), - ); - } - } - } + void _checkCleartextMqttArg(NamedExpression node) { + if (!config.boolOption('requireTls')) return; + final value = node.expression; + if (value is! SimpleStringLiteral || value.stringValue == null) return; + if (!_cleartextMqttPattern.hasMatch(value.stringValue!)) return; + + final line = lineNumberForOffset(lineInfo, node.offset); + issues.add( + StaticIssue( + id: 'iot_security', + title: 'IoT 安全 — 明文 MQTT 连接', + file: file, + line: line, + level: config.severity, + domain: IssueDomain.architecture, + message: '检测到明文 MQTT 连接配置', + detail: + '行 $line: ${value.toSource()}\n' + '明文 MQTT 连接不安全,应使用 mqtts:// (TLS) 或端口 8883', + suggestion: '将 MQTT 连接升级为 mqtts:// 并使用端口 8883', + metadata: {'securityCheck': 'cleartext_mqtt', 'line': line}, + ), + ); } - static RuleDefinition describe() => const RuleDefinition( - id: 'iot_security', - name: 'IoT 安全风险', - domain: IssueDomain.architecture, - defaultSeverity: RiskLevel.high, - purpose: '检测硬编码凭据、明文 MQTT/HTTP、不安全的 BLE 配置', - riskReason: '硬编码凭据泄露导致设备被入侵;明文传输导致数据窃听', - badExample: 'password: "123456";tcp://broker:1883;BLE 使用 withoutBonding', - fixSuggestion: '使用环境变量或安全存储管理凭据;使用 TLS 加密通信', - defaultOptions: {'requireTls': true}, - ); + void _checkCleartextHttp(VariableDeclaration node) { + if (!config.boolOption('requireTls')) return; + final value = node.initializer; + if (value is! SimpleStringLiteral || value.stringValue == null) return; + final url = value.stringValue!; + if (!url.startsWith('http://')) return; + if (url.contains('localhost') || url.contains('127.0.0.1')) return; + + final line = lineNumberForOffset(lineInfo, node.name.offset); + issues.add( + StaticIssue( + id: 'iot_security', + title: 'IoT 安全 — 明文 HTTP 连接', + file: file, + line: line, + level: config.severity, + domain: IssueDomain.architecture, + message: '检测到明文 HTTP URL: $url', + detail: '行 $line: ${value.toSource()}\n明文 HTTP 不安全,应使用 HTTPS', + suggestion: '将 HTTP 连接升级为 HTTPS', + metadata: {'securityCheck': 'cleartext_http', 'url': url}, + ), + ); + } + + void _checkInsecureBle(VariableDeclaration node) { + final value = node.initializer; + if (value is! SimpleStringLiteral || value.stringValue == null) return; + final literalValue = value.stringValue!; + if (!_insecureBleValues.contains(literalValue)) return; + + final line = lineNumberForOffset(lineInfo, node.name.offset); + issues.add( + StaticIssue( + id: 'iot_security', + title: 'IoT 安全 — 不安全 BLE 配置', + file: file, + line: line, + level: config.severity, + domain: IssueDomain.architecture, + message: '检测到不安全的 BLE 连接配置: "$literalValue"', + detail: + '行 $line: ${value.toSource()}\n' + 'BLE 连接应启用配对和加密 (bond / pair)', + suggestion: '启用 BLE 配对和加密配置', + metadata: { + 'securityCheck': 'insecure_ble', + 'keyword': literalValue, + 'line': line, + }, + ), + ); + } + + void _checkInsecureBleArg(NamedExpression node) { + final value = node.expression; + if (value is! SimpleStringLiteral || value.stringValue == null) return; + final literalValue = value.stringValue!; + if (!_insecureBleValues.contains(literalValue)) return; + + final line = lineNumberForOffset(lineInfo, node.offset); + issues.add( + StaticIssue( + id: 'iot_security', + title: 'IoT 安全 — 不安全 BLE 配置', + file: file, + line: line, + level: config.severity, + domain: IssueDomain.architecture, + message: '检测到不安全的 BLE 连接配置: "$literalValue"', + detail: + '行 $line: ${value.toSource()}\n' + 'BLE 连接应启用配对和加密 (bond / pair)', + suggestion: '启用 BLE 配对和加密配置', + metadata: { + 'securityCheck': 'insecure_ble', + 'keyword': literalValue, + 'line': line, + }, + ), + ); + } } diff --git a/lib/src/rules/lifecycle_resource.dart b/lib/src/rules/lifecycle_resource.dart index b724acc..06e0d37 100644 --- a/lib/src/rules/lifecycle_resource.dart +++ b/lib/src/rules/lifecycle_resource.dart @@ -16,6 +16,7 @@ const _resourceTypes = { 'MqttClient': 'disconnect', 'BluetoothDevice': 'disconnect', 'StreamController': 'close', + 'OverlayEntry': 'remove', }; class LifecycleResourceRule { @@ -120,7 +121,7 @@ class LifecycleResourceRule { domain: IssueDomain.performance, defaultSeverity: RiskLevel.medium, purpose: - '检测 StreamSubscription/Timer/AnimationController 等资源在 dispose 中未释放', + '检测 StreamSubscription/Timer/AnimationController/OverlayEntry 等资源在 dispose 中未释放', riskReason: '未释放的资源导致内存泄漏和性能下降', badExample: '在 State 中创建 StreamSubscription 但 dispose() 中未调用 cancel()', fixSuggestion: '在 dispose() 中对每个资源调用对应的 cancel()/dispose()/close()', diff --git a/pubspec.yaml b/pubspec.yaml index b0538e5..22a7e0f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutterguard_cli description: IoT Flutter static analysis CLI for architecture, lifecycle, state, security, and CI enforcement. -version: 0.7.0 +version: 0.7.1 repository: https://github.com/lizy-coding/flutterguard issue_tracker: https://github.com/lizy-coding/flutterguard/issues topics: diff --git a/scripts/release_preflight.sh b/scripts/release_preflight.sh new file mode 100644 index 0000000..878acf2 --- /dev/null +++ b/scripts/release_preflight.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Runs the publish checks that can be automated before creating a pub.dev release. +# It never uploads a package. +set -u -o pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +failures=() + +pass() { + printf 'PASS %s\n' "$1" +} + +fail() { + printf 'FAIL %s\n' "$1" >&2 + failures+=("$1") +} + +run_gate() { + local label="$1" + shift + printf '\n== %s ==\n' "$label" + if "$@"; then + pass "$label" + else + fail "$label" + fi +} + +printf 'FlutterGuard release preflight (no package upload)\n' +printf 'Repository: %s\n\n' "$ROOT_DIR" + +for required_file in pubspec.yaml README.md CHANGELOG.md LICENSE; do + if [[ -s "$required_file" ]]; then + pass "required file: $required_file" + else + fail "required file: $required_file" + fi +done + +if git diff --check; then + pass 'no whitespace errors' +else + fail 'no whitespace errors' +fi + +if [[ -z "$(git status --porcelain)" ]]; then + pass 'clean Git worktree' +else + fail 'clean Git worktree (commit, stash, or remove local changes before publishing)' +fi + +run_gate 'resolve dependencies' dart pub get +run_gate 'format gate' dart format --output=none --set-exit-if-changed bin lib test +run_gate 'static analysis' dart analyze +run_gate 'test suite' dart test +run_gate 'CLI smoke scan' dart run bin/flutterguard.dart scan example --format json --no-color --fail-on high +run_gate 'pub.dev dry run' dart pub publish --server https://pub.dev --dry-run + +VERSION="$(awk '/^version:[[:space:]]*/ { print $2; exit }' pubspec.yaml)" +EXPECTED_TAG="v$VERSION" + +printf '\n== Human release checklist ==\n' +printf '[ ] Inspect the file list and warnings printed by "pub.dev dry run"; do not publish unwanted files.\n' +printf '[ ] Confirm that every included file may legally be redistributed and the LICENSE is correct.\n' +printf '[ ] Confirm %s is a new pub.dev version and CHANGELOG.md describes this release.\n' "$VERSION" +printf '[ ] Create and push tag %s at the intended release commit.\n' "$EXPECTED_TAG" +printf '[ ] Confirm the publishing Google account has access to the intended pub.dev publisher.\n' + +if git rev-parse -q --verify "refs/tags/$EXPECTED_TAG" >/dev/null; then + tag_commit="$(git rev-list -n 1 "$EXPECTED_TAG")" + head_commit="$(git rev-parse HEAD)" + if [[ "$tag_commit" == "$head_commit" ]]; then + pass "local tag $EXPECTED_TAG points at HEAD" + else + printf 'INFO local tag %s does not point at HEAD; verify the release commit before pushing it.\n' "$EXPECTED_TAG" + fi +else + printf 'INFO local tag %s has not been created yet.\n' "$EXPECTED_TAG" +fi + +if (( ${#failures[@]} > 0 )); then + printf '\nRelease preflight failed (%d gate(s)):\n' "${#failures[@]}" >&2 + for failure in "${failures[@]}"; do + printf ' - %s\n' "$failure" >&2 + done + exit 1 +fi + +printf '\nAutomated preflight passed. Complete every human checklist item before running dart pub publish.\n' diff --git a/test/fixtures/ble_scanning_negative.dart b/test/fixtures/ble_scanning_negative.dart new file mode 100644 index 0000000..0532fea --- /dev/null +++ b/test/fixtures/ble_scanning_negative.dart @@ -0,0 +1,20 @@ +// Fixture: BLE scanning with proper timeout configuration +class BleDevice {} + +class ScanResult {} + +class BleService { + late BleDevice _device; + + void startScan({Duration? timeout}) { + // configured with timeout parameter + } + + void startScanWithTimeout() { + FlutterBluePlus.startScan(timeout: const Duration(seconds: 30)); + } + + void stopAndDisconnect() { + _device.disconnect(); + } +} diff --git a/test/fixtures/ble_scanning_suppression.dart b/test/fixtures/ble_scanning_suppression.dart new file mode 100644 index 0000000..cb5439d --- /dev/null +++ b/test/fixtures/ble_scanning_suppression.dart @@ -0,0 +1,14 @@ +// ignore_for_file: unused_local_variable +// Fixture: BLE scanning with suppression comments +class BleSuppressedDevice {} + +class BleSuppressedService { + late BleSuppressedDevice _device; + + // flutterguard: ignore ble_scanning + void startScan() { + // scanning without timeout, suppressed + } + + void connect() {} +} diff --git a/test/fixtures/iot_security_negative.dart b/test/fixtures/iot_security_negative.dart new file mode 100644 index 0000000..772ecff --- /dev/null +++ b/test/fixtures/iot_security_negative.dart @@ -0,0 +1,15 @@ +// Fixture: safe IoT code that should NOT trigger iot_security +class SafeIotService { + void connect() { + final password = getPasswordFromEnv(); + final brokerUrl = "mqtts://secure-broker.example.com:8883"; + final apiUrl = "https://iot.example.com/api/data"; + final bleConfig = BondingConfig.withPairing(); + } + + String getPasswordFromEnv() => ''; +} + +class BondingConfig { + BondingConfig.withPairing(); +} diff --git a/test/fixtures/iot_security_suppression.dart b/test/fixtures/iot_security_suppression.dart new file mode 100644 index 0000000..d57da1e --- /dev/null +++ b/test/fixtures/iot_security_suppression.dart @@ -0,0 +1,16 @@ +// Fixture: IoT security issues with suppression comments +class SuppressedIotWidget { + void connect() { + // flutterguard: ignore iot_security + final password = "admin123"; + + // flutterguard: ignore iot_security + final brokerUrl = "tcp://192.168.1.100:1883"; + + // flutterguard: ignore iot_security + final apiUrl = "http://iot.example.com/api/data"; + + // flutterguard: ignore iot_security + final bleConfig = "withoutBonding"; + } +} diff --git a/test/fixtures/lifecycle_issue.dart b/test/fixtures/lifecycle_issue.dart index 8c902c0..e8815d4 100644 --- a/test/fixtures/lifecycle_issue.dart +++ b/test/fixtures/lifecycle_issue.dart @@ -1,18 +1,23 @@ // ignore_for_file: unused_field, inference_failure_on_instance_creation import 'dart:async'; +class OverlayEntry {} + // Fixture: lifecycle resource not disposed class LifecycleIssueWidget { late StreamSubscription _subscription; late Timer _timer; + late OverlayEntry _overlayEntry; LifecycleIssueWidget() { _subscription = const Stream.empty().listen((_) {}); _timer = Timer.periodic(const Duration(seconds: 1), (_) {}); + _overlayEntry = OverlayEntry(); } void dispose() { // _subscription.cancel() is missing // _timer.cancel() is missing + // _overlayEntry.remove() is missing } } diff --git a/test/fixtures/lifecycle_negative.dart b/test/fixtures/lifecycle_negative.dart new file mode 100644 index 0000000..1a9fa84 --- /dev/null +++ b/test/fixtures/lifecycle_negative.dart @@ -0,0 +1,25 @@ +// ignore_for_file: unused_field, inference_failure_on_instance_creation +import 'dart:async'; + +class OverlayEntry {} + +class StreamController {} + +// Fixture: lifecycle resources properly disposed +class LifecycleOkWidget { + late StreamSubscription _subscription; + late Timer _timer; + late OverlayEntry _overlayEntry; + + LifecycleOkWidget() { + _subscription = const Stream.empty().listen((_) {}); + _timer = Timer.periodic(const Duration(seconds: 1), (_) {}); + _overlayEntry = OverlayEntry(); + } + + void dispose() { + _subscription.cancel(); + _timer.cancel(); + _overlayEntry.remove(); + } +} diff --git a/test/rules_test.dart b/test/rules_test.dart index 1af59a3..440a764 100644 --- a/test/rules_test.dart +++ b/test/rules_test.dart @@ -14,6 +14,7 @@ import 'package:flutterguard_cli/src/rules/riverpod_state_management.dart'; import 'package:flutterguard_cli/src/rules/state_dependency_cycle.dart'; import 'package:flutterguard_cli/src/source_workspace.dart'; import 'package:flutterguard_cli/src/static_issue.dart'; +import 'package:flutterguard_cli/src/suppression.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; @@ -24,17 +25,41 @@ RuleConfig _config( Map options = const {}, }) => RuleConfig(enabled: true, severity: severity, options: options); +RuleConfig get _disabled => + const RuleConfig(enabled: false, severity: RiskLevel.low); + void main() { group('IoT and lifecycle rules', () { test('resource lifecycle finds undisposed fields', () { final issues = LifecycleResourceRule( _config(RiskLevel.medium), ).analyze([p.join(_fixtures, 'lifecycle_issue.dart')]); - expect(issues, hasLength(2)); + expect(issues, hasLength(3)); expect( issues.every((issue) => issue.id == 'lifecycle_resource_not_disposed'), isTrue, ); + expect( + issues.any( + (issue) => + (issue.metadata['resourceType'] as String) == 'OverlayEntry', + ), + isTrue, + ); + }); + + test('resource lifecycle negative: properly disposed resources', () { + final issues = LifecycleResourceRule( + _config(RiskLevel.medium), + ).analyze([p.join(_fixtures, 'lifecycle_negative.dart')]); + expect(issues, isEmpty); + }); + + test('resource lifecycle disabled produces no findings', () { + final issues = LifecycleResourceRule( + _disabled, + ).analyze([p.join(_fixtures, 'lifecycle_issue.dart')]); + expect(issues, isEmpty); }); test('BLE rule only owns scan timeout', () { @@ -45,6 +70,31 @@ void main() { expect(issues.single.metadata['check'], 'scan_without_timeout'); }); + test('BLE rule negative: startScan with timeout parameter', () { + final issues = BleScanningRule( + _config(RiskLevel.medium), + ).analyze([p.join(_fixtures, 'ble_scanning_negative.dart')]); + expect(issues, isEmpty); + }); + + test('BLE rule disabled produces no findings', () { + final issues = BleScanningRule( + _disabled, + ).analyze([p.join(_fixtures, 'ble_scanning_issue.dart')]); + expect(issues, isEmpty); + }); + + test('BLE rule suppression filters findings', () { + final file = p.join(_fixtures, 'ble_scanning_suppression.dart'); + final rawIssues = BleScanningRule( + _config(RiskLevel.medium), + ).analyze([file]); + expect(rawIssues, isNotEmpty); + final filter = SuppressionFilter([file]); + final visible = rawIssues.where((i) => !filter.isSuppressed(i)).toList(); + expect(visible, isEmpty); + }); + test('IoT security detects transport and credential risks', () { final issues = IotSecurityRule( _config(RiskLevel.high, options: {'requireTls': true}), @@ -52,6 +102,55 @@ void main() { expect(issues.length, greaterThanOrEqualTo(3)); expect(issues.every((issue) => issue.level == RiskLevel.high), isTrue); }); + + test('IoT security negative: safe code with proper protocols', () { + final issues = IotSecurityRule( + _config(RiskLevel.high, options: {'requireTls': true}), + ).analyze([p.join(_fixtures, 'iot_security_negative.dart')]); + expect(issues, isEmpty); + }); + + test('IoT security disabled produces no findings', () { + final issues = IotSecurityRule( + _disabled, + ).analyze([p.join(_fixtures, 'iot_security_issue.dart')]); + expect(issues, isEmpty); + }); + + test('IoT security requireTls disabled skips transport checks', () { + final issues = IotSecurityRule( + _config(RiskLevel.high, options: {'requireTls': false}), + ).analyze([p.join(_fixtures, 'iot_security_issue.dart')]); + final checks = issues.map((i) => i.metadata['securityCheck']).toSet(); + expect(checks, isNot(contains('cleartext_mqtt'))); + expect(checks, isNot(contains('cleartext_http'))); + }); + + test('IoT security suppression filters findings', () { + final file = p.join(_fixtures, 'iot_security_suppression.dart'); + final rawIssues = IotSecurityRule( + _config(RiskLevel.high, options: {'requireTls': true}), + ).analyze([file]); + expect(rawIssues, isNotEmpty); + final filter = SuppressionFilter([file]); + final visible = rawIssues.where((i) => !filter.isSuppressed(i)).toList(); + expect(visible, isEmpty); + }); + + test('IoT security report contract: JSON output fields', () { + final issues = IotSecurityRule( + _config(RiskLevel.high, options: {'requireTls': true}), + ).analyze([p.join(_fixtures, 'iot_security_issue.dart')]); + for (final issue in issues) { + final json = issue.toJson(); + expect(json['ruleId'], 'iot_security'); + expect(json, contains('severity')); + expect(json, contains('domain')); + expect(json, contains('message')); + expect(json, contains('suggestion')); + expect(json, isNot(contains('id'))); + } + }); }); group('Architecture rules', () {