| // Copyright 2013 The Flutter Authors | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| import 'dart:io' as io; | |
| import 'package:file/file.dart'; | |
| import 'package:yaml/yaml.dart'; | |
| import 'common/core.dart'; | |
| import 'common/file_filters.dart'; | |
| import 'common/flutter_command_utils.dart'; | |
| import 'common/gradle.dart'; | |
| import 'common/output_utils.dart'; | |
| import 'common/package_looping_command.dart'; | |
| import 'common/plugin_utils.dart'; | |
| import 'common/pub_utils.dart'; | |
| import 'common/repository_package.dart'; | |
| import 'common/xcode.dart'; | |
| /// A command to run Dart analysis on packages. | |
| class AnalyzeCommand extends PackageLoopingCommand { | |
| /// Creates a analysis command instance. | |
| AnalyzeCommand(super.packagesDir, {super.processRunner, super.platform, super.gitDir}) { | |
| // Platform options. | |
| // By default, only Dart analysis is run. | |
| argParser.addFlag(_dartFlag, help: "Runs 'dart analyze'", defaultsTo: true); | |
| argParser.addFlag(platformAndroid, help: "Runs 'gradle lint' on Android code"); | |
| argParser.addFlag(platformIOS, help: "Runs 'xcodebuild analyze' on iOS code"); | |
| argParser.addFlag(platformMacOS, help: "Runs 'xcodebuild analyze' on macOS code"); | |
| // Dart options. | |
| // TODO(stuartmorgan): Remove this entirely once the flutter/flutter and dart-lang repo scripts | |
| // that run flutter/packages analysis have been updated not to pass it. | |
| argParser.addMultiOption( | |
| 'custom-analysis', | |
| help: 'Ignored; exists for legacy compatibility only.', | |
| hide: true, | |
| ); | |
| argParser.addOption( | |
| _analysisSdk, | |
| valueHelp: 'dart-sdk', | |
| help: | |
| 'An optional path to a Dart SDK; this is used to override the ' | |
| 'SDK used to provide analysis.', | |
| ); | |
| argParser.addFlag( | |
| _downgradeFlag, | |
| help: | |
| 'Runs "flutter pub downgrade" before analysis to verify that ' | |
| 'the minimum constraints are sufficiently new for APIs used.', | |
| ); | |
| argParser.addFlag( | |
| _libOnlyFlag, | |
| help: | |
| 'Only analyze the lib/ directory of the main package, not the ' | |
| 'entire package.', | |
| ); | |
| argParser.addFlag( | |
| _skipIfResolvingFailsFlag, | |
| help: | |
| 'If resolution fails, skip the package. This is only ' | |
| 'intended to be used with pathified analysis, where a resolver ' | |
| 'failure indicates that no out-of-band failure can result anyway.', | |
| hide: true, | |
| ); | |
| // Xcode options. | |
| argParser.addOption( | |
| _minIOSVersionArg, | |
| help: | |
| 'Sets the minimum iOS deployment version to use when compiling, ' | |
| 'overriding the default minimum version. This can be used to find ' | |
| 'deprecation warnings that will affect the plugin in the future.', | |
| ); | |
| argParser.addOption( | |
| _minMacOSVersionArg, | |
| help: | |
| 'Sets the minimum macOS deployment version to use when compiling, ' | |
| 'overriding the default minimum version. This can be used to find ' | |
| 'deprecation warnings that will affect the plugin in the future.', | |
| ); | |
| } | |
| static const String _dartFlag = 'dart'; | |
| static const String _downgradeFlag = 'downgrade'; | |
| static const String _libOnlyFlag = 'lib-only'; | |
| static const String _analysisSdk = 'analysis-sdk'; | |
| static const String _skipIfResolvingFailsFlag = 'skip-if-resolving-fails'; | |
| static const String _minIOSVersionArg = 'ios-min-version'; | |
| static const String _minMacOSVersionArg = 'macos-min-version'; | |
| late String _dartBinaryPath; | |
| @override | |
| final String name = 'analyze'; | |
| @override | |
| final String description = | |
| 'Analyzes all packages using dart analyze.\n\n' | |
| 'This command requires "dart" and "flutter" to be in your path.'; | |
| /// Checks that there are no package-local analysis_options.yaml files. | |
| bool _hasLocalAnalysisOptions(RepositoryPackage package) { | |
| final List<FileSystemEntity> files = package.directory.listSync( | |
| recursive: true, | |
| followLinks: false, | |
| ); | |
| for (final file in files) { | |
| if (file.basename != 'analysis_options.yaml' && file.basename != '.analysis_options') { | |
| continue; | |
| } | |
| // Skip anything checked out inside of .dart_tool/. | |
| if (file.path.contains('/.dart_tool/')) { | |
| continue; | |
| } | |
| printError('Found an unexpected analysis_options.yaml at ${file.absolute.path}.'); | |
| printError( | |
| 'If this is an intentional exception to the general repository guidance against having ' | |
| 'custom analysis_options.yaml files, add a ci_config.yaml to this package that ' | |
| 'sets "allow_custom_analysis_options: true", with a comment explaining why an exception ' | |
| 'is needed.', | |
| ); | |
| return true; | |
| } | |
| return false; | |
| } | |
| @override | |
| bool shouldIgnoreFile(String path) { | |
| // Support files don't affect any analysis. | |
| if (isRepoLevelNonCodeImpactingFile(path) || isPackageSupportFile(path)) { | |
| return true; | |
| } | |
| // For native code, it depends on the flags. | |
| if (path.endsWith('.dart')) { | |
| return !getBoolArg(_dartFlag); | |
| } | |
| if (path.endsWith('.java') || path.endsWith('.kt')) { | |
| return !getBoolArg(platformAndroid); | |
| } | |
| if (path.endsWith('.c') || | |
| path.endsWith('.cc') || | |
| path.endsWith('.cpp') || | |
| path.endsWith('.h')) { | |
| // If C/C++ linting is added, Windows and Linux should be added here. | |
| return !(getBoolArg(platformIOS) || getBoolArg(platformMacOS)); | |
| } | |
| if (path.endsWith('.m') || path.endsWith('.mm') || path.endsWith('.swift')) { | |
| return !(getBoolArg(platformIOS) || getBoolArg(platformMacOS)); | |
| } | |
| return false; | |
| } | |
| @override | |
| Future<void> initializeRun() async { | |
| // Use the Dart SDK override if one was passed in. | |
| final dartSdk = argResults![_analysisSdk] as String?; | |
| _dartBinaryPath = dartSdk == null ? 'dart' : path.join(dartSdk, 'bin', 'dart'); | |
| } | |
| @override | |
| Future<PackageResult> runForPackage(RepositoryPackage package) async { | |
| final subResults = <String, PackageResult>{}; | |
| if (getBoolArg(_dartFlag)) { | |
| _printSectionHeading('Running dart analyze.'); | |
| subResults['Dart'] = await _runDartAnalysisForPackage(package); | |
| } | |
| if (getBoolArg(platformAndroid)) { | |
| _printSectionHeading('Running gradle lint.'); | |
| subResults['Android'] = await _runGradleLintForPackage(package); | |
| } | |
| if (getBoolArg(platformIOS)) { | |
| _printSectionHeading('Running iOS xcodebuild analyze.'); | |
| final String minIOSVersion = getStringArg(_minIOSVersionArg); | |
| subResults['iOS'] = await _runXcodeAnalysisForPackage( | |
| package, | |
| FlutterPlatform.ios, | |
| extraFlags: <String>[ | |
| '-destination', | |
| 'generic/platform=iOS Simulator', | |
| if (minIOSVersion.isNotEmpty) 'IPHONEOS_DEPLOYMENT_TARGET=$minIOSVersion', | |
| ], | |
| ); | |
| } | |
| if (getBoolArg(platformMacOS)) { | |
| _printSectionHeading('Running macOS xcodebuild analyze.'); | |
| final String minMacOSVersion = getStringArg(_minMacOSVersionArg); | |
| subResults['macOS'] = await _runXcodeAnalysisForPackage( | |
| package, | |
| FlutterPlatform.macos, | |
| extraFlags: <String>[ | |
| if (minMacOSVersion.isNotEmpty) 'MACOSX_DEPLOYMENT_TARGET=$minMacOSVersion', | |
| ], | |
| ); | |
| } | |
| // Make sure at least one analysis option was requested. | |
| if (subResults.isEmpty) { | |
| printError('At least one analysis option flag must be provided.'); | |
| throw ToolExit(exitInvalidArguments); | |
| } | |
| // If only one analysis was requested, just return its result. | |
| if (subResults.length == 1) { | |
| return subResults.values.first; | |
| } | |
| // Otherwise, aggregate the messages, with the least positive status. | |
| final failedResults = Map<String, PackageResult>.of(subResults) | |
| ..removeWhere((String key, PackageResult value) => value.state != RunState.failed); | |
| final skippedResults = Map<String, PackageResult>.of(subResults) | |
| ..removeWhere((String key, PackageResult value) => value.state != RunState.skipped); | |
| // If anything failed, collect all the failure messages, prefixed by type. | |
| if (failedResults.isNotEmpty) { | |
| return PackageResult.fail(<String>[ | |
| for (final MapEntry<String, PackageResult> entry in failedResults.entries) | |
| '${entry.key}${entry.value.details.isEmpty ? '' : ': ${entry.value.details.join(', ')}'}', | |
| ]); | |
| } | |
| // If everything was skipped, mark as skipped with all of the explanations. | |
| if (skippedResults.length == subResults.length) { | |
| return PackageResult.skip( | |
| skippedResults.entries | |
| .map( | |
| (MapEntry<String, PackageResult> entry) => | |
| '${entry.key}: ${entry.value.details.first}', | |
| ) | |
| .join(', '), | |
| ); | |
| } | |
| // For all succes, or a mix of success and skip, log any skips but mark as | |
| // success. | |
| for (final MapEntry<String, PackageResult> skip in skippedResults.entries) { | |
| printSkip('Skipped ${skip.key}: ${skip.value.details.first}'); | |
| } | |
| return PackageResult.success(); | |
| } | |
| void _printSectionHeading(String heading) { | |
| print('\n$heading'); | |
| print('--------------------'); | |
| } | |
| /// Runs Dart analysis for the given package, and returns the result that | |
| /// applies to that analysis. | |
| Future<PackageResult> _runDartAnalysisForPackage(RepositoryPackage package) async { | |
| final bool libOnly = getBoolArg(_libOnlyFlag); | |
| if (libOnly && !package.libDirectory.existsSync()) { | |
| return PackageResult.skip('No lib/ directory.'); | |
| } | |
| if (getBoolArg(_downgradeFlag)) { | |
| if (!await _runPubCommand(package, 'downgrade')) { | |
| return PackageResult.fail(<String>['Unable to resolve downgraded dependencies']); | |
| } | |
| } | |
| // Analysis runs over the package and all subpackages (unless only lib/ is | |
| // being analyzed), so all of them need `flutter pub get` run before | |
| // analyzing. `example` packages can be skipped since 'flutter pub get' | |
| // automatically runs `pub get` in examples as part of handling the parent | |
| // directory. | |
| final packagesToGet = <RepositoryPackage>[package, if (!libOnly) ...package.getSubpackages()]; | |
| for (final packageToGet in packagesToGet) { | |
| if (packageToGet.directory.basename != 'example' || | |
| !RepositoryPackage(packageToGet.directory.parent).pubspecFile.existsSync()) { | |
| if (!await _runPubCommand(packageToGet, 'get')) { | |
| if (getBoolArg(_skipIfResolvingFailsFlag)) { | |
| // Re-run, capturing output, to see if the failure was a resolver | |
| // failure. (This is slightly inefficient, but this should be a | |
| // very rare case.) | |
| const resolverFailureMessage = 'version solving failed'; | |
| final io.ProcessResult result = await processRunner.run(flutterCommand, <String>[ | |
| 'pub', | |
| 'get', | |
| ], workingDir: packageToGet.directory); | |
| if ((result.stderr as String).contains(resolverFailureMessage) || | |
| (result.stdout as String).contains(resolverFailureMessage)) { | |
| logWarning('Skipping package due to pub resolution failure.'); | |
| return PackageResult.skip('Resolution failed.'); | |
| } | |
| } | |
| return PackageResult.fail(<String>['Unable to get dependencies']); | |
| } | |
| } | |
| } | |
| // Require an explicit opt-in to use custom analysis options, since it's very easy to | |
| // accidentally introduce them to the repo (e.g., with 'flutter create'). | |
| if (!(package.parseCIConfig()?.allowCustomAnalysisOptions ?? false) && | |
| _hasLocalAnalysisOptions(package)) { | |
| return PackageResult.fail(<String>['Unexpected local analysis options']); | |
| } | |
| final int mainExitCode = await processRunner.runAndStream(_dartBinaryPath, <String>[ | |
| 'analyze', | |
| '--fatal-infos', | |
| if (libOnly) 'lib', | |
| ], workingDir: package.directory); | |
| var skillsExitCode = 0; | |
| var skillsErrors = <String>[]; | |
| if (!libOnly && (package.parseCIConfig()?.analyzeSkills ?? false)) { | |
| skillsErrors = _validateAgentsSkillsDirectory(package); | |
| if (skillsErrors.isEmpty) { | |
| skillsExitCode = await processRunner.runAndStream(_dartBinaryPath, <String>[ | |
| 'analyze', | |
| '--fatal-infos', | |
| '.agents/skills', | |
| ], workingDir: package.directory); | |
| } | |
| } | |
| final errors = <String>[ | |
| if (mainExitCode != 0) 'Main package analysis failed', | |
| if (skillsExitCode != 0) 'Skills analysis failed', | |
| ...skillsErrors, | |
| ]; | |
| final customCheckRunners = <_CustomLinter>[ | |
| _CustomLinter(dependencyName: 'cognitive_complexity', run: _runCognitiveComplexityForPackage), | |
| ]; | |
| // Skip custom linters during downgrade as metrics are redundant and vulnerable to dependency issues. | |
| if (!getBoolArg(_downgradeFlag)) { | |
| final Pubspec pubspec = package.parsePubspec(); | |
| for (final runner in customCheckRunners) { | |
| final bool hasDependency = pubspec.devDependencies.containsKey(runner.dependencyName); | |
| if (hasDependency) { | |
| errors.addAll(await runner.run(package)); | |
| } | |
| } | |
| } | |
| return errors.isEmpty ? PackageResult.success() : PackageResult.fail(errors); | |
| } | |
| /// Retrieves the configured cognitive complexity threshold from the local | |
| /// `analysis_options.yaml` if it exists and is configured. | |
| int? _getLinterThreshold(RepositoryPackage package) { | |
| final File optionsFile = package.directory.childFile('analysis_options.yaml'); | |
| if (!optionsFile.existsSync()) { | |
| return null; | |
| } | |
| try { | |
| final Object? yaml = loadYaml(optionsFile.readAsStringSync()); | |
| if (yaml is YamlMap) { | |
| final Object? linter = yaml['cognitive_complexity']; | |
| if (linter is YamlMap) { | |
| final Object? threshold = linter['fail-threshold']; | |
| if (threshold is int) { | |
| return threshold; | |
| } | |
| } | |
| } | |
| } catch (_) { | |
| // Ignore errors parsing invalid/incomplete files. | |
| } | |
| return null; | |
| } | |
| bool _isGeneratedDartFile(String filePath) { | |
| return filePath.endsWith('.g.dart') || | |
| filePath.endsWith('.mocks.dart') || | |
| filePath.endsWith('.gen.dart'); | |
| } | |
| /// Runs the `cognitive_complexity` metrics analyzer on the package. | |
| /// | |
| /// Assumes `cognitive_complexity` is present in `dev_dependencies`. | |
| Future<List<String>> _runCognitiveComplexityForPackage(RepositoryPackage package) async { | |
| if (!package.libDirectory.existsSync()) { | |
| return <String>[]; | |
| } | |
| final filesToAnalyze = <String>[]; | |
| for (final FileSystemEntity entity in package.libDirectory.listSync(recursive: true)) { | |
| if (entity is File && entity.path.endsWith('.dart') && !_isGeneratedDartFile(entity.path)) { | |
| final String relativePath = path.relative(entity.path, from: package.directory.path); | |
| filesToAnalyze.add(relativePath.replaceAll(r'\', '/')); | |
| } | |
| } | |
| if (filesToAnalyze.isEmpty) { | |
| return <String>[]; | |
| } | |
| filesToAnalyze.sort(); | |
| print('Running cognitive_complexity analysis...'); | |
| final int? threshold = _getLinterThreshold(package); | |
| final args = <String>[ | |
| 'run', | |
| 'cognitive_complexity', | |
| if (threshold != null) ...<String>[ | |
| // --fail-threshold only controls the exit code, while --threshold | |
| // controls which functions are printed in the output table (default 0). | |
| // Pass both so that only failing functions are printed in CI logs. | |
| '--threshold', | |
| threshold.toString(), | |
| '--fail-threshold', | |
| threshold.toString(), | |
| ], | |
| ...filesToAnalyze, | |
| ]; | |
| final int linterExitCode = await processRunner.runAndStream( | |
| _dartBinaryPath, | |
| args, | |
| workingDir: package.directory, | |
| ); | |
| if (linterExitCode != 0) { | |
| final thresholdMessage = threshold != null ? ' (configured threshold: $threshold)' : ''; | |
| return <String>[ | |
| 'Metrics violations found$thresholdMessage. See the package\'s local "analysis_options.yaml" for configured thresholds.', | |
| ]; | |
| } | |
| return <String>[]; | |
| } | |
| /// Validates that `.agents/skills` contains Dart code if configured for skills analysis. | |
| /// | |
| /// Returns a list of error strings if the package is configured for skills analysis | |
| /// but no Dart code was found. Returns an empty list on success. | |
| List<String> _validateAgentsSkillsDirectory(RepositoryPackage package) { | |
| bool hasDartFiles(Directory dir) { | |
| if (!dir.existsSync()) { | |
| return false; | |
| } | |
| return dir | |
| .listSync(recursive: true) | |
| .any((FileSystemEntity entity) => entity is File && entity.path.endsWith('.dart')); | |
| } | |
| final Directory agentsSkillsDir = package.directory | |
| .childDirectory('.agents') | |
| .childDirectory('skills'); | |
| if (!hasDartFiles(agentsSkillsDir)) { | |
| printError( | |
| 'Configured to analyze skills for ${package.directory.basename}, but no Dart code was found in .agents/skills.', | |
| ); | |
| return <String>['No Dart code found in .agents/skills']; | |
| } | |
| return <String>[]; | |
| } | |
| Future<bool> _runPubCommand(RepositoryPackage package, String command) async { | |
| return runPubCommand( | |
| <String>[command], | |
| package, | |
| processRunner, | |
| platform, | |
| dartSdkPathOverride: _dartBinaryPath, | |
| // 'get' and 'downgrade' resolve examples as well, so check | |
| // those as well when deciding between `flutter` and `dart`. | |
| recursiveFlutterCheck: true, | |
| ); | |
| } | |
| /// Runs Gradle lint analysis for the given package, and returns the result | |
| /// that applies to that analysis. | |
| Future<PackageResult> _runGradleLintForPackage(RepositoryPackage package) async { | |
| if (!pluginSupportsPlatform(platformAndroid, package, requiredMode: PlatformSupport.inline) || | |
| !package.platformDirectory(FlutterPlatform.android).existsSync()) { | |
| return PackageResult.skip('Package does not contain native Android plugin code'); | |
| } | |
| for (final RepositoryPackage example in package.getExamples()) { | |
| final project = GradleProject(example, processRunner: processRunner, platform: platform); | |
| if (!project.isConfigured()) { | |
| final bool buildSuccess = await runConfigOnlyBuild( | |
| example, | |
| processRunner, | |
| platform, | |
| FlutterPlatform.android, | |
| ); | |
| if (!buildSuccess) { | |
| printError('Unable to configure Gradle project.'); | |
| return PackageResult.fail(<String>['Unable to configure Gradle.']); | |
| } | |
| } | |
| final String packageName = package.directory.basename; | |
| // Only lint one build mode to avoid extra work. | |
| // Only lint the plugin project itself, to avoid failing due to errors in | |
| // dependencies. | |
| // | |
| // TODO(stuartmorgan): Consider adding an XML parser to read and summarize | |
| // all results. Currently, only the first three errors will be shown | |
| // inline, and the rest have to be checked via the CI-uploaded artifact. | |
| final int exitCode = await project.runCommand('$packageName:lintDebug'); | |
| if (exitCode != 0) { | |
| return PackageResult.fail(); | |
| } | |
| } | |
| return PackageResult.success(); | |
| } | |
| /// Analyzes [plugin] for [targetPlatform]. | |
| Future<PackageResult> _runXcodeAnalysisForPackage( | |
| RepositoryPackage package, | |
| FlutterPlatform targetPlatform, { | |
| List<String> extraFlags = const <String>[], | |
| }) async { | |
| final platformString = targetPlatform == FlutterPlatform.ios ? 'iOS' : 'macOS'; | |
| if (!pluginSupportsPlatform( | |
| targetPlatform.name, | |
| package, | |
| requiredMode: PlatformSupport.inline, | |
| )) { | |
| return PackageResult.skip('Package does not contain native $platformString plugin code'); | |
| } | |
| final xcode = Xcode(processRunner: processRunner, log: true); | |
| final errors = <String>[]; | |
| for (final RepositoryPackage example in package.getExamples()) { | |
| // Unconditionally re-run build with --debug --config-only, to ensure that | |
| // the project is in a debug state even if it was previously configured. | |
| print('Running flutter build --config-only...'); | |
| final bool buildSuccess = await runConfigOnlyBuild( | |
| example, | |
| processRunner, | |
| platform, | |
| targetPlatform, | |
| buildDebug: true, | |
| ); | |
| if (!buildSuccess) { | |
| printError('Unable to prepare native project files.'); | |
| errors.add( | |
| 'Unable to build ${getRelativePosixPath(example.directory, from: package.directory)}.', | |
| ); | |
| continue; | |
| } | |
| // Running tests and static analyzer. | |
| final String examplePath = getRelativePosixPath( | |
| example.directory, | |
| from: package.directory.parent, | |
| ); | |
| print('Running $platformString tests and analyzer for $examplePath...'); | |
| final int exitCode = await xcode.runXcodeBuild( | |
| example.directory, | |
| platformString, | |
| // Clean before analyzing to remove cached swiftmodules from previous | |
| // runs, which can cause conflicts. | |
| actions: <String>['clean', 'analyze'], | |
| workspace: '${platformString.toLowerCase()}/Runner.xcworkspace', | |
| scheme: 'Runner', | |
| configuration: 'Debug', | |
| hostPlatform: platform, | |
| extraFlags: <String>[...extraFlags, 'GCC_TREAT_WARNINGS_AS_ERRORS=YES'], | |
| ); | |
| if (exitCode == 0) { | |
| printSuccess('$examplePath ($platformString) passed analysis.'); | |
| } else { | |
| printError('$examplePath ($platformString) failed analysis.'); | |
| errors.add( | |
| '${getRelativePosixPath(example.directory, from: package.directory)} failed analysis.', | |
| ); | |
| } | |
| } | |
| return errors.isEmpty ? PackageResult.success() : PackageResult.fail(errors); | |
| } | |
| } | |
| /// Represents a custom linter check that is executed during package analysis. | |
| class _CustomLinter { | |
| const _CustomLinter({required this.dependencyName, required this.run}); | |
| /// The name of the package dependency that triggers this custom check. | |
| /// | |
| /// The check is only executed if this dependency is listed in the package's | |
| /// `dev_dependencies`. | |
| final String dependencyName; | |
| /// The runner function that executes the custom check. | |
| final Future<List<String>> Function(RepositoryPackage) run; | |
| } |