Repository: VeryGoodOpenSource/very_good_cli
Pull request #850 (2023-10-13 12:56:31)

feat: add `--forbidden` to `check licenses`
<!--
  Thanks for contributing!

  Provide a description of your changes below and a general summary in the title

  Please look at the following checklist to ensure that your PR can be accepted quickly:
-->

> Should be merged after #848

## Description

Adds a multi option (--forbidden) on very_good packages check licenses.

This change anticipates https://github.com/VeryGoodOpenSource/very_good_cli/issues/771

## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [X] ✨ New feature (non-breaking change which adds functionality)
- [ ] 🛠️ Bug fix (non-breaking change which fixes an issue)
- [ ] ❌ Breaking change (fix or feature that would cause existing functionality to change)
- [ ] 🧹 Code refactor
- [ ] ✅ Build configuration change
- [ ] 📝 Documentation
- [ ] 🗑️ Chore



Patch:
diff --git a/lib/src/commands/packages/commands/check/commands/licenses.dart b/lib/src/commands/packages/commands/check/commands/licenses.dart
index 5c4bdf1..ca3db69 100644
--- a/lib/src/commands/packages/commands/check/commands/licenses.dart
+++ b/lib/src/commands/packages/commands/check/commands/licenses.dart
@@ -61,6 +61,10 @@ class PackagesCheckLicensesCommand extends Command<int> {
       ..addMultiOption(
         'allowed',
         help: 'Whitelist of allowed licenses.',
+      )
+      ..addMultiOption(
+        'forbidden',
+        help: 'Blacklist of not allowed licenses.',
       );
   }
 
@@ -89,11 +93,21 @@ class PackagesCheckLicensesCommand extends Command<int> {
     final ignoreFailures = _argResults['ignore-failures'] as bool;
     final dependencyTypes = _argResults['dependency-type'] as List<String>;
     final allowedLicenses = _argResults['allowed'] as List<String>;
+    final forbiddenLicenses = _argResults['forbidden'] as List<String>;
+
+    if (allowedLicenses.isNotEmpty && forbiddenLicenses.isNotEmpty) {
+      usageException(
+        '''Cannot specify both ${styleItalic.wrap('allowed')} and ${styleItalic.wrap('forbidden')} options.''',
+      );
+    }
 
-    final invalidLicenses = _invalidLicenses(allowedLicenses);
+    final invalidLicenses = _invalidLicenses([
+      ...allowedLicenses,
+      ...forbiddenLicenses,
+    ]);
     if (invalidLicenses.isNotEmpty) {
       _logger.warn(
-        '''Some ${styleItalic.wrap('allowed')} licenses failed to be recognized: ${invalidLicenses.stringify()}. Refer to the documentation for a list of valid licenses.''',
+        '''Some licenses failed to be recognized: ${invalidLicenses.stringify()}. Refer to the documentation for a list of valid licenses.''',
       );
     }
 
@@ -172,9 +186,20 @@ class PackagesCheckLicensesCommand extends Command<int> {
       }
     }
 
