Skip to content

Commit 2a58446

Browse files
committed
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
1 parent eb44516 commit 2a58446

File tree

4 files changed

+881
-0
lines changed

4 files changed

+881
-0
lines changed

lib/src/commands/packages/commands/check/commands/licenses.dart

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import 'package:package_config/package_config.dart' as package_config;
2020
import 'package:pana/src/license_detection/license_detector.dart' as detector;
2121
import 'package:path/path.dart' as path;
2222
import 'package:very_good_cli/src/pub_license/spdx_license.gen.dart';
23+
import 'package:very_good_cli/src/pubspec/pubspec.dart';
2324
import 'package:very_good_cli/src/pubspec_lock/pubspec_lock.dart';
2425

2526
/// Overrides the [package_config.findPackageConfig] function for testing.
@@ -35,6 +36,10 @@ Future<detector.Result> Function(String, double)? detectLicenseOverride;
3536
@visibleForTesting
3637
const pubspecLockBasename = 'pubspec.lock';
3738

39+
/// The basename of the pubspec file.
40+
@visibleForTesting
41+
const pubspecBasename = 'pubspec.yaml';
42+
3843
/// The URI for the pub.dev license page for the given [packageName].
3944
@visibleForTesting
4045
Uri pubLicenseUri(String packageName) =>
@@ -178,11 +183,48 @@ class PackagesCheckLicensesCommand extends Command<int> {
178183
return ExitCode.noInput.code;
179184
}
180185

