Repository: VeryGoodOpenSource/very_good_cli
Pull request #847 (2023-10-10 11:40:10)

feat: add `--dependency-type` to `check licenses`
> Should be merged after #846 

<!--
  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:
-->

## Description

Adds a multi option (--dependecy-type) 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 439e83d..d18d06b 100644
--- a/lib/src/commands/packages/commands/check/commands/licenses.dart
+++ b/lib/src/commands/packages/commands/check/commands/licenses.dart
@@ -22,11 +22,27 @@ class PackagesCheckLicensesCommand extends Command<int> {
     PubLicense? pubLicense,
   })  : _logger = logger ?? Logger(),
         _pubLicense = pubLicense ?? PubLicense() {
-    argParser.addFlag(
-      'ignore-failures',
-      help: 'Ignore any license that failed to be retrieved.',
-      negatable: false,
-    );
+    argParser
+      ..addFlag(
+        'ignore-failures',
+        help: 'Ignore any license that failed to be retrieved.',
+        negatable: false,
+      )
+      ..addMultiOption(
+        'dependency-type',
+        help: 'The type of dependencies to check licenses for.',
+        allowed: [
+          'direct-main',
+          'direct-dev',
+          'transitive',
+        ],
+        allowedHelp: {
+          'direct-main': 'Check for direct main dependencies.',
+          'direct-dev': 'Check for direct dev dependencies.',
+          'transitive': 'Check for transitive dependencies.',
+        },
+        defaultsTo: ['direct-main'],
+      );
   }
 
   final Logger _logger;
@@ -52,6 +68,7 @@ class PackagesCheckLicensesCommand extends Command<int> {
     }
 
     final ignoreFailures = _argResults['ignore-failures'] as bool;
+    final dependencyTypes = _argResults['dependency-type'] as List<String>;
 
     final target = _argResults.rest.length == 1 ? _argResults.rest[0] : '.';
     final targetPath = path.normalize(Directory(target).absolute.path);
@@ -72,12 +89,25 @@ class PackagesCheckLicensesCommand extends Command<int> {
       return ExitCode.noInput.code;
     }
 
-    final filteredDependencies =
-        pubspecLock.packages.where(_isHostedDirectDependency);
+    final filteredDependencies = pubspecLock.packages.where((dependency) {
+      // ignore: invalid_use_of_protected_member
+      final isPubHosted = dependency.hosted != null;
+      if (!isPubHosted) return false;
+
+      final dependencyType = dependency.type();
+      return (dependencyTypes.contains('direct-main') &&
+              dependencyType == DependencyType.direct) ||
+          (dependencyTypes.contains('direct-dev') &&
+              dependencyType == DependencyType.development) ||
+          (dependencyTypes.contains('transitive') &&
+              dependencyType == DependencyType.transitive);
+    });
 
     if (filteredDependencies.isEmpty) {
       progress.cancel();
-      _logger.err('No hosted direct dependencies found in $targetPath');
+      _logger.err(
+        '''No hosted dependencies found in $targetPath of type: ${dependencyTypes.stringify()}.''',
+      );
       return ExitCode.usage.code;
     }
 
@@ -133,15 +163,6 @@ PubspecLock? _tryParsePubspecLock(File pubspecLockFile) {
   }
 }
 
