Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Unreleased
<!-- Add unreleased changes here -->

- Added support for glob patterns in `workspace:` entries (for example
`packages/*`). Only directories containing a `pubspec.yaml` are treated as
workspace members. A glob that matches no packages logs a warning. (ref #176)
- Workspace members are now validated in sorted order (deduplicated across
overlapping entries) rather than in the order they are listed in `workspace:`.
This only affects the order of log output.
- Ignore nested packages when validating surrounding packages (#173).

- Parse files with the language version the package declares instead of the
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,18 @@ This package supports [Pub Workspaces](https://dart.dev/tools/pub/workspaces), a
workspace:
- pkg1
- pkg2
- packages/*
```

Glob patterns such as `packages/*` are supported. Only directories that contain
a `pubspec.yaml` are treated as workspace members; other directories matched by
the glob are ignored. A glob that matches no packages logs a warning.

> **Note:** `dependency_validator` will expand glob entries regardless of your
> SDK constraint, but pub itself only supports glob patterns in `workspace:` when
> the root package's SDK constraint is `^3.11.0` or higher. On older SDKs, pub
> treats the entry as a literal path and `pub get` will fail.

and your sub-packages should have `resolution: workspace` in their `pubspec.yaml`s. For more information, see the linked documentation.

**Running `dependency_validator` will always validate the package your terminal is in**. If you run the tool on the top-level workspace package, it will analyze the workspace package _and_ its sub-packages. To just analyze a sub-package, run the tool in its folder, or pass the `-C` argument:
Expand Down
21 changes: 14 additions & 7 deletions lib/src/dependency_validator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Future<bool> checkPackage({required String root}) async {
.map((s) {
try {
return makeGlob("$root/$s");
} catch (_, __) {
} catch (_) {
logger.shout(yellow.wrap('invalid glob syntax: "$s"'));
return null;
}
Expand All @@ -79,11 +79,16 @@ Future<bool> checkPackage({required String root}) async {
sourceUrl: pubspecFile.uri,
);

List<String> workspaceMembers = const [];
var subResult = true;
if (pubspec.isWorkspaceRoot) {
final resolved = resolveWorkspaceMembers(root, pubspec.workspace ?? []);
if (resolved == null) return false;
workspaceMembers = resolved;

logger.fine('In a workspace. Recursing through sub-packages...');
for (final package in pubspec.workspace ?? []) {
subResult &= await checkPackage(root: '$root/$package');
for (final package in workspaceMembers) {
subResult &= await checkPackage(root: p.join(root, package));
logger.info('');
}
}
Expand All @@ -108,11 +113,13 @@ Future<bool> checkPackage({required String root}) async {
);

final nestedPackages = listNestedPackages(root);
final nestedPackagePaths = {
for (final nested in nestedPackages) p.normalize(nested.path),
for (final subpackage in workspaceMembers)
p.normalize(p.join(root, subpackage)),
};
final nestedPackageGlobs = [
for (final nested in nestedPackages)
makeGlob('${p.normalize(nested.path)}/**'),
for (final subpackage in pubspec.workspace ?? [])
makeGlob('${p.normalize('$root/$subpackage')}/**'),
for (final path in nestedPackagePaths) makeGlob('$path/**'),
];
logger.fine(
'nested package globs:\n'
Expand Down
95 changes: 95 additions & 0 deletions lib/src/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import 'dart:io';

import 'package:glob/glob.dart';
import 'package:glob/list_local_fs.dart';
import 'package:io/ansi.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:logging/logging.dart';
Expand Down Expand Up @@ -230,3 +231,97 @@ extension PubspecUtils on Pubspec {
/// This function removes `./` paths and replaces all `\` with `/`.
Glob makeGlob(String path) =>
Glob(p.posix.normalize(path.replaceAll(r'\', '/')));

/// Returns whether [path] contains glob wildcard syntax characters.
bool hasGlobWildcards(String path) =>
path.contains('*') ||
path.contains('?') ||
path.contains('[') ||
path.contains(']') ||
path.contains('{') ||
path.contains('}');

String _normalizeWorkspaceMemberPath(String path) =>
p.posix.normalize(path.replaceAll(r'\', '/'));

/// Resolves workspace member paths from [workspacePaths] relative to [root].
///
/// Glob patterns (for example `packages/*`) are expanded to directories
/// containing a pubspec.yaml file, matching pub's workspace resolution
/// behavior.
///
/// A glob pattern that matches no package directories logs a warning, since
/// pub itself rejects such an entry and it usually indicates a typo.
///
/// Returns a sorted, deduplicated list with posix-normalized path separators.
/// Returns `null` if any workspace path has invalid glob syntax, escapes the
/// workspace root, or cannot be listed.
List<String>? resolveWorkspaceMembers(
String root,
Iterable<String> workspacePaths,
) {
final canonicalRoot = p.canonicalize(root);
final members = <String>{};
for (final workspacePath in workspacePaths) {
if (hasGlobWildcards(workspacePath)) {
final Glob glob;
try {
glob = makeGlob(workspacePath);
} on FormatException {
logger.shout('invalid glob syntax: "$workspacePath"');
return null;
}

final List<FileSystemEntity> matches;
try {
matches = glob.listSync(root: root, followLinks: false);
} on FileSystemException catch (e) {
logger.shout(
'failed to list workspace glob "$workspacePath": ${e.message}'
'${e.path != null ? ' (${e.path})' : ''}',
);
return null;
}

var matchedPackage = false;
for (final entity in matches) {
if (entity is! Directory) continue;
final canonicalEntity = p.canonicalize(entity.path);
if (!p.isWithin(canonicalRoot, canonicalEntity)) continue;

final relPath = _normalizeWorkspaceMemberPath(
p.relative(entity.path, from: root),
);
if (p.split(relPath).any((d) => d != '.' && d.startsWith('.'))) {
continue;
}

if (!File(p.join(entity.path, 'pubspec.yaml')).existsSync()) {
continue;
}
matchedPackage = true;
members.add(relPath);
}
if (!matchedPackage) {
logger.warning(
'No workspace packages matching "$workspacePath". '
'Check the pattern for typos.',
);
}
} else {
final normalized = _normalizeWorkspaceMemberPath(workspacePath);
final fullPath = p.normalize(
p.isAbsolute(normalized) ? normalized : p.join(root, normalized),
);
final canonicalPath = p.canonicalize(fullPath);
if (!p.isWithin(canonicalRoot, canonicalPath)) {
logger.shout(
'Workspace member "$workspacePath" must be in a subdirectory of the workspace root.',
);
return null;
}
members.add(normalized);
}
}
return (members.toList()..sort());
}
91 changes: 72 additions & 19 deletions test/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'dart:io';
import 'package:dependency_validator/src/dependency_validator.dart';
import 'package:dependency_validator/src/pubspec_config.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:pub_semver/pub_semver.dart';
import 'package:pubspec_parse/pubspec_parse.dart';
import 'package:test/test.dart';
Expand Down Expand Up @@ -69,27 +70,60 @@ final requireDart36 = {
"sdk": VersionConstraint.compatibleWith(Version.parse('3.6.0')),
};

Future<void> checkWorkspace({
typedef WorkspaceSubpackage = ({
String path,
List<d.Descriptor> contents,
Map<String, Dependency> deps,
DepValidatorConfig? config,
});

/// Creates a workspace in the test sandbox and runs [checkPackage] on it.
///
/// By default the workspace has a single sub-package at `subpackage/`
/// described by [subpackage], [subpackageDeps], and [subpackageConfig]. Pass
/// [subpackages] instead to create several sub-packages at arbitrary paths;
/// the single-sub-package parameters must then be omitted.
///
/// [workspaceMembers] overrides the root pubspec's `workspace:` list (for
/// example to use glob patterns); it defaults to the sub-package paths.
///
/// Returns the log messages emitted at or above [logLevel] while validating.
Future<List<String>> checkWorkspace({
required Map<String, Dependency> workspaceDeps,
required Map<String, Dependency> subpackageDeps,
required List<d.Descriptor> workspace,
required List<d.Descriptor> subpackage,
Map<String, Dependency>? subpackageDeps,
List<d.Descriptor>? subpackage,
DepValidatorConfig? workspaceConfig,
DepValidatorConfig? subpackageConfig,
Level logLevel = Level.OFF,
Matcher matcher = isTrue,
List<String>? workspaceMembers,
List<WorkspaceSubpackage>? subpackages,
}) async {
if (subpackages != null &&
(subpackage != null ||
subpackageDeps != null ||
subpackageConfig != null)) {
throw ArgumentError(
'Pass either `subpackages` or the single-subpackage parameters '
'(`subpackage`, `subpackageDeps`, `subpackageConfig`), not both.',
);
}
final resolvedSubpackages = subpackages ??
[
(
path: 'subpackage',
contents: subpackage ?? const [],
deps: subpackageDeps ?? const {},
config: subpackageConfig,
),
];
final workspacePubspec = Pubspec(
'workspace',
environment: requireDart36,
dependencies: workspaceDeps,
workspace: ['subpackage'],
);
final subpackagePubspec = Pubspec(
'subpackage',
environment: requireDart36,
dependencies: subpackageDeps,
resolution: 'workspace',
workspace:
workspaceMembers ?? resolvedSubpackages.map((s) => s.path).toList(),
);
final dir = d.dir('workspace', [
...workspace,
Expand All @@ -99,18 +133,37 @@ Future<void> checkWorkspace({
'dart_dependency_validator.yaml',
jsonEncode(workspaceConfig.toJson()),
),
d.dir('subpackage', [
...subpackage,
d.file('pubspec.yaml', jsonEncode(subpackagePubspec.toJson())),
if (subpackageConfig != null)
for (final subpackageSpec in resolvedSubpackages)
d.dir(subpackageSpec.path, [
...subpackageSpec.contents,
d.file(
'dart_dependency_validator.yaml',
jsonEncode(subpackageConfig.toJson()),
'pubspec.yaml',
jsonEncode(
Pubspec(
p.basename(subpackageSpec.path),
environment: requireDart36,
dependencies: subpackageSpec.deps,
resolution: 'workspace',
).toJson(),
),
),
]),
if (subpackageSpec.config != null)
d.file(
'dart_dependency_validator.yaml',
jsonEncode(subpackageSpec.config!.toJson()),
),
]),
]);
await dir.create();
Logger.root.level = logLevel;
final result = await checkPackage(root: '${d.sandbox}/workspace');
expect(result, matcher);
final messages = <String>[];
final subscription =
Logger.root.onRecord.listen((record) => messages.add(record.message));
try {
final result = await checkPackage(root: '${d.sandbox}/workspace');
expect(result, matcher);
} finally {
await subscription.cancel();
}
return messages;
}
Loading
Loading