From 2a584468681c81fc70d061b4a41f5e393ea0d33a Mon Sep 17 00:00:00 2001 From: Meylis Annagurbanov Date: Tue, 30 Dec 2025 00:24:02 +0500 Subject: [PATCH 1/4] feat: add workspace support for packages check licenses When a pubspec.yaml declares a workspace property, the command now recursively collects dependencies from all workspace members and checks their licenses using the root pubspec.lock. This enables license checking in monorepo projects that use Dart's pub workspace feature. Closes #1273 --- .../commands/check/commands/licenses.dart | 108 +++++++ lib/src/pubspec/pubspec.dart | 206 +++++++++++++ .../check/commands/licenses_test.dart | 282 +++++++++++++++++ test/src/pubspec/pubspec_test.dart | 285 ++++++++++++++++++ 4 files changed, 881 insertions(+) create mode 100644 lib/src/pubspec/pubspec.dart create mode 100644 test/src/pubspec/pubspec_test.dart diff --git a/lib/src/commands/packages/commands/check/commands/licenses.dart b/lib/src/commands/packages/commands/check/commands/licenses.dart index 8f345bbf6..ac0cd1fad 100644 --- a/lib/src/commands/packages/commands/check/commands/licenses.dart +++ b/lib/src/commands/packages/commands/check/commands/licenses.dart @@ -20,6 +20,7 @@ import 'package:package_config/package_config.dart' as package_config; import 'package:pana/src/license_detection/license_detector.dart' as detector; import 'package:path/path.dart' as path; import 'package:very_good_cli/src/pub_license/spdx_license.gen.dart'; +import 'package:very_good_cli/src/pubspec/pubspec.dart'; import 'package:very_good_cli/src/pubspec_lock/pubspec_lock.dart'; /// Overrides the [package_config.findPackageConfig] function for testing. @@ -35,6 +36,10 @@ Future Function(String, double)? detectLicenseOverride; @visibleForTesting const pubspecLockBasename = 'pubspec.lock'; +/// The basename of the pubspec file. +@visibleForTesting +const pubspecBasename = 'pubspec.yaml'; + /// The URI for the pub.dev license page for the given [packageName]. @visibleForTesting Uri pubLicenseUri(String packageName) => @@ -178,11 +183,48 @@ class PackagesCheckLicensesCommand extends Command { return ExitCode.noInput.code; } + // Check if this is a workspace root and collect dependencies accordingly + final pubspecFile = File(path.join(targetPath, pubspecBasename)); + final pubspec = _tryParsePubspec(pubspecFile); + + // Collect workspace dependencies if this is a workspace root + final workspaceDependencies = _collectWorkspaceDependencies( + pubspec: pubspec, + targetDirectory: targetDirectory, + dependencyTypes: dependencyTypes, + ); + final filteredDependencies = pubspecLock.packages.where((dependency) { if (!dependency.isPubHosted) return false; if (skippedPackages.contains(dependency.name)) return false; + // If we have workspace dependencies, use them for filtering direct deps + if (workspaceDependencies != null) { + // For direct-main and direct-dev, check against workspace dependencies + if (dependencyTypes.contains('direct-main') || + dependencyTypes.contains('direct-dev')) { + if (workspaceDependencies.contains(dependency.name)) { + return true; + } + } + + // For transitive and direct-overridden, still use pubspec.lock types + final dependencyType = dependency.type; + if (dependencyTypes.contains('transitive') && + dependencyType == PubspecLockPackageDependencyType.transitive) { + return true; + } + if (dependencyTypes.contains('direct-overridden') && + dependencyType == + PubspecLockPackageDependencyType.directOverridden) { + return true; + } + + return false; + } + + // Non-workspace: use the original filtering logic final dependencyType = dependency.type; return (dependencyTypes.contains('direct-main') && dependencyType == PubspecLockPackageDependencyType.directMain) || @@ -497,3 +539,69 @@ extension on List { return '${join(', ')} and $last'; } } + +/// Attempts to parse a [Pubspec] from a file. +/// +/// Returns `null` if the file doesn't exist or cannot be parsed. +Pubspec? _tryParsePubspec(File pubspecFile) { + if (!pubspecFile.existsSync()) return null; + try { + return Pubspec.fromFile(pubspecFile); + } on PubspecParseException catch (_) { + return null; + } +} + +/// Collects dependencies from a workspace. +/// +/// If [pubspec] is not a workspace root, returns `null`. +/// Otherwise, returns a set of dependency names collected from all workspace +/// members based on the requested [dependencyTypes]. +Set? _collectWorkspaceDependencies({ + required Pubspec? pubspec, + required Directory targetDirectory, + required List dependencyTypes, +}) { + if (pubspec == null || !pubspec.isWorkspaceRoot) return null; + + final dependencies = {}; + + // Collect dependencies from the root pubspec itself + if (dependencyTypes.contains('direct-main')) { + dependencies.addAll(pubspec.dependencies); + } + if (dependencyTypes.contains('direct-dev')) { + dependencies.addAll(pubspec.devDependencies); + } + + // Collect dependencies from workspace members + final members = pubspec.resolveWorkspaceMembers(targetDirectory); + for (final memberDirectory in members) { + final memberPubspecFile = File( + path.join(memberDirectory.path, pubspecBasename), + ); + final memberPubspec = _tryParsePubspec(memberPubspecFile); + if (memberPubspec == null) continue; + + if (dependencyTypes.contains('direct-main')) { + dependencies.addAll(memberPubspec.dependencies); + } + if (dependencyTypes.contains('direct-dev')) { + dependencies.addAll(memberPubspec.devDependencies); + } + + // Handle nested workspaces recursively + if (memberPubspec.isWorkspaceRoot) { + final nestedDeps = _collectWorkspaceDependencies( + pubspec: memberPubspec, + targetDirectory: memberDirectory, + dependencyTypes: dependencyTypes, + ); + if (nestedDeps != null) { + dependencies.addAll(nestedDeps); + } + } + } + + return dependencies; +} diff --git a/lib/src/pubspec/pubspec.dart b/lib/src/pubspec/pubspec.dart new file mode 100644 index 000000000..6a69608b9 --- /dev/null +++ b/lib/src/pubspec/pubspec.dart @@ -0,0 +1,206 @@ +/// A simple parser for pubspec.yaml files. +/// +/// This is used by the `packages check licenses` command to detect workspace +/// configurations and collect dependencies from workspace members. +library; + +import 'dart:collection'; +import 'dart:io'; + +import 'package:glob/glob.dart'; +import 'package:glob/list_local_fs.dart'; +import 'package:path/path.dart' as path; +import 'package:yaml/yaml.dart'; + +/// {@template PubspecParseException} +/// Thrown when a [Pubspec] fails to parse. +/// {@endtemplate} +class PubspecParseException implements Exception { + /// {@macro PubspecParseException} + const PubspecParseException([this.message]); + + /// The error message. + final String? message; + + @override + String toString() => message != null + ? 'PubspecParseException: $message' + : 'PubspecParseException'; +} + +/// {@template Pubspec} +/// A representation of a pubspec.yaml file. +/// {@endtemplate} +class Pubspec { + const Pubspec._({ + required this.name, + required this.dependencies, + required this.devDependencies, + required this.workspace, + required this.resolution, + }); + + /// Parses a [Pubspec] from a string. + /// + /// Throws a [PubspecParseException] if the string cannot be parsed. + factory Pubspec.fromString(String content) { + late final YamlMap yaml; + try { + yaml = loadYaml(content) as YamlMap; + // loadYaml throws TypeError when it fails to cast content as a YamlMap. + // YamlException is thrown when the content is not valid YAML. + // We need to catch both to provide a meaningful exception. + // ignore: avoid_catching_errors + } on TypeError catch (_) { + throw const PubspecParseException('Failed to parse YAML content'); + } on YamlException catch (_) { + throw const PubspecParseException('Failed to parse YAML content'); + } + + final name = yaml['name'] as String? ?? ''; + + final dependencies = _parseDependencies(yaml['dependencies']); + final devDependencies = _parseDependencies(yaml['dev_dependencies']); + + final workspaceValue = yaml['workspace']; + List? workspace; + if (workspaceValue is YamlList) { + workspace = workspaceValue.cast().toList(); + } + + final resolutionValue = yaml['resolution']; + PubspecResolution? resolution; + if (resolutionValue is String) { + resolution = PubspecResolution.tryParse(resolutionValue); + } + + return Pubspec._( + name: name, + dependencies: UnmodifiableListView(dependencies), + devDependencies: UnmodifiableListView(devDependencies), + workspace: workspace != null ? UnmodifiableListView(workspace) : null, + resolution: resolution, + ); + } + + /// Parses a [Pubspec] from a file. + /// + /// Throws a [PubspecParseException] if the file cannot be read or parsed. + factory Pubspec.fromFile(File file) { + if (!file.existsSync()) { + throw PubspecParseException('File not found: ${file.path}'); + } + return Pubspec.fromString(file.readAsStringSync()); + } + + /// The name of the package. + final String name; + + /// The direct main dependencies. + final UnmodifiableListView dependencies; + + /// The direct dev dependencies. + final UnmodifiableListView devDependencies; + + /// The workspace member paths, if this is a workspace root. + /// + /// This is `null` if this pubspec is not a workspace root. + final UnmodifiableListView? workspace; + + /// The resolution mode for this package. + /// + /// This is `null` if no resolution is specified (typical for standalone + /// packages or workspace roots). + final PubspecResolution? resolution; + + /// Whether this pubspec is a workspace root. + bool get isWorkspaceRoot => workspace != null; + + /// Whether this pubspec is a workspace member. + bool get isWorkspaceMember => resolution == PubspecResolution.workspace; + + /// Resolves workspace member paths to actual directories. + /// + /// This handles glob patterns in workspace paths (e.g., `packages/*`). + /// The [rootDirectory] should be the directory containing this pubspec. + /// + /// Returns an empty list if this is not a workspace root. + List resolveWorkspaceMembers(Directory rootDirectory) { + if (workspace == null) return []; + + final members = []; + for (final pattern in workspace!) { + if (_isGlobPattern(pattern)) { + // Handle glob patterns + final glob = Glob(pattern); + final matches = glob.listSync(root: rootDirectory.path); + for (final match in matches) { + if (match is Directory) { + final pubspecFile = File(path.join(match.path, 'pubspec.yaml')); + if (pubspecFile.existsSync()) { + members.add(Directory(match.path)); + } + } else if (match is File && + path.basename(match.path) == 'pubspec.yaml') { + members.add(Directory(match.parent.path)); + } + } + } else { + // Handle direct path + final memberPath = path.join(rootDirectory.path, pattern); + final memberDir = Directory(memberPath); + if (memberDir.existsSync()) { + members.add(memberDir); + } + } + } + + return members; + } +} + +/// Parses dependency names from a YAML dependencies map. +List _parseDependencies(Object? value) { + if (value == null) return []; + if (value is! YamlMap) return []; + + return value.keys.cast().toList(); +} + +/// Checks if a path pattern contains glob characters. +bool _isGlobPattern(String pattern) { + return pattern.contains('*') || + pattern.contains('?') || + pattern.contains('[') || + pattern.contains('{'); +} + +/// {@template PubspecResolution} +/// The resolution mode for a pubspec. +/// {@endtemplate} +enum PubspecResolution { + /// This package is a workspace member and should resolve with the workspace + /// root. + workspace._('workspace'), + + /// This package uses external resolution (e.g., Dart SDK packages). + external._('external'), + ; + + const PubspecResolution._(this.value); + + /// Tries to parse a [PubspecResolution] from a string. + /// + /// Returns `null` if the string is not a valid resolution value. + static PubspecResolution? tryParse(String value) { + for (final resolution in PubspecResolution.values) { + if (resolution.value == value) { + return resolution; + } + } + return null; + } + + /// The string representation as it appears in pubspec.yaml. + final String value; +} 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 11f0bdce5..91de90d1c 100644 --- a/test/src/commands/packages/commands/check/commands/licenses_test.dart +++ b/test/src/commands/packages/commands/check/commands/licenses_test.dart @@ -1737,6 +1737,167 @@ and limitations under the License.'''); }), ); }); + + group('workspace support', () { + test( + 'collects dependencies from workspace members', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + // Create workspace root pubspec.yaml + File(path.join(tempDirectory.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceRootPubspecContent, + ); + + // Create workspace member directories and pubspec.yaml files + final appDir = Directory( + path.join(tempDirectory.path, 'packages', 'app'), + )..createSync(recursive: true); + File(path.join(appDir.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceMemberAppPubspecContent, + ); + + final sharedDir = Directory( + path.join(tempDirectory.path, 'packages', 'shared'), + )..createSync(recursive: true); + File(path.join(sharedDir.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceMemberSharedPubspecContent, + ); + + // Create pubspec.lock at workspace root with all dependencies + File( + path.join(tempDirectory.path, pubspecLockBasename), + ).writeAsStringSync(_workspacePubspecLockContent); + + when( + () => packageConfig.packages, + ).thenReturn([veryGoodTestRunnerConfigPackage, cliCompletionConfigPackage]); + when(() => detectorResult.matches).thenReturn([mitLicenseMatch]); + + when(() => logger.progress(any())).thenReturn(progress); + + final result = await commandRunner.run( + [...commandArguments, tempDirectory.path], + ); + + // Should find dependencies from both workspace members + verify( + () => progress.update( + 'Collecting licenses from 1 out of 2 packages', + ), + ).called(1); + verify( + () => progress.update( + 'Collecting licenses from 2 out of 2 packages', + ), + ).called(1); + verify( + () => progress.complete( + '''Retrieved 2 licenses from 2 packages of type: MIT (2).''', + ), + ).called(1); + + expect(result, equals(ExitCode.success.code)); + }), + ); + + test( + 'filters dev dependencies from workspace members correctly', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + // Create workspace root pubspec.yaml + File(path.join(tempDirectory.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceRootPubspecContent, + ); + + // Create workspace member with dev dependencies + final appDir = Directory( + path.join(tempDirectory.path, 'packages', 'app'), + )..createSync(recursive: true); + File(path.join(appDir.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceMemberWithDevDepsPubspecContent, + ); + + // Create shared package directory + final sharedDir = Directory( + path.join(tempDirectory.path, 'packages', 'shared'), + )..createSync(recursive: true); + File(path.join(sharedDir.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceMemberSharedPubspecContent, + ); + + // Create pubspec.lock at workspace root + File( + path.join(tempDirectory.path, pubspecLockBasename), + ).writeAsStringSync(_workspacePubspecLockWithDevDepsContent); + + when( + () => packageConfig.packages, + ).thenReturn([veryGoodAnalysisConfigPackage]); + when(() => detectorResult.matches).thenReturn([mitLicenseMatch]); + + when(() => logger.progress(any())).thenReturn(progress); + + final result = await commandRunner.run( + [ + ...commandArguments, + '--dependency-type', + 'direct-dev', + tempDirectory.path, + ], + ); + + // Should find dev dependencies from workspace members + verify( + () => progress.update( + 'Collecting licenses from 1 out of 1 package', + ), + ).called(1); + verify( + () => progress.complete( + '''Retrieved 1 license from 1 package of type: MIT (1).''', + ), + ).called(1); + + expect(result, equals(ExitCode.success.code)); + }), + ); + + test( + 'works with non-workspace projects (backwards compatibility)', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + // Create a regular pubspec.yaml (no workspace property) + File(path.join(tempDirectory.path, 'pubspec.yaml')).writeAsStringSync( + _regularPubspecContent, + ); + + File( + path.join(tempDirectory.path, pubspecLockBasename), + ).writeAsStringSync(_validPubspecLockContent); + + when( + () => packageConfig.packages, + ).thenReturn([veryGoodTestRunnerConfigPackage]); + when(() => detectorResult.matches).thenReturn([mitLicenseMatch]); + + when(() => logger.progress(any())).thenReturn(progress); + + final result = await commandRunner.run( + [...commandArguments, tempDirectory.path], + ); + + verify( + () => progress.update( + 'Collecting licenses from 1 out of 1 package', + ), + ).called(1); + verify( + () => progress.complete( + '''Retrieved 1 license from 1 package of type: MIT (1).''', + ), + ).called(1); + + expect(result, equals(ExitCode.success.code)); + }), + ); + }); }); } @@ -1853,3 +2014,124 @@ sdks: dart: ">=3.10.0 <4.0.0" '''; + +/// A workspace root pubspec.yaml content. +const _workspaceRootPubspecContent = ''' +name: workspace_root + +environment: + sdk: ^3.6.0 + +workspace: + - packages/app + - packages/shared +'''; + +/// A workspace member pubspec.yaml for app package. +const _workspaceMemberAppPubspecContent = ''' +name: app + +environment: + sdk: ^3.6.0 + +resolution: workspace + +dependencies: + very_good_test_runner: ^0.1.0 +'''; + +/// A workspace member pubspec.yaml for shared package. +const _workspaceMemberSharedPubspecContent = ''' +name: shared + +environment: + sdk: ^3.6.0 + +resolution: workspace + +dependencies: + cli_completion: ^0.4.0 +'''; + +/// A workspace member pubspec.yaml with dev dependencies. +const _workspaceMemberWithDevDepsPubspecContent = ''' +name: app + +environment: + sdk: ^3.6.0 + +resolution: workspace + +dependencies: + http: ^1.0.0 + +dev_dependencies: + very_good_analysis: ^5.0.0 +'''; + +/// A pubspec.lock for workspace with dependencies from members. +const _workspacePubspecLockContent = ''' +packages: + very_good_test_runner: + dependency: "direct main" + description: + name: very_good_test_runner + sha256: "4d41e5d7677d259b9a1599c78645ac2d36bc2bd6ff7773507bcb0bab41417fe2" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + cli_completion: + dependency: "direct main" + description: + name: cli_completion + sha256: "1e87700c029c77041d836e57f9016b5c90d353151c43c2ca0c36deaadc05aa3a" + url: "https://pub.dev" + source: hosted + version: "0.4.0" +sdks: + dart: ">=3.10.0 <4.0.0" + +'''; + +/// A pubspec.lock for workspace with dev dependencies. +const _workspacePubspecLockWithDevDepsContent = ''' +packages: + http: + dependency: "direct main" + description: + name: http + sha256: "5895291c13fa8a3bd82e76d5627f69e0f97bf76e" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + very_good_analysis: + dependency: "direct dev" + description: + name: very_good_analysis + sha256: "9ae7f3a3bd5764fb021b335ca28a34f040cd0ab6eec00a1b213b445dae58a4b8" + url: "https://pub.dev" + source: hosted + version: "5.1.0" + cli_completion: + dependency: "direct main" + description: + name: cli_completion + sha256: "1e87700c029c77041d836e57f9016b5c90d353151c43c2ca0c36deaadc05aa3a" + url: "https://pub.dev" + source: hosted + version: "0.4.0" +sdks: + dart: ">=3.10.0 <4.0.0" + +'''; + +/// A regular pubspec.yaml (non-workspace). +const _regularPubspecContent = ''' +name: regular_package + +environment: + sdk: ^3.0.0 + +dependencies: + very_good_test_runner: ^0.1.0 +'''; diff --git a/test/src/pubspec/pubspec_test.dart b/test/src/pubspec/pubspec_test.dart new file mode 100644 index 000000000..c8971d454 --- /dev/null +++ b/test/src/pubspec/pubspec_test.dart @@ -0,0 +1,285 @@ +// Ensures we don't have to use const constructors +// and instances are created at runtime. +// ignore_for_file: prefer_const_constructors + +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; +import 'package:very_good_cli/src/pubspec/pubspec.dart'; + +void main() { + group('$Pubspec', () { + group('fromString', () { + test('parses basic pubspec correctly', () { + final pubspec = Pubspec.fromString(_basicPubspecContent); + + expect(pubspec.name, equals('test_package')); + expect(pubspec.dependencies, equals(['foo', 'bar'])); + expect(pubspec.devDependencies, equals(['test', 'mocktail'])); + expect(pubspec.workspace, isNull); + expect(pubspec.resolution, isNull); + expect(pubspec.isWorkspaceRoot, isFalse); + expect(pubspec.isWorkspaceMember, isFalse); + }); + + test('parses workspace root pubspec correctly', () { + final pubspec = Pubspec.fromString(_workspaceRootPubspecContent); + + expect(pubspec.name, equals('workspace_root')); + expect(pubspec.dependencies, isEmpty); + expect(pubspec.devDependencies, isEmpty); + expect(pubspec.workspace, equals(['packages/app', 'packages/shared'])); + expect(pubspec.resolution, isNull); + expect(pubspec.isWorkspaceRoot, isTrue); + expect(pubspec.isWorkspaceMember, isFalse); + }); + + test('parses workspace member pubspec correctly', () { + final pubspec = Pubspec.fromString(_workspaceMemberPubspecContent); + + expect(pubspec.name, equals('workspace_member')); + expect(pubspec.dependencies, equals(['http', 'shared'])); + expect(pubspec.devDependencies, equals(['test'])); + expect(pubspec.workspace, isNull); + expect(pubspec.resolution, equals(PubspecResolution.workspace)); + expect(pubspec.isWorkspaceRoot, isFalse); + expect(pubspec.isWorkspaceMember, isTrue); + }); + + test('parses workspace with glob pattern correctly', () { + final pubspec = Pubspec.fromString(_workspaceWithGlobPubspecContent); + + expect(pubspec.workspace, equals(['packages/*'])); + expect(pubspec.isWorkspaceRoot, isTrue); + }); + + test('parses pubspec with no dependencies correctly', () { + final pubspec = Pubspec.fromString(_noDependenciesPubspecContent); + + expect(pubspec.name, equals('no_deps')); + expect(pubspec.dependencies, isEmpty); + expect(pubspec.devDependencies, isEmpty); + }); + + test('throws $PubspecParseException when content is empty', () { + expect( + () => Pubspec.fromString(''), + throwsA(isA()), + ); + }); + + test('throws $PubspecParseException when content is invalid YAML', () { + expect( + () => Pubspec.fromString('invalid: yaml: content: ['), + throwsA(isA()), + ); + }); + + test('handles missing name gracefully', () { + final pubspec = Pubspec.fromString('dependencies:\n foo: ^1.0.0'); + expect(pubspec.name, equals('')); + }); + }); + + group('fromFile', () { + late Directory tempDirectory; + + setUp(() { + tempDirectory = Directory.systemTemp.createTempSync(); + }); + + tearDown(() { + tempDirectory.deleteSync(recursive: true); + }); + + test('parses file correctly', () { + final pubspecFile = File(path.join(tempDirectory.path, 'pubspec.yaml')) + ..writeAsStringSync(_basicPubspecContent); + + final pubspec = Pubspec.fromFile(pubspecFile); + + expect(pubspec.name, equals('test_package')); + expect(pubspec.dependencies, equals(['foo', 'bar'])); + }); + + test('throws $PubspecParseException when file does not exist', () { + final nonExistentFile = File( + path.join(tempDirectory.path, 'nonexistent.yaml'), + ); + + expect( + () => Pubspec.fromFile(nonExistentFile), + throwsA(isA()), + ); + }); + }); + + group('resolveWorkspaceMembers', () { + late Directory tempDirectory; + + setUp(() { + tempDirectory = Directory.systemTemp.createTempSync(); + }); + + tearDown(() { + tempDirectory.deleteSync(recursive: true); + }); + + test('returns empty list when not a workspace root', () { + final pubspec = Pubspec.fromString(_basicPubspecContent); + final members = pubspec.resolveWorkspaceMembers(tempDirectory); + expect(members, isEmpty); + }); + + test('resolves direct path members correctly', () { + // Create workspace structure + final appDir = Directory(path.join(tempDirectory.path, 'packages/app')) + ..createSync(recursive: true); + File(path.join(appDir.path, 'pubspec.yaml')) + .writeAsStringSync(_workspaceMemberPubspecContent); + + final sharedDir = Directory( + path.join(tempDirectory.path, 'packages/shared'), + )..createSync(recursive: true); + File(path.join(sharedDir.path, 'pubspec.yaml')) + .writeAsStringSync(_workspaceMemberPubspecContent); + + final pubspec = Pubspec.fromString(_workspaceRootPubspecContent); + final members = pubspec.resolveWorkspaceMembers(tempDirectory); + + expect(members.length, equals(2)); + expect(members.map((d) => path.basename(d.path)), contains('app')); + expect(members.map((d) => path.basename(d.path)), contains('shared')); + }); + + test('ignores directories without pubspec.yaml for glob patterns', () { + // Create workspace structure with one valid and one invalid member + final validDir = Directory( + path.join(tempDirectory.path, 'packages/valid'), + )..createSync(recursive: true); + File(path.join(validDir.path, 'pubspec.yaml')) + .writeAsStringSync(_workspaceMemberPubspecContent); + + // Create a directory without pubspec.yaml + Directory(path.join(tempDirectory.path, 'packages/invalid')) + .createSync(recursive: true); + + final pubspec = Pubspec.fromString(_workspaceWithGlobPubspecContent); + final members = pubspec.resolveWorkspaceMembers(tempDirectory); + + expect(members.length, equals(1)); + expect(path.basename(members.first.path), equals('valid')); + }); + + test('skips non-existent direct path members', () { + // Don't create any directories + final pubspec = Pubspec.fromString(_workspaceRootPubspecContent); + final members = pubspec.resolveWorkspaceMembers(tempDirectory); + + expect(members, isEmpty); + }); + }); + }); + + group('$PubspecParseException', () { + test('toString returns message when provided', () { + final exception = PubspecParseException('test message'); + expect( + exception.toString(), + equals('PubspecParseException: test message'), + ); + }); + + test('toString returns class name when no message', () { + final exception = PubspecParseException(); + expect(exception.toString(), equals('PubspecParseException')); + }); + }); + + group('$PubspecResolution', () { + group('tryParse', () { + test('parses workspace correctly', () { + expect( + PubspecResolution.tryParse('workspace'), + equals(PubspecResolution.workspace), + ); + }); + + test('parses external correctly', () { + expect( + PubspecResolution.tryParse('external'), + equals(PubspecResolution.external), + ); + }); + + test('returns null for invalid value', () { + expect(PubspecResolution.tryParse('invalid'), isNull); + }); + }); + }); +} + +/// A basic pubspec.yaml content with dependencies. +const _basicPubspecContent = ''' +name: test_package + +environment: + sdk: ^3.0.0 + +dependencies: + foo: ^1.0.0 + bar: ^2.0.0 + +dev_dependencies: + test: ^1.0.0 + mocktail: ^1.0.0 +'''; + +/// A workspace root pubspec.yaml content. +const _workspaceRootPubspecContent = ''' +name: workspace_root + +environment: + sdk: ^3.6.0 + +workspace: + - packages/app + - packages/shared +'''; + +/// A workspace member pubspec.yaml content. +const _workspaceMemberPubspecContent = ''' +name: workspace_member + +environment: + sdk: ^3.6.0 + +resolution: workspace + +dependencies: + http: ^1.0.0 + shared: ^1.0.0 + +dev_dependencies: + test: ^1.0.0 +'''; + +/// A workspace pubspec.yaml with glob pattern. +const _workspaceWithGlobPubspecContent = ''' +name: workspace_glob + +environment: + sdk: ^3.6.0 + +workspace: + - packages/* +'''; + +/// A pubspec.yaml with no dependencies. +const _noDependenciesPubspecContent = ''' +name: no_deps + +environment: + sdk: ^3.0.0 +'''; From 6177eb42ff94a0345fa269a3c362a6ea4f929795 Mon Sep 17 00:00:00 2001 From: Meylis Annagurbanov Date: Mon, 19 Jan 2026 13:36:19 +0500 Subject: [PATCH 2/4] Formatted test files --- .../check/commands/licenses_test.dart | 5 ++++- test/src/pubspec/pubspec_test.dart | 20 +++++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) 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 91de90d1c..398f64fa0 100644 --- a/test/src/commands/packages/commands/check/commands/licenses_test.dart +++ b/test/src/commands/packages/commands/check/commands/licenses_test.dart @@ -1769,7 +1769,10 @@ and limitations under the License.'''); when( () => packageConfig.packages, - ).thenReturn([veryGoodTestRunnerConfigPackage, cliCompletionConfigPackage]); + ).thenReturn([ + veryGoodTestRunnerConfigPackage, + cliCompletionConfigPackage, + ]); when(() => detectorResult.matches).thenReturn([mitLicenseMatch]); when(() => logger.progress(any())).thenReturn(progress); diff --git a/test/src/pubspec/pubspec_test.dart b/test/src/pubspec/pubspec_test.dart index c8971d454..7afc02ca0 100644 --- a/test/src/pubspec/pubspec_test.dart +++ b/test/src/pubspec/pubspec_test.dart @@ -136,14 +136,16 @@ void main() { // Create workspace structure final appDir = Directory(path.join(tempDirectory.path, 'packages/app')) ..createSync(recursive: true); - File(path.join(appDir.path, 'pubspec.yaml')) - .writeAsStringSync(_workspaceMemberPubspecContent); + File( + path.join(appDir.path, 'pubspec.yaml'), + ).writeAsStringSync(_workspaceMemberPubspecContent); final sharedDir = Directory( path.join(tempDirectory.path, 'packages/shared'), )..createSync(recursive: true); - File(path.join(sharedDir.path, 'pubspec.yaml')) - .writeAsStringSync(_workspaceMemberPubspecContent); + File( + path.join(sharedDir.path, 'pubspec.yaml'), + ).writeAsStringSync(_workspaceMemberPubspecContent); final pubspec = Pubspec.fromString(_workspaceRootPubspecContent); final members = pubspec.resolveWorkspaceMembers(tempDirectory); @@ -158,12 +160,14 @@ void main() { final validDir = Directory( path.join(tempDirectory.path, 'packages/valid'), )..createSync(recursive: true); - File(path.join(validDir.path, 'pubspec.yaml')) - .writeAsStringSync(_workspaceMemberPubspecContent); + File( + path.join(validDir.path, 'pubspec.yaml'), + ).writeAsStringSync(_workspaceMemberPubspecContent); // Create a directory without pubspec.yaml - Directory(path.join(tempDirectory.path, 'packages/invalid')) - .createSync(recursive: true); + Directory( + path.join(tempDirectory.path, 'packages/invalid'), + ).createSync(recursive: true); final pubspec = Pubspec.fromString(_workspaceWithGlobPubspecContent); final members = pubspec.resolveWorkspaceMembers(tempDirectory); From d625cb6a89eecf50ca707a3f8bc66c5ea913ea70 Mon Sep 17 00:00:00 2001 From: Meylis Annagurbanov Date: Tue, 20 Jan 2026 00:06:22 +0500 Subject: [PATCH 3/4] test: add coverage for glob matching pubspec.yaml files --- test/src/pubspec/pubspec_test.dart | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/src/pubspec/pubspec_test.dart b/test/src/pubspec/pubspec_test.dart index 7afc02ca0..55c4c0ca4 100644 --- a/test/src/pubspec/pubspec_test.dart +++ b/test/src/pubspec/pubspec_test.dart @@ -183,6 +183,25 @@ void main() { expect(members, isEmpty); }); + + test('resolves glob pattern matching pubspec.yaml files directly', () { + // Create workspace structure + final memberDir = Directory( + path.join(tempDirectory.path, 'packages/member'), + )..createSync(recursive: true); + File( + path.join(memberDir.path, 'pubspec.yaml'), + ).writeAsStringSync(_workspaceMemberPubspecContent); + + // Use a glob pattern that matches pubspec.yaml files directly + final pubspec = Pubspec.fromString( + _workspaceWithFileGlobPubspecContent, + ); + final members = pubspec.resolveWorkspaceMembers(tempDirectory); + + expect(members.length, equals(1)); + expect(path.basename(members.first.path), equals('member')); + }); }); }); @@ -287,3 +306,14 @@ name: no_deps environment: sdk: ^3.0.0 '''; + +/// A workspace pubspec.yaml with glob pattern that matches pubspec.yaml files. +const _workspaceWithFileGlobPubspecContent = ''' +name: workspace_file_glob + +environment: + sdk: ^3.6.0 + +workspace: + - packages/*/pubspec.yaml +'''; From b8d019fe46a08ec8bdc34f67105607a3c8d8e89d Mon Sep 17 00:00:00 2001 From: Meylis Annagurbanov Date: Tue, 20 Jan 2026 00:50:52 +0500 Subject: [PATCH 4/4] test: add coverage for workspace dependency filtering --- .../check/commands/licenses_test.dart | 337 ++++++++++++++++++ 1 file changed, 337 insertions(+) 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 398f64fa0..734d0bb6a 100644 --- a/test/src/commands/packages/commands/check/commands/licenses_test.dart +++ b/test/src/commands/packages/commands/check/commands/licenses_test.dart @@ -1863,6 +1863,236 @@ and limitations under the License.'''); }), ); + test( + 'filters transitive dependencies in workspace correctly', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + // Create workspace root pubspec.yaml + File(path.join(tempDirectory.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceRootPubspecContent, + ); + + // Create workspace member directories + final appDir = Directory( + path.join(tempDirectory.path, 'packages', 'app'), + )..createSync(recursive: true); + File(path.join(appDir.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceMemberAppPubspecContent, + ); + + final sharedDir = Directory( + path.join(tempDirectory.path, 'packages', 'shared'), + )..createSync(recursive: true); + File(path.join(sharedDir.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceMemberSharedPubspecContent, + ); + + // Create pubspec.lock with transitive dependencies + File( + path.join(tempDirectory.path, pubspecLockBasename), + ).writeAsStringSync(_workspacePubspecLockWithTransitiveContent); + + when( + () => packageConfig.packages, + ).thenReturn([yamlConfigPackage]); + when(() => detectorResult.matches).thenReturn([mitLicenseMatch]); + + when(() => logger.progress(any())).thenReturn(progress); + + final result = await commandRunner.run( + [ + ...commandArguments, + '--dependency-type', + 'transitive', + tempDirectory.path, + ], + ); + + verify( + () => progress.update( + 'Collecting licenses from 1 out of 1 package', + ), + ).called(1); + verify( + () => progress.complete( + '''Retrieved 1 license from 1 package of type: MIT (1).''', + ), + ).called(1); + + expect(result, equals(ExitCode.success.code)); + }), + ); + + test( + 'filters direct-overridden dependencies in workspace correctly', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + // Create workspace root pubspec.yaml + File(path.join(tempDirectory.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceRootPubspecContent, + ); + + // Create workspace member directories + final appDir = Directory( + path.join(tempDirectory.path, 'packages', 'app'), + )..createSync(recursive: true); + File(path.join(appDir.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceMemberAppPubspecContent, + ); + + final sharedDir = Directory( + path.join(tempDirectory.path, 'packages', 'shared'), + )..createSync(recursive: true); + File(path.join(sharedDir.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceMemberSharedPubspecContent, + ); + + // Create pubspec.lock with direct-overridden dependencies + File( + path.join(tempDirectory.path, pubspecLockBasename), + ).writeAsStringSync(_workspacePubspecLockWithOverriddenContent); + + when( + () => packageConfig.packages, + ).thenReturn([pathConfigPackage]); + when(() => detectorResult.matches).thenReturn([mitLicenseMatch]); + + when(() => logger.progress(any())).thenReturn(progress); + + final result = await commandRunner.run( + [ + ...commandArguments, + '--dependency-type', + 'direct-overridden', + tempDirectory.path, + ], + ); + + verify( + () => progress.update( + 'Collecting licenses from 1 out of 1 package', + ), + ).called(1); + verify( + () => progress.complete( + '''Retrieved 1 license from 1 package of type: MIT (1).''', + ), + ).called(1); + + expect(result, equals(ExitCode.success.code)); + }), + ); + + test( + 'handles malformed workspace member pubspec gracefully', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + // Create workspace root pubspec.yaml + File(path.join(tempDirectory.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceRootPubspecContent, + ); + + // Create workspace member with malformed pubspec + final appDir = Directory( + path.join(tempDirectory.path, 'packages', 'app'), + )..createSync(recursive: true); + File(path.join(appDir.path, 'pubspec.yaml')).writeAsStringSync( + 'invalid: yaml: content: [', + ); + + // Create valid shared package + final sharedDir = Directory( + path.join(tempDirectory.path, 'packages', 'shared'), + )..createSync(recursive: true); + File(path.join(sharedDir.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceMemberSharedPubspecContent, + ); + + File( + path.join(tempDirectory.path, pubspecLockBasename), + ).writeAsStringSync(_workspacePubspecLockContent); + + when( + () => packageConfig.packages, + ).thenReturn([cliCompletionConfigPackage]); + when(() => detectorResult.matches).thenReturn([mitLicenseMatch]); + + when(() => logger.progress(any())).thenReturn(progress); + + final result = await commandRunner.run( + [...commandArguments, tempDirectory.path], + ); + + // Should still work, just skipping the malformed member + verify( + () => progress.update( + 'Collecting licenses from 1 out of 1 package', + ), + ).called(1); + + expect(result, equals(ExitCode.success.code)); + }), + ); + + test( + 'handles nested workspaces recursively', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + // Create root workspace pubspec.yaml + File(path.join(tempDirectory.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceRootPubspecContent, + ); + + // Create first-level workspace member that is also a workspace root + final appDir = Directory( + path.join(tempDirectory.path, 'packages', 'app'), + )..createSync(recursive: true); + File(path.join(appDir.path, 'pubspec.yaml')).writeAsStringSync( + _nestedWorkspaceRootPubspecContent, + ); + + // Create nested workspace member + final nestedDir = Directory( + path.join(tempDirectory.path, 'packages', 'app', 'nested', 'pkg'), + )..createSync(recursive: true); + File(path.join(nestedDir.path, 'pubspec.yaml')).writeAsStringSync( + _nestedWorkspaceMemberPubspecContent, + ); + + // Create shared package + final sharedDir = Directory( + path.join(tempDirectory.path, 'packages', 'shared'), + )..createSync(recursive: true); + File(path.join(sharedDir.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceMemberSharedPubspecContent, + ); + + File( + path.join(tempDirectory.path, pubspecLockBasename), + ).writeAsStringSync(_nestedWorkspacePubspecLockContent); + + when( + () => packageConfig.packages, + ).thenReturn([ + veryGoodTestRunnerConfigPackage, + cliCompletionConfigPackage, + yamlConfigPackage, + ]); + when(() => detectorResult.matches).thenReturn([mitLicenseMatch]); + + when(() => logger.progress(any())).thenReturn(progress); + + final result = await commandRunner.run( + [...commandArguments, tempDirectory.path], + ); + + // Should collect dependencies from all levels including nested + verify( + () => progress.complete( + '''Retrieved 3 licenses from 3 packages of type: MIT (3).''', + ), + ).called(1); + + expect(result, equals(ExitCode.success.code)); + }), + ); + test( 'works with non-workspace projects (backwards compatibility)', withRunner((commandRunner, logger, pubUpdater, printLogs) async { @@ -2138,3 +2368,110 @@ environment: dependencies: very_good_test_runner: ^0.1.0 '''; + +/// A pubspec.lock for workspace with transitive dependencies. +const _workspacePubspecLockWithTransitiveContent = ''' +packages: + very_good_test_runner: + dependency: "direct main" + description: + name: very_good_test_runner + sha256: "4d41e5d7677d259b9a1599c78645ac2d36bc2bd6ff7773507bcb0bab41417fe2" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.10.0 <4.0.0" + +'''; + +/// A pubspec.lock for workspace with direct-overridden dependencies. +const _workspacePubspecLockWithOverriddenContent = ''' +packages: + very_good_test_runner: + dependency: "direct main" + description: + name: very_good_test_runner + sha256: "4d41e5d7677d259b9a1599c78645ac2d36bc2bd6ff7773507bcb0bab41417fe2" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + path: + dependency: "direct overridden" + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" +sdks: + dart: ">=3.10.0 <4.0.0" + +'''; + +/// A nested workspace root pubspec.yaml (workspace member that is also a root). +const _nestedWorkspaceRootPubspecContent = ''' +name: app + +environment: + sdk: ^3.6.0 + +workspace: + - nested/pkg + +dependencies: + very_good_test_runner: ^0.1.0 +'''; + +/// A nested workspace member pubspec.yaml. +const _nestedWorkspaceMemberPubspecContent = ''' +name: nested_pkg + +environment: + sdk: ^3.6.0 + +resolution: workspace + +dependencies: + yaml: ^3.1.0 +'''; + +/// A pubspec.lock for nested workspace with dependencies from all levels. +const _nestedWorkspacePubspecLockContent = ''' +packages: + very_good_test_runner: + dependency: "direct main" + description: + name: very_good_test_runner + sha256: "4d41e5d7677d259b9a1599c78645ac2d36bc2bd6ff7773507bcb0bab41417fe2" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + cli_completion: + dependency: "direct main" + description: + name: cli_completion + sha256: "1e87700c029c77041d836e57f9016b5c90d353151c43c2ca0c36deaadc05aa3a" + url: "https://pub.dev" + source: hosted + version: "0.4.0" + yaml: + dependency: "direct main" + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.10.0 <4.0.0" + +''';