-bool _isHostedDirectDependency(
-  PackageDependency dependency,
-) {
-  // ignore: invalid_use_of_protected_member
-  final isPubHostedDependency = dependency.hosted != null;
-  final isDirectDependency = dependency.type() == DependencyType.direct;
-  return isPubHostedDependency && isDirectDependency;
-}
-
 /// Composes a human friendly [String] to report the result of the retrieved
 /// licenses.
 String _composeReport(Map<String, Set<String>?> licenses) {


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 226aad7..d5b0d7c 100644
--- a/test/src/commands/packages/commands/check/commands/licenses_test.dart
+++ b/test/src/commands/packages/commands/check/commands/licenses_test.dart
@@ -1,5 +1,6 @@
 import 'dart:io';
 
+import 'package:collection/collection.dart';
 import 'package:mason_logger/mason_logger.dart';
 import 'package:mocktail/mocktail.dart';
 import 'package:path/path.dart' as path;
@@ -16,8 +17,13 @@ const _expectedPackagesCheckLicensesUsage = [
   'Check packages licenses in a Dart or Flutter project.\n'
       '\n'
       'Usage: very_good packages check licenses [arguments]\n'
-      '-h, --help               Print this usage information.\n'
-      '''    --ignore-failures    Ignore any license that failed to be retrieved.\n'''
+      '-h, --help                           Print this usage information.\n'
+      '''    --ignore-failures                Ignore any license that failed to be retrieved.\n'''
+      '''    --dependency-type                The type of dependencies to check licenses for.\n'''
+      '\n'
+      '''          [direct-dev]               Check for direct dev dependencies.\n'''
+      '''          [direct-main] (default)    Check for direct main dependencies.\n'''
+      '''          [transitive]               Check for transitive dependencies.\n'''
       '\n'
       'Run "very_good help" to see global options.'
 ];
@@ -155,9 +161,6 @@ void main() {
 
             when(() => logger.progress(any())).thenReturn(progress);
 
-            when(() => pubLicense.getLicense(any())).thenAnswer(
-              (_) => Future.value({'MIT'}),
-            );
             const failedDependencyName = 'very_good_test_runner';
             const exception = PubLicenseException('message');
             when(() => pubLicense.getLicense(failedDependencyName))
@@ -199,9 +202,6 @@ void main() {
 
             when(() => logger.progress(any())).thenReturn(progress);
 
-            when(() => pubLicense.getLicense(any())).thenAnswer(
-              (_) => Future.value({'MIT'}),
-            );
             const failedDependencyName = 'very_good_test_runner';
             const error = 'error';
             when(() => pubLicense.getLicense(failedDependencyName))
@@ -283,6 +283,298 @@ void main() {
       );
     });
 
+    group('dependency-type', () {
+      const dependencyTypeArgument = '--dependency-type';
+      const dependencyTypeMainDirectOption = 'direct-main';
+      const dependencyTypeDevDirectOption = 'direct-dev';
+      const dependencyTypeTransitiveOption = 'transitive';
+
+      group('throws usage exception', () {
+        test(
+          'when no option is provided',
+          withRunner(
+              (commandRunner, logger, pubUpdater, pubLicense, printLogs) async {
+            final result = await commandRunner.run(
+              [...commandArguments, dependencyTypeArgument],
+            );
+            expect(result, equals(ExitCode.usage.code));
+          }),
+        );
+
+        test(
+          'when invalid option is provided',
+          withRunner(
+              (commandRunner, logger, pubUpdater, pubLicense, printLogs) async {
+            final result = await commandRunner.run(
+              [...commandArguments, dependencyTypeArgument, 'invalid'],
+            );
+            expect(result, equals(ExitCode.usage.code));
+          }),
+        );
+
+        group('reports licenses', () {
+          /// A map of dependencies by type from [_validPubspecLockContent].
+          const dependenciesByType = {
+            dependencyTypeMainDirectOption: ['very_good_test_runner'],
+            dependencyTypeDevDirectOption: ['very_good_analysis'],
+            dependencyTypeTransitiveOption: ['yaml'],
+          };
+
+          group('on developer main dependencies only', () {
+            test(
+              'by default',
+              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);
+
+                final result = await commandRunner.run(
+                  [...commandArguments, tempDirectory.path],
+                );
+
+                final packageNames =
+                    verify(() => pubLicense.getLicense(captureAny()))
+                        .captured
+                        .cast<String>();
+
+                expect(
+                  packageNames,
+                  equals(dependenciesByType[dependencyTypeMainDirectOption]),
+                );
+
+                verify(
+                  () => progress.update('Collecting licenses of 0/1 packages'),
+                ).called(1);
+                verify(
+                  () => progress.complete(
+                    'Retrieved 1 license from 1 package of type: MIT.',
+                  ),
+                ).called(1);
+
+                expect(result, equals(ExitCode.success.code));
+              }),
+            );
+
+            test(
+              'when specified',
+              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);
+
+                final result = await commandRunner.run(
+                  [
+                    ...commandArguments,
+                    dependencyTypeArgument,
+                    dependencyTypeMainDirectOption,
+                    tempDirectory.path,
+                  ],
+                );
+
+                final packageNames =
+                    verify(() => pubLicense.getLicense(captureAny()))
+                        .captured
+                        .cast<String>();
+
+                expect(
+                  packageNames,
+                  equals(dependenciesByType[dependencyTypeMainDirectOption]),
+                );
+
+                verify(
+                  () => progress.update('Collecting licenses of 0/1 packages'),
+                ).called(1);
+                verify(
+                  () => progress.complete(
+                    'Retrieved 1 license from 1 package of type: MIT.',
+                  ),
+                ).called(1);
+
+                expect(result, equals(ExitCode.success.code));
+              }),
+            );
+          });
+
+          test(
+            'on developer dev dependencies only',
+            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);
+
+              final result = await commandRunner.run(
+                [
+                  ...commandArguments,
+                  dependencyTypeArgument,
+                  dependencyTypeDevDirectOption,
+                  tempDirectory.path,
+                ],
+              );
+
+              final packageNames =
+                  verify(() => pubLicense.getLicense(captureAny()))
+                      .captured
+                      .cast<String>();
+
+              expect(
+                packageNames,
+                equals(dependenciesByType[dependencyTypeDevDirectOption]),
+              );
+
+              verify(
+                () => progress.update('Collecting licenses of 0/1 packages'),
+              ).called(1);
+              verify(
+                () => progress.complete(
+                  'Retrieved 1 license from 1 package of type: MIT.',
+                ),
+              ).called(1);
+
+              expect(result, equals(ExitCode.success.code));
+            }),
+          );
+
+          test(
+            'on transitive dependencies only',
+            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);
+
+              final result = await commandRunner.run(
+                [
+                  ...commandArguments,
+                  dependencyTypeArgument,
+                  dependencyTypeTransitiveOption,
+                  tempDirectory.path,
+                ],
+              );
+
+              final packageNames =
+                  verify(() => pubLicense.getLicense(captureAny()))
+                      .captured
+                      .cast<String>();
+
+              expect(
+                packageNames,
+                equals(dependenciesByType[dependencyTypeTransitiveOption]),
+              );
+
+              verify(
+                () => progress.update('Collecting licenses of 0/1 packages'),
+              ).called(1);
+              verify(
+                () => progress.complete(
+                  'Retrieved 1 license from 1 package of type: MIT.',
+                ),
+              ).called(1);
+
+              expect(result, equals(ExitCode.success.code));
+            }),
+          );
+
+          test(
+            'on all dependencies',
+            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);
+
+              final result = await commandRunner.run(
+                [
+                  ...commandArguments,
+                  dependencyTypeArgument,
+                  dependencyTypeDevDirectOption,
+                  dependencyTypeArgument,
+                  dependencyTypeTransitiveOption,
+                  dependencyTypeArgument,
+                  dependencyTypeMainDirectOption,
+                  tempDirectory.path,
+                ],
+              );
+
+              final packageNames =
+                  verify(() => pubLicense.getLicense(captureAny()))
+                      .captured
+                      .cast<String>();
+
+              expect(
+                packageNames,
+                equals([...dependenciesByType.values].flattened),
+              );
+
+              verify(
+                () => progress.update('Collecting licenses of 0/3 packages'),
+              ).called(1);
+              verify(
+                () => progress.update('Collecting licenses of 1/3 packages'),
+              ).called(1);
+              verify(
+                () => progress.update('Collecting licenses of 2/3 packages'),
+              ).called(1);
+              verify(
+                () => progress.complete(
+                  'Retrieved 3 licenses from 3 packages of type: MIT.',
+                ),
+              ).called(1);
+
+              expect(result, equals(ExitCode.success.code));
+            }),
+          );
+        });
+      });
+    });
+
     group('exits with error', () {
       test(
         'when it did not find a pubspec.lock file at the target path',
@@ -334,7 +626,7 @@ void main() {
       );
 
       test(
-        'when no hosted direct dependencies are found',
+        'when no dependencies of type are found',
         withRunner(
             (commandRunner, logger, pubUpdater, pubLicense, printLogs) async {
           final tempDirectory = Directory.systemTemp.createTempSync();
@@ -350,7 +642,7 @@ void main() {
           );
 
           final errorMessage =
-              'No hosted direct dependencies found in ${tempDirectory.path}';
+              '''No hosted dependencies found in ${tempDirectory.path} of type: direct-main.''';
           verify(() => logger.err(errorMessage)).called(1);
 
           verify(() => progress.cancel()).called(1);
@@ -439,14 +731,6 @@ void main() {
 /// - one hosted transitive dependency
 const _validPubspecLockContent = '''
 packages:
-  very_good_analysis:
-    dependency: "direct dev"
-    description:
-      name: very_good_analysis
-      sha256: "9ae7f3a3bd5764fb021b335ca28a34f040cd0ab6eec00a1b213b445dae58a4b8"
-      url: "https://pub.dev"
-    source: hosted
-    version: "5.1.0"
   very_good_test_runner:
     dependency: "direct main"
     description:
@@ -455,6 +739,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "0.1.2"
+  very_good_analysis:
+    dependency: "direct dev"
+    description:
+      name: very_good_analysis
+      sha256: "9ae7f3a3bd5764fb021b335ca28a34f040cd0ab6eec00a1b213b445dae58a4b8"
+      url: "https://pub.dev"
+    source: hosted
+    version: "5.1.0"
   yaml:
     dependency: transitive
     description: