// Copyright 2013 The Flutter Authors. All rights reserved. // 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:git/git.dart'; import 'package:platform/platform.dart'; import 'package:yaml/yaml.dart'; import 'common/core.dart'; import 'common/package_looping_command.dart'; import 'common/process_runner.dart'; import 'common/repository_package.dart'; /// A command to enforce pubspec conventions across the repository. /// /// This both ensures that repo best practices for which optional fields are /// used are followed, and that the structure is consistent to make edits /// across multiple pubspec files easier. class PubspecCheckCommand extends PackageLoopingCommand { /// Creates an instance of the version check command. PubspecCheckCommand( Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), Platform platform = const LocalPlatform(), GitDir? gitDir, }) : super( packagesDir, processRunner: processRunner, platform: platform, gitDir: gitDir, ); // Section order for plugins. Because the 'flutter' section is critical // information for plugins, and usually small, it goes near the top unlike in // a normal app or package. static const List _majorPluginSections = [ 'environment:', 'flutter:', 'dependencies:', 'dev_dependencies:', 'false_secrets:', ]; static const List _majorPackageSections = [ 'environment:', 'dependencies:', 'dev_dependencies:', 'flutter:', 'false_secrets:', ]; static const String _expectedIssueLinkFormat = 'https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A'; @override final String name = 'pubspec-check'; @override final String description = 'Checks that pubspecs follow repository conventions.'; @override bool get hasLongOutput => false; @override bool get includeSubpackages => true; @override Future runForPackage(RepositoryPackage package) async { final File pubspec = package.pubspecFile; final bool passesCheck = !pubspec.existsSync() || await _checkPubspec(pubspec, package: package); if (!passesCheck) { return PackageResult.fail(); } return PackageResult.success(); } Future _checkPubspec( File pubspecFile, { required RepositoryPackage package, }) async { final String contents = pubspecFile.readAsStringSync(); final Pubspec? pubspec = _tryParsePubspec(contents); if (pubspec == null) { return false; } final List pubspecLines = contents.split('\n'); final bool isPlugin = pubspec.flutter?.containsKey('plugin') ?? false; final List sectionOrder = isPlugin ? _majorPluginSections : _majorPackageSections; bool passing = _checkSectionOrder(pubspecLines, sectionOrder); if (!passing) { printError('${indentation}Major sections should follow standard ' 'repository ordering:'); final String listIndentation = indentation * 2; printError('$listIndentation${sectionOrder.join('\n$listIndentation')}'); } if (isPlugin) { final String? implementsError = _checkForImplementsError(pubspec, package: package); if (implementsError != null) { printError('$indentation$implementsError'); passing = false; } final String? defaultPackageError = _checkForDefaultPackageError(pubspec, package: package); if (defaultPackageError != null) { printError('$indentation$defaultPackageError'); passing = false; } } // Ignore metadata that's only relevant for published packages if the // packages is not intended for publishing. if (pubspec.publishTo != 'none') { final List repositoryErrors = _checkForRepositoryLinkErrors(pubspec, package: package); if (repositoryErrors.isNotEmpty) { for (final String error in repositoryErrors) { printError('$indentation$error'); } passing = false; } if (!_checkIssueLink(pubspec)) { printError( '${indentation}A package should have an "issue_tracker" link to a ' 'search for open flutter/flutter bugs with the relevant label:\n' '${indentation * 2}$_expectedIssueLinkFormat'); passing = false; } // Don't check descriptions for federated package components other than // the app-facing package, since they are unlisted, and are expected to // have short descriptions. if (!package.isPlatformInterface && !package.isPlatformImplementation) { final String? descriptionError = _checkDescription(pubspec, package: package); if (descriptionError != null) { printError('$indentation$descriptionError'); passing = false; } } } return passing; } Pubspec? _tryParsePubspec(String pubspecContents) { try { return Pubspec.parse(pubspecContents); } on Exception catch (exception) { print(' Cannot parse pubspec.yaml: $exception'); } return null; } bool _checkSectionOrder( List pubspecLines, List sectionOrder) { int previousSectionIndex = 0; for (final String line in pubspecLines) { final int index = sectionOrder.indexOf(line); if (index == -1) { continue; } if (index < previousSectionIndex) { return false; } previousSectionIndex = index; } return true; } List _checkForRepositoryLinkErrors( Pubspec pubspec, { required RepositoryPackage package, }) { final List errorMessages = []; if (pubspec.repository == null) { errorMessages.add('Missing "repository"'); } else { final String relativePackagePath = getRelativePosixPath(package.directory, from: packagesDir.parent); if (!pubspec.repository!.path.endsWith(relativePackagePath)) { errorMessages .add('The "repository" link should end with the package path.'); } } if (pubspec.homepage != null) { errorMessages .add('Found a "homepage" entry; only "repository" should be used.'); } return errorMessages; } // Validates the "description" field for a package, returning an error // string if there are any issues. String? _checkDescription( Pubspec pubspec, { required RepositoryPackage package, }) { final String? description = pubspec.description; if (description == null) { return 'Missing "description"'; } if (description.length < 60) { return '"description" is too short. pub.dev recommends package ' 'descriptions of 60-180 characters.'; } if (description.length > 180) { return '"description" is too long. pub.dev recommends package ' 'descriptions of 60-180 characters.'; } return null; } bool _checkIssueLink(Pubspec pubspec) { return pubspec.issueTracker ?.toString() .startsWith(_expectedIssueLinkFormat) == true; } // Validates the "implements" keyword for a plugin, returning an error // string if there are any issues. // // Should only be called on plugin packages. String? _checkForImplementsError( Pubspec pubspec, { required RepositoryPackage package, }) { if (_isImplementationPackage(package)) { final String? implements = pubspec.flutter!['plugin']!['implements'] as String?; final String expectedImplements = package.directory.parent.basename; if (implements == null) { return 'Missing "implements: $expectedImplements" in "plugin" section.'; } else if (implements != expectedImplements) { return 'Expecetd "implements: $expectedImplements"; ' 'found "implements: $implements".'; } } return null; } // Validates any "default_package" entries a plugin, returning an error // string if there are any issues. // // Should only be called on plugin packages. String? _checkForDefaultPackageError( Pubspec pubspec, { required RepositoryPackage package, }) { final dynamic platformsEntry = pubspec.flutter!['plugin']!['platforms']; if (platformsEntry == null) { logWarning('Does not implement any platforms'); return null; } final YamlMap platforms = platformsEntry as YamlMap; final String packageName = package.directory.basename; // Validate that the default_package entries look correct (e.g., no typos). final Set defaultPackages = {}; for (final MapEntry platformEntry in platforms.entries) { final String? defaultPackage = platformEntry.value['default_package'] as String?; if (defaultPackage != null) { defaultPackages.add(defaultPackage); if (!defaultPackage.startsWith('${packageName}_')) { return '"$defaultPackage" is not an expected implementation name ' 'for "$packageName"'; } } } // Validate that all default_packages are also dependencies. final Iterable dependencies = pubspec.dependencies.keys; final Iterable missingPackages = defaultPackages .where((String package) => !dependencies.contains(package)); if (missingPackages.isNotEmpty) { return 'The following default_packages are missing ' 'corresponding dependencies:\n ' + missingPackages.join('\n '); } return null; } // Returns true if [packageName] appears to be an implementation package // according to repository conventions. bool _isImplementationPackage(RepositoryPackage package) { if (!package.isFederated) { return false; } final String packageName = package.directory.basename; final String parentName = package.directory.parent.basename; // A few known package names are not implementation packages; assume // anything else is. (This is done instead of listing known implementation // suffixes to allow for non-standard suffixes; e.g., to put several // platforms in one package for code-sharing purposes.) const Set nonImplementationSuffixes = { '', // App-facing package. '_platform_interface', // Platform interface package. }; final String suffix = packageName.substring(parentName.length); return !nonImplementationSuffixes.contains(suffix); } }