-    final bannedDependencies = allowedLicenses.isNotEmpty
-        ? _bannedDependencies(licenses, allowedLicenses.contains)
-        : null;
+    late final _BannedDependencyLicenseMap? bannedDependencies;
+    if (allowedLicenses.isNotEmpty) {
+      bannedDependencies = _bannedDependencies(
+        licenses: licenses,
+        isAllowed: allowedLicenses.contains,
+      );
+    } else if (forbiddenLicenses.isNotEmpty) {
+      bannedDependencies = _bannedDependencies(
+        licenses: licenses,
+        isAllowed: (license) => !forbiddenLicenses.contains(license),
+      );
+    } else {
+      bannedDependencies = null;
+    }
 
     progress.complete(
       _composeReport(
@@ -227,10 +252,10 @@ List<String> _invalidLicenses(List<String> licenses) {
 ///
 /// The [Map] is lazily initialized, if no dependencies are banned `null` is
 /// returned.
-_BannedDependencyLicenseMap? _bannedDependencies(
-  _DependencyLicenseMap licenses,
-  bool Function(String license) isAllowed,
-) {
+_BannedDependencyLicenseMap? _bannedDependencies({
+  required _DependencyLicenseMap licenses,
+  required bool Function(String license) isAllowed,
+}) {
   _BannedDependencyLicenseMap? bannedDependencies;
   for (final dependency in licenses.entries) {
     final name = dependency.key;


Test patch:
diff --git a/test/src/commands/packages/commands/check/commands/licenses_test.dart b/test/src/commands/packages/commands/check/commands/licenses_test.dart
index 1794ed1..1f8491f 100644
--- a/test/src/commands/packages/commands/check/commands/licenses_test.dart
+++ b/test/src/commands/packages/commands/check/commands/licenses_test.dart
@@ -25,7 +25,8 @@ const _expectedPackagesCheckLicensesUsage = [
       '''          [direct-main] (default)    Check for direct main dependencies.\n'''
       '''          [transitive]               Check for transitive dependencies.\n'''
       '\n'
-      '    --allowed                        Whitelist of allowed licenses.\n'
+      '''    --allowed                        Whitelist of allowed licenses.\n'''
+      '''    --forbidden                      Blacklist of not allowed licenses.\n'''
       '\n'
       'Run "very_good help" to see global options.'
 ];
@@ -67,16 +68,29 @@ void main() {
       expect(command.hidden, isTrue);
     });
 
-    test(
-      '''throws usage exception when too many rest arguments are provided''',
-      withRunner(
-          (commandRunner, logger, pubUpdater, pubLicense, printLogs) async {
-        final result = await commandRunner.run(
-          [...commandArguments, 'arg1', 'arg2'],
-        );
-        expect(result, equals(ExitCode.usage.code));
-      }),
-    );
+    group('throws usage exception', () {
+      test(
+        '''when too many rest arguments are provided''',
+        withRunner(
+            (commandRunner, logger, pubUpdater, pubLicense, printLogs) async {
+          final result = await commandRunner.run(
+            [...commandArguments, 'arg1', 'arg2'],
+          );
+          expect(result, equals(ExitCode.usage.code));
+        }),
+      );
+
+      test(
+        '''when allowed and forbidden are used simultaneously''',
+        withRunner(
+            (commandRunner, logger, pubUpdater, pubLicense, printLogs) async {
+          final result = await commandRunner.run(
+            [...commandArguments, '--allowed', 'MIT', '--forbidden', 'BSD'],
+          );
+          expect(result, equals(ExitCode.usage.code));
+        }),
+      );
+    });
 
     group(
       'reports licenses',
@@ -603,7 +617,7 @@ void main() {
           );
 
           const warningMessage =
-              '''Some allowed licenses failed to be recognized: $invalidLicense. Refer to the documentation for a list of valid licenses.''';
+              '''Some licenses failed to be recognized: $invalidLicense. Refer to the documentation for a list of valid licenses.''';
           verify(
             () => logger.warn(warningMessage),
           ).called(1);
@@ -729,6 +743,160 @@ void main() {
       });
     });
 
+    group('forbidden', () {
+      const forbiddenArgument = '--forbidden';
+
+      test(
+        'warns when a license is not recognized',
+        withRunner(
+            (commandRunner, logger, pubUpdater, pubLicense, printLogs) async {
+          final tempDirectory = Directory.systemTemp.createTempSync();
+          addTearDown(() => tempDirectory.deleteSync(recursive: true));
+
+          File(path.join(tempDirectory.path, pubspecLockBasename))
+              .writeAsStringSync(_validPubspecLockContent);
+
+          when(() => logger.progress(any())).thenReturn(progress);
+
+          const invalidLicense = 'not_a_valid_license';
+          await commandRunner.run(
+            [
+              ...commandArguments,
+              forbiddenArgument,
+              invalidLicense,
+              tempDirectory.path,
+            ],
+          );
+
+          const warningMessage =
+              '''Some licenses failed to be recognized: $invalidLicense. Refer to the documentation for a list of valid licenses.''';
+          verify(
+            () => logger.warn(warningMessage),
+          ).called(1);
+        }),
+      );
+
+      test(
+        'exits when a license is forbidden',
+        withRunner(
+            (commandRunner, logger, pubUpdater, pubLicense, printLogs) async {
+          final tempDirectory = Directory.systemTemp.createTempSync();
+          addTearDown(() => tempDirectory.deleteSync(recursive: true));
+
+          File(path.join(tempDirectory.path, pubspecLockBasename))
+              .writeAsStringSync(_validPubspecLockContent);
+
+          when(() => logger.progress(any())).thenReturn(progress);
+
+          when(() => pubLicense.getLicense(any()))
+              .thenAnswer((_) => Future.value({'BSD'}));
+
+          final result = await commandRunner.run(
+            [
+              ...commandArguments,
+              forbiddenArgument,
+              'BSD',
+              tempDirectory.path,
+            ],
+          );
+
+          expect(result, ExitCode.config.code);
+        }),
+      );
+
+      group('report', () {
+        test(
+          'when a single license is forbidden',
+          withRunner(
+              (commandRunner, logger, pubUpdater, pubLicense, printLogs) async {
+            final tempDirectory = Directory.systemTemp.createTempSync();
+            addTearDown(() => tempDirectory.deleteSync(recursive: true));
+
+            File(path.join(tempDirectory.path, pubspecLockBasename))
+                .writeAsStringSync(_validMultiplePubspecLockContent);
+
+            when(() => logger.progress(any())).thenReturn(progress);
+
+            const dependency1Name = 'very_good_test_runner';
+            when(() => pubLicense.getLicense(dependency1Name))
+                .thenAnswer((_) => Future.value({'MIT'}));
+            final license1LinkedMessage = link(
+              uri: pubLicenseUri(dependency1Name),
+              message: 'MIT',
+            );
+
+            const dependency2Name = 'cli_completion';
+            when(() => pubLicense.getLicense(dependency2Name))
+                .thenAnswer((_) => Future.value({'BSD'}));
+
+            await commandRunner.run(
+              [
+                ...commandArguments,
+                forbiddenArgument,
+                'MIT',
+                tempDirectory.path,
+              ],
+            );
+
+            final errorMessage =
+                '''1 dependency has a banned license: $dependency1Name ($license1LinkedMessage).''';
+
+            verify(
+              () => logger.err(errorMessage),
+            ).called(1);
+          }),
+        );
+
+        test(
+          'when multiple licenses are forbidden',
+          withRunner(
+              (commandRunner, logger, pubUpdater, pubLicense, printLogs) async {
+            final tempDirectory = Directory.systemTemp.createTempSync();
+            addTearDown(() => tempDirectory.deleteSync(recursive: true));
+
+            File(path.join(tempDirectory.path, pubspecLockBasename))
+                .writeAsStringSync(_validMultiplePubspecLockContent);
+
+            when(() => logger.progress(any())).thenReturn(progress);
+
+            const dependency1Name = 'very_good_test_runner';
+            when(() => pubLicense.getLicense(dependency1Name))
+                .thenAnswer((_) => Future.value({'MIT'}));
+            final license1LinkedMessage = link(
+              uri: pubLicenseUri(dependency1Name),
+              message: 'MIT',
+            );
+
+            const dependency2Name = 'cli_completion';
+            when(() => pubLicense.getLicense(dependency2Name))
+                .thenAnswer((_) => Future.value({'BSD'}));
+            final license2LinkedMessage = link(
+              uri: pubLicenseUri(dependency2Name),
+              message: 'BSD',
+            );
+
+            await commandRunner.run(
+              [
+                ...commandArguments,
+                forbiddenArgument,
+                'BSD',
+                forbiddenArgument,
+                'MIT',
+                tempDirectory.path,
+              ],
+            );
+
+            final errorMessage =
+                '''2 dependencies have banned licenses: $dependency1Name ($license1LinkedMessage) and $dependency2Name ($license2LinkedMessage).''';
+
+            verify(
+              () => logger.err(errorMessage),
+            ).called(1);
+          }),
+        );
+      });
+    });
+
     group('exits with error', () {
       test(
         'when it did not find a pubspec.lock file at the target path',