Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2a58446
feat: add workspace support for packages check licenses
realmeylisdev Dec 29, 2025
ce7e027
Merge branch 'main' into feat/workspace-license-support
realmeylisdev Jan 17, 2026
6177eb4
Formatted test files
realmeylisdev Jan 19, 2026
d625cb6
test: add coverage for glob matching pubspec.yaml files
realmeylisdev Jan 19, 2026
b8d019f
test: add coverage for workspace dependency filtering
realmeylisdev Jan 19, 2026
f32c5e9
Merge branch 'main' into feat/workspace-license-support
realmeylisdev Jan 23, 2026
7bb1147
Merge branch 'main' into feat/workspace-license-support
realmeylisdev Jan 29, 2026
ad7f11c
fix: resolve merge conflict in licenses command
realmeylisdev Mar 18, 2026
2cad6f0
Merge branch 'main' into feat/workspace-license-support
realmeylisdev Apr 1, 2026
9b9cfa2
Merge branch 'main' into feat/workspace-license-support
realmeylisdev Apr 9, 2026
f2760e4
refactor(pubspec): consolidate pubspec helpers into pubspec library
realmeylisdev Apr 9, 2026
33a4973
Merge branch 'main' into feat/workspace-license-support
realmeylisdev Apr 13, 2026
df0bda5
Merge branch 'main' into feat/workspace-license-support
realmeylisdev Apr 20, 2026
8ca2652
refactor(pubspec): use package:pubspec_parse per review
realmeylisdev Apr 20, 2026
5cb355b
Merge branch 'main' into feat/workspace-license-support
realmeylisdev May 5, 2026
1242593
Merge branch 'main' into feat/workspace-license-support
realmeylisdev May 6, 2026
4c86f8e
refactor: address PR review comments for workspace license support
realmeylisdev May 6, 2026
9b78da4
Merge branch 'main' into feat/workspace-license-support
realmeylisdev May 20, 2026
d5668ab
refactor(pubspec): address remaining bot review nits
realmeylisdev May 20, 2026
13db52e
Merge branch 'main' into feat/workspace-license-support
realmeylisdev Jun 22, 2026
d2b106d
refactor(pubspec): address remaining PR review feedback
realmeylisdev Jun 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions lib/src/commands/packages/commands/check/commands/licenses.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -178,7 +179,9 @@ class PackagesCheckLicensesCommand extends Command<int> {

final progress = _logger.progress('Checking licenses on $targetPath');

final pubspecLockFile = File(path.join(targetPath, pubspecLockBasename));
final pubspecLockFile = File(
path.join(targetPath, pubspecLockBasename),
);
if (!pubspecLockFile.existsSync()) {
progress.cancel();
_logger.err('Could not find a $pubspecLockBasename in $targetPath');
Expand All @@ -192,16 +195,37 @@ class PackagesCheckLicensesCommand extends Command<int> {
return ExitCode.noInput.code;
}

final pubspecFile = File(
path.join(targetPath, pubspecBasename),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two coupled signals are being smuggled through workspaceDependencies: (a) whether the project is a workspace root and (b) the actual dep set. Consider making collectWorkspaceDependencies return a Set<String> unconditionally (empty when not a workspace) and read the boolean directly via pubspec?.isWorkspaceRoot ?? false. That removes the != null guard at the call site and the mixed-purpose null return in the extension.

final pubspec = PubspecWorkspace.tryParse(pubspecFile);
final isWorkspace = pubspec?.isWorkspaceRoot ?? false;
final workspaceDependencies =
pubspec?.collectWorkspaceDependencies(
root: targetDirectory,
dependencyTypes: dependencyTypes,
) ??
const <String>{};

final filteredDependencies = pubspecLock.packages.where((dependency) {
if (!dependency.isPubHosted) return false;

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

final dependencyType = dependency.type;
return (dependencyTypes.contains('direct-main') &&
dependencyType == PubspecLockPackageDependencyType.directMain) ||
(dependencyTypes.contains('direct-dev') &&
dependencyType == PubspecLockPackageDependencyType.directDev) ||

// In a workspace, whether a dependency counts as direct is determined by
// which members declare it rather than by the lock file's recorded type.
final isDirect = isWorkspace
? workspaceDependencies.contains(dependency.name)
: (dependencyTypes.contains('direct-main') &&
dependencyType ==
PubspecLockPackageDependencyType.directMain) ||
(dependencyTypes.contains('direct-dev') &&
dependencyType ==
PubspecLockPackageDependencyType.directDev);

return isDirect ||
(dependencyTypes.contains('transitive') &&
dependencyType == PubspecLockPackageDependencyType.transitive) ||
(dependencyTypes.contains('direct-overridden') &&
Expand Down
132 changes: 132 additions & 0 deletions lib/src/pubspec/pubspec.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/// Workspace-aware helpers around [package:pubspec_parse].
///
/// Parsing is delegated to [Pubspec.parse]; this library only adds the
/// filesystem and glob expansion helpers needed by
/// `packages check licenses` to walk workspace members.
library;

import 'dart:io';

import 'package:glob/glob.dart';
import 'package:glob/list_local_fs.dart';
import 'package:path/path.dart' as path;
import 'package:pubspec_parse/pubspec_parse.dart';

export 'package:pubspec_parse/pubspec_parse.dart' show Pubspec;

/// The basename of the pubspec file.
const pubspecBasename = 'pubspec.yaml';

/// Workspace-related conveniences on top of [Pubspec].
extension PubspecWorkspace on Pubspec {
/// Attempts to read and parse a [Pubspec] from [file].
///
/// Returns `null` when [file] does not exist or cannot be parsed; parsing is
/// strict, so a structurally invalid pubspec (for example, one missing a
/// `name`) is treated as unparseable rather than yielding a partially
/// populated [Pubspec].
///
/// Parse failures are swallowed silently: callers that walk workspace members
/// (see [collectWorkspaceDependencies]) skip members that fail to parse, so a
/// malformed member contributes no dependencies.
static Pubspec? tryParse(File file) {
if (!file.existsSync()) return null;
try {
return Pubspec.parse(file.readAsStringSync(), sourceUrl: file.uri);
} on Exception {
return null;
}
}

/// Whether this pubspec is a workspace root.
bool get isWorkspaceRoot => workspace != null;

/// Resolves the workspace members declared by this pubspec, expanding any
/// glob patterns relative to [root].
///
/// Returns an empty list when this pubspec is not a workspace root.
List<Directory> resolveMembers(Directory root) {
final patterns = workspace;
if (patterns == null) return const [];

final members = <Directory>[];
for (final pattern in patterns) {
// Workspace patterns are POSIX-style (forward slashes) per the pub
// specification. The platform-default glob context accepts forward
// slashes on every platform, including Windows, so the pattern needs no
// separator normalization.
final matches = Glob(pattern).listSync(root: root.path);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Glob(pattern).listSync(root: root.path) uses the platform's default path.Context. Pub workspace globs are always POSIX-style (forward slashes) per the pub spec, but on Windows the default Context is windows. Worth either pinning Glob(pattern, context: p.posix) for portability, or at minimum verifying behavior on Windows CI before release. (Same applies to test fixtures using packages/app literals.)

for (final match in matches) {
if (match is Directory) {
final pubspecFile = File(path.join(match.path, pubspecBasename));
if (pubspecFile.existsSync()) {
members.add(Directory(match.path));
}
} else if (match is File &&
path.basename(match.path) == pubspecBasename) {
members.add(Directory(match.parent.path));
}
}
}

return members;
}

/// Collects the direct dependencies declared by this workspace root and all
/// of its members.
///
/// Returns an empty [Set] when this pubspec is not a workspace root.
/// Otherwise returns the names of the dependencies declared by the root and
/// every member (recursively), filtered by [dependencyTypes] (only
/// `direct-main` and `direct-dev` are relevant here).
///
/// `dependency_overrides` are intentionally not collected: overridden
/// dependencies are surfaced by the caller through the workspace's
/// `pubspec.lock` (as `direct-overridden`) rather than from member pubspecs.
///
/// The [visited] parameter prevents infinite recursion from circular
/// workspace references; pass `null` to start a fresh traversal.
Set<String> collectWorkspaceDependencies({
required Directory root,
required List<String> dependencyTypes,
Set<String>? visited,
}) {
if (!isWorkspaceRoot) return {};

final seen = visited ?? {};
if (!seen.add(root.absolute.path)) return {};

final deps = <String>{..._directDependencies(dependencyTypes)};

for (final memberDir in resolveMembers(root)) {
final memberPubspec = PubspecWorkspace.tryParse(
File(path.join(memberDir.path, pubspecBasename)),
);
if (memberPubspec == null) continue;

if (memberPubspec.isWorkspaceRoot) {
// Nested workspace: the recursive call collects the member's own direct
// dependencies along with those of its descendants, so they are not
// added here as well.
deps.addAll(
memberPubspec.collectWorkspaceDependencies(
root: memberDir,
dependencyTypes: dependencyTypes,
visited: seen,
),
);
} else {
deps.addAll(memberPubspec._directDependencies(dependencyTypes));
}
}

return deps;
}

/// The names of this pubspec's own direct dependencies, filtered by
/// [dependencyTypes].
Set<String> _directDependencies(List<String> dependencyTypes) => {
if (dependencyTypes.contains('direct-main')) ...dependencies.keys,
if (dependencyTypes.contains('direct-dev')) ...devDependencies.keys,
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catching Exception is correct here (Pubspec.parse throws ParsedYamlException/YamlException, both FormatExceptions), but the dartdoc currently just says "cannot be parsed" — it's worth being explicit that this swallows all parse failures silently. Callers (notably collectWorkspaceDependencies) skip these members without logging, which could make broken member pubspecs invisible. Consider returning a richer signal or at least documenting that malformed members are silently skipped.

Loading
Loading