186+
// Check if this is a workspace root and collect dependencies accordingly
187+
final pubspecFile = File(path.join(targetPath, pubspecBasename));
188+
final pubspec = _tryParsePubspec(pubspecFile);
189+
190+
// Collect workspace dependencies if this is a workspace root
191+
final workspaceDependencies = _collectWorkspaceDependencies(
192+
pubspec: pubspec,
193+
targetDirectory: targetDirectory,
194+
dependencyTypes: dependencyTypes,
195+
);
196+
181197
final filteredDependencies = pubspecLock.packages.where((dependency) {
182198
if (!dependency.isPubHosted) return false;
183199

184200
if (skippedPackages.contains(dependency.name)) return false;
185201

202+
// If we have workspace dependencies, use them for filtering direct deps
203+
if (workspaceDependencies != null) {
204+
// For direct-main and direct-dev, check against workspace dependencies
205+
if (dependencyTypes.contains('direct-main') ||
206+
dependencyTypes.contains('direct-dev')) {
207+
if (workspaceDependencies.contains(dependency.name)) {
208+
return true;
209+
}
210+
}
211+
212+
// For transitive and direct-overridden, still use pubspec.lock types
213+
final dependencyType = dependency.type;
214+
if (dependencyTypes.contains('transitive') &&
215+
dependencyType == PubspecLockPackageDependencyType.transitive) {
216+
return true;
217+
}
218+
if (dependencyTypes.contains('direct-overridden') &&
219+
dependencyType ==
220+
PubspecLockPackageDependencyType.directOverridden) {
221+
return true;
222+
}
223+
224+
return false;
225+
}
226+
227+
// Non-workspace: use the original filtering logic
186228
final dependencyType = dependency.type;
187229
return (dependencyTypes.contains('direct-main') &&
188230
dependencyType == PubspecLockPackageDependencyType.directMain) ||
@@ -497,3 +539,69 @@ extension on List<Object> {
497539
return '${join(', ')} and $last';
498540
}
499541
}
542+
543+
/// Attempts to parse a [Pubspec] from a file.
544+
///
545+
/// Returns `null` if the file doesn't exist or cannot be parsed.
546+
Pubspec? _tryParsePubspec(File pubspecFile) {
547+
if (!pubspecFile.existsSync()) return null;
548+
try {
549+
return Pubspec.fromFile(pubspecFile);
550+
} on PubspecParseException catch (_) {
551+
return null;
552+
}
553+
}
554+
555+
/// Collects dependencies from a workspace.
556+
///
557+
/// If [pubspec] is not a workspace root, returns `null`.
558+
/// Otherwise, returns a set of dependency names collected from all workspace
559+
/// members based on the requested [dependencyTypes].
560+
Set<String>? _collectWorkspaceDependencies({
561+
required Pubspec? pubspec,
562+
required Directory targetDirectory,
563+
required List<String> dependencyTypes,
564+
}) {
565+
if (pubspec == null || !pubspec.isWorkspaceRoot) return null;
566+
567+
final dependencies = <String>{};
568+
569+
// Collect dependencies from the root pubspec itself
570+
if (dependencyTypes.contains('direct-main')) {
571+
dependencies.addAll(pubspec.dependencies);
572+
}
573+
if (dependencyTypes.contains('direct-dev')) {
574+
dependencies.addAll(pubspec.devDependencies);
575+
}
576+
577+
// Collect dependencies from workspace members
578+
final members = pubspec.resolveWorkspaceMembers(targetDirectory);
579+
for (final memberDirectory in members) {
580+
final memberPubspecFile = File(
581+
path.join(memberDirectory.path, pubspecBasename),
582+
);
583+
final memberPubspec = _tryParsePubspec(memberPubspecFile);
584+
if (memberPubspec == null) continue;
585+
586+
if (dependencyTypes.contains('direct-main')) {
587+
dependencies.addAll(memberPubspec.dependencies);
588+
}
589+
if (dependencyTypes.contains('direct-dev')) {
590+
dependencies.addAll(memberPubspec.devDependencies);
591+
}
592+
593+
// Handle nested workspaces recursively
594+
if (memberPubspec.isWorkspaceRoot) {
595+
final nestedDeps = _collectWorkspaceDependencies(
596+
pubspec: memberPubspec,
597+
targetDirectory: memberDirectory,
598+
dependencyTypes: dependencyTypes,
599+
);
600+
if (nestedDeps != null) {
601+
dependencies.addAll(nestedDeps);
602+
}
603+
}
604+
}
605+
606+
return dependencies;
607+
}

lib/src/pubspec/pubspec.dart

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/// A simple parser for pubspec.yaml files.
2+
///
3+
/// This is used by the `packages check licenses` command to detect workspace
4+
/// configurations and collect dependencies from workspace members.
5+
library;
6+
7+
import 'dart:collection';
8+
import 'dart:io';
9+
10+
import 'package:glob/glob.dart';
11+
import 'package:glob/list_local_fs.dart';
12+
import 'package:path/path.dart' as path;
13+
import 'package:yaml/yaml.dart';
14+
15+
/// {@template PubspecParseException}
16+
/// Thrown when a [Pubspec] fails to parse.
17+
/// {@endtemplate}
18+
class PubspecParseException implements Exception {
19+
/// {@macro PubspecParseException}
20+
const PubspecParseException([this.message]);
21+
22+
/// The error message.
23+
final String? message;
24+
25+
@override
26+
String toString() => message != null
27+
? 'PubspecParseException: $message'
28+
: 'PubspecParseException';
29+
}
30+
31+
/// {@template Pubspec}
32+
/// A representation of a pubspec.yaml file.
33+
/// {@endtemplate}
34+
class Pubspec {
35+
const Pubspec._({
36+
required this.name,
37+
required this.dependencies,
38+
required this.devDependencies,
39+
required this.workspace,
40+
required this.resolution,
41+
});
42+
43+
/// Parses a [Pubspec] from a string.
44+
///
45+
/// Throws a [PubspecParseException] if the string cannot be parsed.
46+
factory Pubspec.fromString(String content) {
47+
late final YamlMap yaml;
48+
try {
49+
yaml = loadYaml(content) as YamlMap;
50+
// loadYaml throws TypeError when it fails to cast content as a YamlMap.
51+
// YamlException is thrown when the content is not valid YAML.
52+
// We need to catch both to provide a meaningful exception.
53+
// ignore: avoid_catching_errors
54+
} on TypeError catch (_) {
55+
throw const PubspecParseException('Failed to parse YAML content');
56+
} on YamlException catch (_) {
57+
throw const PubspecParseException('Failed to parse YAML content');
58+
}
59+
60+
final name = yaml['name'] as String? ?? '';
61+
62+
final dependencies = _parseDependencies(yaml['dependencies']);
63+
final devDependencies = _parseDependencies(yaml['dev_dependencies']);
64+
65+
final workspaceValue = yaml['workspace'];
66+
List<String>? workspace;
67+
if (workspaceValue is YamlList) {
68+
workspace = workspaceValue.cast<String>().toList();
69+
}
70+
71+
final resolutionValue = yaml['resolution'];
72+
PubspecResolution? resolution;
73+
if (resolutionValue is String) {
74+
resolution = PubspecResolution.tryParse(resolutionValue);
75+
}
76+
77+
return Pubspec._(
78+
name: name,
79+
dependencies: UnmodifiableListView(dependencies),
80+
devDependencies: UnmodifiableListView(devDependencies),
81+
workspace: workspace != null ? UnmodifiableListView(workspace) : null,
82+
resolution: resolution,
83+
);
84+
}
85+
86+
/// Parses a [Pubspec] from a file.
87+
///
88+
/// Throws a [PubspecParseException] if the file cannot be read or parsed.
89+
factory Pubspec.fromFile(File file) {
90+
if (!file.existsSync()) {
91+
throw PubspecParseException('File not found: ${file.path}');
92+
}
93+
return Pubspec.fromString(file.readAsStringSync());
94+
}
95+
96+
/// The name of the package.
97+
final String name;
98+
99+
/// The direct main dependencies.
100+
final UnmodifiableListView<String> dependencies;
101+
102+
/// The direct dev dependencies.
103+
final UnmodifiableListView<String> devDependencies;
104+
105+
/// The workspace member paths, if this is a workspace root.
106+
///
107+
/// This is `null` if this pubspec is not a workspace root.
108+
final UnmodifiableListView<String>? workspace;
109+
110+
/// The resolution mode for this package.
111+
///
112+
/// This is `null` if no resolution is specified (typical for standalone
113+
/// packages or workspace roots).
114+
final PubspecResolution? resolution;
115+
116+
/// Whether this pubspec is a workspace root.
117+
bool get isWorkspaceRoot => workspace != null;
118+
119+
/// Whether this pubspec is a workspace member.
120+
bool get isWorkspaceMember => resolution == PubspecResolution.workspace;
121+
122+
/// Resolves workspace member paths to actual directories.
123+
///
124+
/// This handles glob patterns in workspace paths (e.g., `packages/*`).
125+
/// The [rootDirectory] should be the directory containing this pubspec.
126+
///
127+
/// Returns an empty list if this is not a workspace root.
128+
List<Directory> resolveWorkspaceMembers(Directory rootDirectory) {
129+
if (workspace == null) return [];
130+
131+
final members = <Directory>[];
132+
for (final pattern in workspace!) {
133+
if (_isGlobPattern(pattern)) {
134+
// Handle glob patterns
135+
final glob = Glob(pattern);
136+
final matches = glob.listSync(root: rootDirectory.path);
137+
for (final match in matches) {
138+
if (match is Directory) {
139+
final pubspecFile = File(path.join(match.path, 'pubspec.yaml'));
140+
if (pubspecFile.existsSync()) {
141+
members.add(Directory(match.path));
142+
}
143+
} else if (match is File &&
144+
path.basename(match.path) == 'pubspec.yaml') {
145+
members.add(Directory(match.parent.path));
146+
}
147+
}
148+
} else {
149+
// Handle direct path
150+
final memberPath = path.join(rootDirectory.path, pattern);
151+
final memberDir = Directory(memberPath);
152+
if (memberDir.existsSync()) {
153+
members.add(memberDir);
154+
}
155+
}
156+
}
157+
158+
return members;
159+
}
160+
}
161+
162+
/// Parses dependency names from a YAML dependencies map.
163+
List<String> _parseDependencies(Object? value) {
164+
if (value == null) return [];
165+
if (value is! YamlMap) return [];
166+
167+
return value.keys.cast<String>().toList();
168+
}
169+
170+
/// Checks if a path pattern contains glob characters.
171+
bool _isGlobPattern(String pattern) {
172+
return pattern.contains('*') ||
173+
pattern.contains('?') ||
174+
pattern.contains('[') ||
175+
pattern.contains('{');
176+
}
177+
178+
/// {@template PubspecResolution}
179+
/// The resolution mode for a pubspec.
180+
/// {@endtemplate}
181+
enum PubspecResolution {
182+
/// This package is a workspace member and should resolve with the workspace
183+
/// root.
184+
workspace._('workspace'),
185+
186+
/// This package uses external resolution (e.g., Dart SDK packages).
187+
external._('external'),
188+
;
189+
190+
const PubspecResolution._(this.value);
191+
192+
/// Tries to parse a [PubspecResolution] from a string.
193+
///
194+
/// Returns `null` if the string is not a valid resolution value.
195+
static PubspecResolution? tryParse(String value) {
196+
for (final resolution in PubspecResolution.values) {
197+
if (resolution.value == value) {
198+
return resolution;
199+
}
200+
}
201+
return null;
202+
}
203+
204+
/// The string representation as it appears in pubspec.yaml.
205+
final String value;
206+
}

0 commit comments

Comments
 (0)