blob: 7f3322e4da9f80f2b991f5efb04f040c90ad690d [file]
// 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 'package:file/file.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:pub_semver/pub_semver.dart';
import 'common/core.dart';
import 'common/git_version_finder.dart';
import 'common/output_utils.dart';
import 'common/package_looping_command.dart';
import 'common/repository_package.dart';
import 'common/tool_config.dart';
import 'validators/dependabot_validator.dart';
import 'validators/gradle_validator.dart';
import 'validators/pubspec_validator.dart';
import 'validators/readme_validator.dart';
import 'validators/repo_info_validator.dart';
import 'validators/version_and_changelog_validator.dart';
const int _missingMinSdkVersionExitCode = 3;
const int _unknownVersionMappingExitCode = 4;
/// The set of possible validators.
///
/// Exposed for testing so that unit tests can target a single validator's
/// behavior via the command without having to set everything required for
/// every other validator to pass.
///
/// This is done instead of testing validators directly to ensure that testing
/// includes things like command line parsing and run initialization.
@visibleForTesting
// ignore: public_member_api_docs
enum Validator { dependabot, gradle, pubspec, readme, repoInfo, version }
/// A command to validate that packages follow various team conventions,
/// guidelines, and best practices.
///
/// This includes:
/// - repository-level metadata about packages, such as repo README and
/// auto-label entries
/// - pubspec format and contents
/// - dependabot configuration coverage
/// - gradle configurations
class ValidateCommand extends PackageLoopingCommand {
/// Creates Dependabot check command instance.
ValidateCommand(
super.packagesDir, {
this.targetedValidators,
super.processRunner,
super.platform,
super.gitDir,
}) {
argParser.addOption(
_prLabelsArg,
help:
'A comma-separated list of labels associated with this PR, '
'if applicable.\n\n'
'If supplied, labels may override or disable some checks.',
hide: true,
);
argParser.addFlag(
_checkForMissingChanges,
help:
'Validates that changes to packages include CHANGELOG and '
'version changes unless they meet an established exemption.\n\n'
'If used with --$_prLabelsArg, this should only be used in '
'pre-submit CI checks, to prevent post-submit breakage '
'when labels are no longer applicable.',
);
argParser.addFlag(
_ignorePlatformInterfaceBreaks,
help:
'Bypasses the check that platform interfaces do not contain '
'breaking changes.\n\n'
'This is only intended for use in post-submit CI checks, to '
'prevent post-submit breakage when overriding the check with '
'labels. Pre-submit checks should always use '
'--$_prLabelsArg instead.',
hide: true,
);
}
static const String _prLabelsArg = 'pr-labels';
static const String _checkForMissingChanges = 'check-for-missing-changes';
static const String _ignorePlatformInterfaceBreaks = 'ignore-platform-interface-breaks';
/// The validators to run.
///
/// If null, all validators are run.
final Set<Validator>? targetedValidators;
late Directory _repoRoot;
late final GitVersionFinder _gitVersionFinder;
late final Set<String> _prLabels = _getPRLabels();
/// Data from the root README.md table of packages.
final Map<String, List<String>> _readmeTableEntries = <String, List<String>>{};
/// Packages with entries in labeler.yml.
final Set<String> _autoLabeledPackages = <String>{};
/// The set of directories covered by the repo's Dependabot configuration.
late DependabotCoverage _dependabotCoverage;
/// The set of packages that are allowed as dependencies.
final AllowPackageLists _allowedPackages = (
local: <String>{},
pinned: <String>{},
unpinned: <String>{},
);
/// The minimum version of Flutter that is allowed for any package.
Version? _minMinFlutterVersion;
/// The minimum version of Dart that is allowed for any package.
Version? _minMinDartVersion;
@override
final String name = 'validate';
@override
final String description = 'Checks that packages follow team guidelines.';
@override
final PackageLoopingType packageLoopingType = PackageLoopingType.includeAllSubpackages;
@override
final bool hasLongOutput = false;
@override
Future<void> initializeRun() async {
_gitVersionFinder = await retrieveVersionFinder();
_repoRoot = packagesDir.fileSystem.directory((await gitDir).path);
if (_shouldRun(Validator.repoInfo)) {
// Extract all of the repo-level README.md table entries.
_readmeTableEntries.addAll(
RepoInfoValidator.loadReadmeTableEntries(
repoRoot: _repoRoot,
packagesDir: packagesDir,
thirdPartyPackagesDir: thirdPartyPackagesDir,
),
);
// Extract all of the labeler.yml package entries.
_autoLabeledPackages.addAll(RepoInfoValidator.loadAutoLabeledPackages(repoRoot: _repoRoot));
}
if (_shouldRun(Validator.pubspec)) {
await _loadAllowedDependencies();
final (flutter: Version? minFlutter, dart: Version? minDart) = _loadMinMinSdkVersions();
_minMinFlutterVersion = minFlutter;
_minMinDartVersion =
minDart ?? (minFlutter == null ? null : getDartSdkForFlutterSdk(minFlutter));
if (_minMinDartVersion == null) {
printError(
'Dart SDK version for Flutter SDK version $_minMinFlutterVersion is unknown. '
'Please update the map for getDartSdkForFlutterSdk with the '
'corresponding Dart version.',
);
throw ToolExit(_unknownVersionMappingExitCode);
}
}
if (_shouldRun(Validator.dependabot)) {
_dependabotCoverage = DependabotValidator.loadConfig(repoRoot: _repoRoot);
}
}
@override
Future<PackageResult> runForPackage(RepositoryPackage package) async {
final List<String> errors = [
if (_shouldRun(Validator.repoInfo)) ...await _validateRepoInfo(package),
if (_shouldRun(Validator.pubspec)) ...await _validatePubspec(package),
if (_shouldRun(Validator.readme)) ...await _validateReadme(package),
if (_shouldRun(Validator.dependabot)) ...await _validateDependabot(package),
if (_shouldRun(Validator.gradle)) ...await _validateGradle(package),
if (_shouldRun(Validator.version)) ...await _validateVersionAndChangelog(package),
];
return errors.isEmpty ? PackageResult.success() : PackageResult.fail(errors);
}
bool _shouldRun(Validator validator) => targetedValidators?.contains(validator) ?? true;
/// Runs repo-level checks.
Future<List<String>> _validateRepoInfo(RepositoryPackage package) async {
// Repo-level checks only apply to top-level packages.
if (!package.isTopLevel) {
return <String>[];
}
final validator = RepoInfoValidator(
readmeTableEntries: _readmeTableEntries,
autoLabeledPackages: _autoLabeledPackages,
gitDir: await gitDir,
repoRoot: _repoRoot,
indentation: indentation,
);
return validator.validatePackage(package);
}
Future<List<String>> _validateDependabot(RepositoryPackage package) async {
final validator = DependabotValidator(
coverage: _dependabotCoverage,
path: path,
repoRoot: _repoRoot,
indentation: indentation,
);
return validator.validateDependabotCoverage(package);
}
Future<List<String>> _validateGradle(RepositoryPackage package) async {
if (!package.platformDirectory(FlutterPlatform.android).existsSync()) {
return [];
}
final validator = GradleValidator(path: path, indentation: indentation);
return validator.validateGradle(package);
}
Future<List<String>> _validatePubspec(RepositoryPackage package) async {
final validator = PubspecValidator(
path: path,
indentation: indentation,
warningLogger: printWarning,
allowedPackages: _allowedPackages,
repoRoot: rootDir,
minMinFlutterVersion: _minMinFlutterVersion,
minMinDartVersion: _minMinDartVersion,
);
return validator.validatePubspec(package);
}
Future<List<String>> _validateReadme(RepositoryPackage package) async {
// TODO(stuartmorgan): Consider restructuring this to just check the
// current package's README for all packages, now that this is part of an
// includeAllSubpackages command. The current logic is from when it was
// its own top-level-only command.
if (!package.isTopLevel) {
return [];
}
final validator = ReadmeValidator(
path: path,
indentation: indentation,
warningLogger: printWarning,
);
final List<String> errors = validator.validateReadme(
package.readmeFile,
mainPackage: package,
isExample: false,
);
for (final RepositoryPackage packageToCheck in package.getExamples()) {
errors.addAll(
validator.validateReadme(packageToCheck.readmeFile, mainPackage: package, isExample: true),
);
}
// If there's an example/README.md for a multi-example package, validate
// that as well, as it will be shown on pub.dev.
final Directory exampleDir = package.directory.childDirectory('example');
final File exampleDirReadme = exampleDir.childFile('README.md');
if (exampleDir.existsSync() && !isPackage(exampleDir)) {
errors.addAll(
validator.validateReadme(exampleDirReadme, mainPackage: package, isExample: true),
);
}
return errors;
}
Future<List<String>> _validateVersionAndChangelog(RepositoryPackage package) async {
if (!package.isTopLevel) {
return [];
}
final Directory repoRoot = packagesDir.fileSystem.directory((await gitDir).path);
final validator = VersionAndChangelogValidator(
path: path,
indentation: indentation,
warningLogger: logWarning,
gitVersionFinder: _gitVersionFinder,
repoRoot: repoRoot,
changedFiles: changedFiles,
prLabels: _prLabels,
);
return validator.validateChangelogAndVersion(
package,
checkForMissingChanges: getBoolArg(_checkForMissingChanges),
ignorePlatformInterfaceBreaks: getBoolArg(_ignorePlatformInterfaceBreaks),
);
}
Stream<String> _findAllPublishedPackages() async* {
for (final File pubspecFile
in (await _repoRoot.list(recursive: true, followLinks: false).toList())
.whereType<File>()
.where((File entity) => p.basename(entity.path) == 'pubspec.yaml')) {
final Pubspec? pubspec = _tryParsePubspec(pubspecFile.readAsStringSync());
if (pubspec != null && pubspec.publishTo != 'none') {
yield pubspec.name;
}
}
}
Future<void> _loadAllowedDependencies() async {
// Find all local, published packages.
_allowedPackages.local.addAll(await _findAllPublishedPackages().toList());
final ({List<String> pinned, List<String> unpinned}) allowedDeps = getAllowedDependencies(
_repoRoot,
);
_allowedPackages.unpinned.addAll(allowedDeps.unpinned);
_allowedPackages.pinned.addAll(allowedDeps.pinned);
}
({Version? flutter, Version? dart}) _loadMinMinSdkVersions() {
final String? minFlutter = getMinFlutterVersion(_repoRoot);
final String? minDart = getMinDartVersion(_repoRoot);
if (minFlutter == null && minDart == null) {
printError(
'Either min_flutter or min_dart must be provided '
'in the repo tool configuration.',
);
throw ToolExit(_missingMinSdkVersionExitCode);
}
return (
flutter: minFlutter == null ? null : Version.parse(minFlutter),
dart: minDart == null ? null : Version.parse(minDart),
);
}
Pubspec? _tryParsePubspec(String pubspecContents) {
try {
return Pubspec.parse(pubspecContents);
} on Exception catch (exception) {
print(' Cannot parse pubspec.yaml: $exception');
}
return null;
}
/// Returns the labels associated with this PR, if any, or an empty set
/// if that flag is not provided.
Set<String> _getPRLabels() {
final String labels = getStringArg(_prLabelsArg);
if (labels.isEmpty) {
return <String>{};
}
return labels.split(',').map((String label) => label.trim()).toSet();
}
}