LCOV - code coverage report
Current view: top level - src/commands/packages/commands/check/commands - licenses.dart (source / functions) Hit Total Coverage
Test: lcov.info Lines: 185 185 100.0 %
Date: 2023-11-15 10:29:52 Functions: 0 0 -

          Line data    Source code
       1             : import 'dart:io';
       2             : 
       3             : import 'package:args/args.dart';
       4             : import 'package:args/command_runner.dart';
       5             : import 'package:collection/collection.dart';
       6             : import 'package:mason/mason.dart';
       7             : import 'package:meta/meta.dart';
       8             : import 'package:package_config/package_config.dart' as package_config;
       9             : // ignore: implementation_imports
      10             : import 'package:pana/src/license_detection/license_detector.dart' as detector;
      11             : import 'package:path/path.dart' as path;
      12             : import 'package:pubspec_lock/pubspec_lock.dart';
      13             : import 'package:very_good_cli/src/pub_license/spdx_license.gen.dart';
      14             : 
      15             : /// Overrides the [package_config.findPackageConfig] function for testing.
      16             : @visibleForTesting
      17             : Future<package_config.PackageConfig?> Function(
      18             :   Directory directory,
      19             : )? findPackageConfigOverride;
      20             : 
      21             : /// Overrides the [detector.detectLicense] function for testing.
      22             : @visibleForTesting
      23             : Future<detector.Result> Function(String, double)? detectLicenseOverride;
      24             : 
      25             : /// The basename of the pubspec lock file.
      26             : @visibleForTesting
      27             : const pubspecLockBasename = 'pubspec.lock';
      28             : 
      29             : /// The URI for the pub.dev license page for the given [packageName].
      30           1 : @visibleForTesting
      31             : Uri pubLicenseUri(String packageName) =>
      32           2 :     Uri.parse('https://pub.dev/packages/$packageName/license');
      33             : 
      34             : /// The URI for the very_good_cli license documentation page.
      35             : @visibleForTesting
      36           3 : final licenseDocumentationUri = Uri.parse(
      37             :   'https://cli.vgv.dev/docs/commands/check_licenses',
      38             : );
      39             : 
      40             : /// Defines a [Map] with dependencies as keys and their licenses as values.
      41             : ///
      42             : /// If a dependency's license failed to be retrieved its license will be `null`.
      43             : typedef _DependencyLicenseMap = Map<String, Set<String>?>;
      44             : 
      45             : /// Defines a [Map] with banned dependencies as keys and their banned licenses
      46             : /// as values.
      47             : typedef _BannedDependencyLicenseMap = Map<String, Set<String>>;
      48             : 
      49             : /// {@template packages_check_licenses_command}
      50             : /// `very_good packages check licenses` command for checking packages licenses.
      51             : /// {@endtemplate}
      52             : class PackagesCheckLicensesCommand extends Command<int> {
      53             :   /// {@macro packages_check_licenses_command}
      54          15 :   PackagesCheckLicensesCommand({
      55             :     Logger? logger,
      56           2 :   }) : _logger = logger ?? Logger() {
      57          15 :     argParser
      58          15 :       ..addFlag(
      59             :         'ignore-retrieval-failures',
      60             :         help: 'Disregard licenses that failed to be retrieved.',
      61             :         negatable: false,
      62             :       )
      63          15 :       ..addMultiOption(
      64             :         'dependency-type',
      65             :         help: 'The type of dependencies to check licenses for.',
      66          15 :         allowed: [
      67             :           'direct-main',
      68             :           'direct-dev',
      69             :           'transitive',
      70             :         ],
      71          15 :         allowedHelp: {
      72             :           'direct-main': 'Check for direct main dependencies.',
      73             :           'direct-dev': 'Check for direct dev dependencies.',
      74             :           'transitive': 'Check for transitive dependencies.',
      75             :         },
      76          15 :         defaultsTo: ['direct-main'],
      77             :       )
      78          15 :       ..addMultiOption(
      79             :         'allowed',
      80             :         help: 'Only allow the use of certain licenses.',
      81             :       )
      82          15 :       ..addMultiOption(
      83             :         'forbidden',
      84             :         help: 'Deny the use of certain licenses.',
      85             :       )
      86          15 :       ..addMultiOption(
      87             :         'skip-packages',
      88             :         help: 'Skip packages from having their licenses checked.',
      89             :       );
      90             :   }
      91             : 
      92             :   final Logger _logger;
      93             : 
      94           2 :   @override
      95             :   String get description =>
      96             :       "Check packages' licenses in a Dart or Flutter project.";
      97             : 
      98          15 :   @override
      99             :   String get name => 'licenses';
     100             : 
     101           2 :   ArgResults get _argResults => argResults!;
     102             : 
     103           1 :   @override
     104             :   Future<int> run() async {
     105           4 :     if (_argResults.rest.length > 1) {
     106           1 :       usageException('Too many arguments');
     107             :     }
     108             : 
     109           2 :     final ignoreFailures = _argResults['ignore-retrieval-failures'] as bool;
     110           2 :     final dependencyTypes = _argResults['dependency-type'] as List<String>;
     111           2 :     final allowedLicenses = _argResults['allowed'] as List<String>;
     112           2 :     final forbiddenLicenses = _argResults['forbidden'] as List<String>;
     113           2 :     final skippedPackages = _argResults['skip-packages'] as List<String>;
     114             :     // TODO(alestiago): Add support for threshold.
     115             : 
     116           4 :     allowedLicenses.removeWhere((license) => license.trim().isEmpty);
     117           4 :     forbiddenLicenses.removeWhere((license) => license.trim().isEmpty);
     118             : 
     119           2 :     if (allowedLicenses.isNotEmpty && forbiddenLicenses.isNotEmpty) {
     120           1 :       usageException(
     121           3 :         '''Cannot specify both ${styleItalic.wrap('allowed')} and ${styleItalic.wrap('forbidden')} options.''',
     122             :       );
     123             :     }
     124             : 
     125           2 :     final invalidLicenses = _invalidLicenses([
     126             :       ...allowedLicenses,
     127           1 :       ...forbiddenLicenses,
     128             :     ]);
     129           1 :     if (invalidLicenses.isNotEmpty) {
     130           1 :       final documentationLink = link(
     131           1 :         uri: licenseDocumentationUri,
     132             :         message: 'documentation',
     133             :       );
     134           2 :       _logger.warn(
     135           2 :         '''Some licenses failed to be recognized: ${invalidLicenses.stringify()}. Refer to the $documentationLink for a list of valid licenses.''',
     136             :       );
     137             :     }
     138             : 
     139           7 :     final target = _argResults.rest.length == 1 ? _argResults.rest[0] : '.';
     140           4 :     final targetPath = path.normalize(Directory(target).absolute.path);
     141           1 :     final targetDirectory = Directory(targetPath);
     142           1 :     if (!targetDirectory.existsSync()) {
     143           2 :       _logger.err(
     144           1 :         '''Could not find directory at $targetPath. Specify a valid path to a Dart or Flutter project.''',
     145             :       );
     146           1 :       return ExitCode.noInput.code;
     147             :     }
     148             : 
     149           3 :     final progress = _logger.progress('Checking licenses on $targetPath');
     150             : 
     151           2 :     final pubspecLockFile = File(path.join(targetPath, pubspecLockBasename));
     152           1 :     if (!pubspecLockFile.existsSync()) {
     153           1 :       progress.cancel();
     154           3 :       _logger.err('Could not find a $pubspecLockBasename in $targetPath');
     155           1 :       return ExitCode.noInput.code;
     156             :     }
     157             : 
     158           1 :     final pubspecLock = _tryParsePubspecLock(pubspecLockFile);
     159             :     if (pubspecLock == null) {
     160           1 :       progress.cancel();
     161           3 :       _logger.err('Could not parse $pubspecLockBasename in $targetPath');
     162           1 :       return ExitCode.noInput.code;
     163             :     }
     164             : 
     165           3 :     final filteredDependencies = pubspecLock.packages.where((dependency) {
     166             :       // ignore: invalid_use_of_protected_member
     167           1 :       final isPubHosted = dependency.hosted != null;
     168             :       if (!isPubHosted) return false;
     169             : 
     170           2 :       if (skippedPackages.contains(dependency.package())) return false;
     171             : 
     172           1 :       final dependencyType = dependency.type();
     173           1 :       return (dependencyTypes.contains('direct-main') &&
     174           1 :               dependencyType == DependencyType.direct) ||
     175           1 :           (dependencyTypes.contains('direct-dev') &&
     176           1 :               dependencyType == DependencyType.development) ||
     177           1 :           (dependencyTypes.contains('transitive') &&
     178           1 :               dependencyType == DependencyType.transitive);
     179             :     });
     180             : 
     181           1 :     if (filteredDependencies.isEmpty) {
     182           1 :       progress.cancel();
     183           2 :       _logger.err(
     184           2 :         '''No hosted dependencies found in $targetPath of type: ${dependencyTypes.stringify()}.''',
     185             :       );
     186           1 :       return ExitCode.usage.code;
     187             :     }
     188             : 
     189           1 :     final packageConfig = await _tryFindPackageConfig(targetDirectory);
     190             :     if (packageConfig == null) {
     191           1 :       progress.cancel();
     192           2 :       _logger.err(
     193           1 :         '''Could not find a valid package config in $targetPath. Run `dart pub get` or `flutter pub get` to generate one.''',
     194             :       );
     195           1 :       return ExitCode.noInput.code;
     196             :     }
     197             : 
     198           1 :     final licenses = <String, Set<String>?>{};
     199             :     final detectLicense = detectLicenseOverride ?? detector.detectLicense;
     200           2 :     for (final dependency in filteredDependencies) {
     201           1 :       progress.update(
     202           6 :         '''Collecting licenses from ${licenses.length + 1} out of ${filteredDependencies.length} ${filteredDependencies.length == 1 ? 'package' : 'packages'}''',
     203             :       );
     204             : 
     205           1 :       final dependencyName = dependency.package();
     206           1 :       final cachePackageEntry = packageConfig.packages
     207           4 :           .firstWhereOrNull((package) => package.name == dependencyName);
     208             :       if (cachePackageEntry == null) {
     209             :         final errorMessage =
     210           1 :             '''[$dependencyName] Could not find cached package path.''';
     211             :         if (!ignoreFailures) {
     212           1 :           progress.cancel();
     213           2 :           _logger.err(errorMessage);
     214           1 :           return ExitCode.noInput.code;
     215             :         }
     216             : 
     217           3 :         _logger.err('\n$errorMessage');
     218           2 :         licenses[dependencyName] = {SpdxLicense.$unknown.value};
     219             :         continue;
     220             :       }
     221             : 
     222           3 :       final packagePath = path.normalize(cachePackageEntry.root.path);
     223           1 :       final packageDirectory = Directory(packagePath);
     224           1 :       if (!packageDirectory.existsSync()) {
     225             :         final errorMessage =
     226           1 :             '''[$dependencyName] Could not find package directory at $packagePath.''';
     227             :         if (!ignoreFailures) {
     228           1 :           progress.cancel();
     229           2 :           _logger.err(errorMessage);
     230           1 :           return ExitCode.noInput.code;
     231             :         }
     232             : 
     233           3 :         _logger.err('\n$errorMessage');
     234           2 :         licenses[dependencyName] = {SpdxLicense.$unknown.value};
     235             :         continue;
     236             :       }
     237             : 
     238           2 :       final licenseFile = File(path.join(packagePath, 'LICENSE'));
     239           1 :       if (!licenseFile.existsSync()) {
     240           2 :         licenses[dependencyName] = {SpdxLicense.$unknown.value};
     241             :         continue;
     242             :       }
     243             : 
     244           1 :       final licenseFileContent = licenseFile.readAsStringSync();
     245             : 
     246             :       late final detector.Result detectorResult;
     247             :       try {
     248           1 :         detectorResult = await detectLicense(licenseFileContent, 0.9);
     249             :       } catch (e) {
     250             :         final errorMessage =
     251           1 :             '''[$dependencyName] Failed to detect license from $packagePath: $e''';
     252             :         if (!ignoreFailures) {
     253           1 :           progress.cancel();
     254           2 :           _logger.err(errorMessage);
     255           1 :           return ExitCode.software.code;
     256             :         }
     257             : 
     258           3 :         _logger.err('\n$errorMessage');
     259           2 :         licenses[dependencyName] = {SpdxLicense.$unknown.value};
     260             :         continue;
     261             :       }
     262             : 
     263           1 :       final rawLicense = detectorResult.matches
     264             :           // ignore: invalid_use_of_visible_for_testing_member
     265           4 :           .map((match) => match.license.identifier)
     266           1 :           .toSet();
     267           1 :       licenses[dependencyName] = rawLicense;
     268             :     }
     269             : 
     270             :     late final _BannedDependencyLicenseMap? bannedDependencies;
     271           1 :     if (allowedLicenses.isNotEmpty) {
     272           1 :       bannedDependencies = _bannedDependencies(
     273             :         licenses: licenses,
     274           1 :         isAllowed: allowedLicenses.contains,
     275             :       );
     276           1 :     } else if (forbiddenLicenses.isNotEmpty) {
     277           1 :       bannedDependencies = _bannedDependencies(
     278             :         licenses: licenses,
     279           2 :         isAllowed: (license) => !forbiddenLicenses.contains(license),
     280             :       );
     281             :     } else {
     282             :       bannedDependencies = null;
     283             :     }
     284             : 
     285           1 :     progress.complete(
     286           1 :       _composeReport(
     287             :         licenses: licenses,
     288             :         bannedDependencies: bannedDependencies,
     289             :       ),
     290             :     );
     291             : 
     292             :     if (bannedDependencies != null) {
     293           3 :       _logger.err(_composeBannedReport(bannedDependencies));
     294           1 :       return ExitCode.config.code;
     295             :     }
     296             : 
     297           1 :     return ExitCode.success.code;
     298             :   }
     299             : }
     300             : 
     301             : /// Attempts to parse a [PubspecLock] file in the given [path].
     302             : ///
     303             : /// If [pubspecLockFile] is not readable or fails to be parsed, `null` is
     304             : /// returned.
     305           1 : PubspecLock? _tryParsePubspecLock(File pubspecLockFile) {
     306             :   try {
     307           2 :     return pubspecLockFile.readAsStringSync().loadPubspecLockFromYaml();
     308             :   } catch (e) {
     309             :     return null;
     310             :   }
     311             : }
     312             : 
     313             : /// Attempts to find a [package_config.PackageConfig] using
     314             : /// [package_config.findPackageConfig].
     315             : ///
     316             : /// If [package_config.findPackageConfig] fails to find a package config `null`
     317             : /// is returned.
     318           1 : Future<package_config.PackageConfig?> _tryFindPackageConfig(
     319             :   Directory directory,
     320             : ) async {
     321             :   try {
     322             :     final findPackageConfig =
     323             :         findPackageConfigOverride ?? package_config.findPackageConfig;
     324           1 :     return await findPackageConfig(directory);
     325             :   } catch (error) {
     326             :     return null;
     327             :   }
     328             : }
     329             : 
     330             : /// Verifies that all [licenses] are valid license inputs.
     331             : ///
     332             : /// Valid license inputs are:
     333             : /// - [SpdxLicense] values.
     334             : ///
     335             : /// Returns a [List] of invalid licenses, if all licenses are valid the list
     336             : /// will be empty.
     337           1 : List<String> _invalidLicenses(List<String> licenses) {
     338           1 :   final invalidLicenses = <String>[];
     339           2 :   for (final license in licenses) {
     340           1 :     final parsedLicense = SpdxLicense.tryParse(license);
     341             :     if (parsedLicense == null) {
     342           1 :       invalidLicenses.add(license);
     343             :     }
     344             :   }
     345             : 
     346             :   return invalidLicenses;
     347             : }
     348             : 
     349             : /// Returns a [Map] of banned dependencies and their banned licenses.
     350             : ///
     351             : /// The [Map] is lazily initialized, if no dependencies are banned `null` is
     352             : /// returned.
     353           1 : _BannedDependencyLicenseMap? _bannedDependencies({
     354             :   required _DependencyLicenseMap licenses,
     355             :   required bool Function(String license) isAllowed,
     356             : }) {
     357             :   _BannedDependencyLicenseMap? bannedDependencies;
     358           2 :   for (final dependency in licenses.entries) {
     359           1 :     final name = dependency.key;
     360           1 :     final license = dependency.value;
     361             :     if (license == null) continue;
     362             : 
     363           2 :     for (final licenseType in license) {
     364           1 :       if (isAllowed(licenseType)) continue;
     365             : 
     366           1 :       bannedDependencies ??= <String, Set<String>>{};
     367           2 :       bannedDependencies.putIfAbsent(name, () => <String>{});
     368           2 :       bannedDependencies[name]!.add(licenseType);
     369             :     }
     370             :   }
     371             : 
     372             :   return bannedDependencies;
     373             : }
     374             : 
     375             : /// Composes a human friendly [String] to report the result of the retrieved
     376             : /// licenses.
     377             : ///
     378             : /// If [bannedDependencies] is provided those banned licenses will be
     379             : /// highlighted in red.
     380           1 : String _composeReport({
     381             :   required _DependencyLicenseMap licenses,
     382             :   required _BannedDependencyLicenseMap? bannedDependencies,
     383             : }) {
     384             :   final bannedLicenseTypes =
     385           3 :       bannedDependencies?.values.fold(<String>{}, (previousValue, licenses) {
     386           1 :     if (licenses.isEmpty) return previousValue;
     387           1 :     return previousValue..addAll(licenses);
     388             :   });
     389             : 
     390             :   final licenseTypes =
     391           4 :       licenses.values.fold(<String>[], (previousValue, licenses) {
     392             :     if (licenses == null) return previousValue;
     393           1 :     return previousValue..addAll(licenses);
     394             :   });
     395             : 
     396           1 :   final licenseCount = <String, int>{};
     397           2 :   for (final license in licenseTypes) {
     398           4 :     licenseCount.update(license, (value) => value + 1, ifAbsent: () => 1);
     399             :   }
     400           1 :   final totalLicenseCount = licenseCount.values
     401           3 :       .fold(0, (previousValue, count) => previousValue + count);
     402             : 
     403           3 :   final formattedLicenseTypes = licenseTypes.toSet().map((license) {
     404             :     final colorWrapper =
     405           1 :         bannedLicenseTypes != null && bannedLicenseTypes.contains(license)
     406           1 :             ? red.wrap
     407           1 :             : green.wrap;
     408             : 
     409           1 :     final count = licenseCount[license];
     410           2 :     final formattedCount = darkGray.wrap('($count)');
     411             : 
     412           2 :     return '${colorWrapper(license)} $formattedCount';
     413             :   });
     414             : 
     415           1 :   final licenseWord = totalLicenseCount == 1 ? 'license' : 'licenses';
     416           2 :   final packageWord = licenses.length == 1 ? 'package' : 'packages';
     417           1 :   final suffix = formattedLicenseTypes.isEmpty
     418             :       ? ''
     419           3 :       : ' of type: ${formattedLicenseTypes.toList().stringify()}';
     420             : 
     421           2 :   return '''Retrieved $totalLicenseCount $licenseWord from ${licenses.length} $packageWord$suffix.''';
     422             : }
     423             : 
     424           1 : String _composeBannedReport(_BannedDependencyLicenseMap bannedDependencies) {
     425           2 :   final bannedDependenciesList = bannedDependencies.entries.fold(
     426           1 :     <String>[],
     427           1 :     (previousValue, element) {
     428           1 :       final dependencyName = element.key;
     429           1 :       final dependencyLicenses = element.value;
     430             : 
     431           1 :       final text = '$dependencyName (${link(
     432           1 :         uri: pubLicenseUri(dependencyName),
     433           2 :         message: dependencyLicenses.toList().stringify(),
     434           1 :       )})';
     435           1 :       return previousValue..add(text);
     436             :     },
     437             :   );
     438             :   final bannedLicenseTypes =
     439           3 :       bannedDependencies.values.fold(<String>{}, (previousValue, licenses) {
     440           1 :     if (licenses.isEmpty) return previousValue;
     441           1 :     return previousValue..addAll(licenses);
     442             :   });
     443             : 
     444             :   final prefix =
     445           2 :       bannedDependencies.length == 1 ? 'dependency has' : 'dependencies have';
     446             :   final suffix =
     447           2 :       bannedLicenseTypes.length == 1 ? 'a banned license' : 'banned licenses';
     448             : 
     449           3 :   return '''${bannedDependencies.length} $prefix $suffix: ${bannedDependenciesList.stringify()}.''';
     450             : }
     451             : 
     452             : extension on List<Object> {
     453           1 :   String stringify() {
     454           1 :     if (isEmpty) return '';
     455           4 :     if (length == 1) return first.toString();
     456           1 :     final last = removeLast();
     457           2 :     return '${join(', ')} and $last';
     458             :   }
     459             : }

Generated by: LCOV version 1.16