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

Generated by: LCOV version 1.15