From 14c613f667918defb8fc67a655fadcfd3c611f31 Mon Sep 17 00:00:00 2001 From: Paul-Anatole CLAUDOT Date: Tue, 4 Nov 2025 09:30:34 +0100 Subject: [PATCH 1/2] Add new config parameters to better handle workspace (allow pinned and exclude workspace packages) --- README.md | 30 ++++++++ example/example.md | 14 +++- lib/src/dependency_validator.dart | 26 +++++-- lib/src/pubspec_config.dart | 8 ++ lib/src/pubspec_config.g.dart | 58 ++++++++++----- test/workspace_test.dart | 118 ++++++++++++++++++++++++++++++ 6 files changed, 229 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index dc4a9e1..45b1fa5 100644 --- a/README.md +++ b/README.md @@ -67,3 +67,33 @@ and your sub-packages should have `resolution: workspace` in their `pubspec.yaml ```bash $ dart run dependency_validator -C pkg1 ``` + +### Workspace Configuration + +When working with workspaces, you can configure workspace-specific settings in the root package's `dart_dependency_validator.yaml` file: + +```yaml +# dart_dependency_validator.yaml (in workspace root) + +# Allow pinned packages across the workspace +allow_pins: true + +# Ignore specific packages in all workspace sub-packages +workspace_global_ignore: + - some_package + - another_package + +# Skip validation for specific workspace packages +workspace_package_ignore: + - pkg1 + - pkg2 +``` + +#### Configuration Inheritance + +By default, sub-packages inherit certain configuration settings from the workspace root: +- `workspace_global_ignore`: Packages listed here will be ignored in all sub-packages +- `allow_pins`: If set to `true` in the workspace root, sub-packages will also allow pinned dependencies +- `ignore`: The standard ignore list from the workspace root is also inherited + +**Important**: If a sub-package has its own `dart_dependency_validator.yaml` file, it will take complete precedence over the workspace configuration. The local config file is always prioritized over any inherited workspace settings. diff --git a/example/example.md b/example/example.md index 22ce7db..681d931 100644 --- a/example/example.md +++ b/example/example.md @@ -29,5 +29,17 @@ ignore: - analyzer # Allow dependencies to be pinned to a specific version instead of a range -allowPins: true +allow_pins: true + +# Workspace-specific configuration (only applies to workspace root packages): + +# Ignore specific packages in all workspace sub-packages +workspace_global_ignore: + - some_package + - another_package + +# Skip validation for specific workspace packages +workspace_package_ignore: + - pkg1 + - pkg2 ``` diff --git a/lib/src/dependency_validator.dart b/lib/src/dependency_validator.dart index 7eb7d9e..25d728f 100644 --- a/lib/src/dependency_validator.dart +++ b/lib/src/dependency_validator.dart @@ -27,7 +27,10 @@ import 'pubspec_config.dart'; import 'utils.dart'; /// Check for missing, under-promoted, over-promoted, and unused dependencies. -Future checkPackage({required String root}) async { +Future checkPackage( + {required String root, + List? inheritedWorkspaceGlobalIgnore, + bool inheritedAllowedPins = false}) async { var result = true; if (!File('$root/pubspec.yaml').existsSync()) { logger.shout(red.wrap('pubspec.yaml not found')); @@ -37,7 +40,9 @@ Future checkPackage({required String root}) async { DepValidatorConfig config; final configFile = File('$root/dart_dependency_validator.yaml'); + var hasLocalFileConfig = false; if (configFile.existsSync()) { + hasLocalFileConfig = true; config = DepValidatorConfig.fromYaml(configFile.readAsStringSync()); } else { final pubspecConfig = PubspecDepValidatorConfig.fromYaml( @@ -66,7 +71,9 @@ Future checkPackage({required String root}) async { .nonNulls .toList(); logger.fine('excludes:\n${bulletItems(excludes.map((g) => g.pattern))}\n'); - final ignoredPackages = config.ignore; + final ignoredPackages = hasLocalFileConfig + ? config.ignore + : inheritedWorkspaceGlobalIgnore ?? config.ignore; logger.fine('ignored packages:\n${bulletItems(ignoredPackages)}\n'); // Read and parse the analysis_options.yaml in the current working directory. @@ -81,16 +88,25 @@ Future checkPackage({required String root}) async { var subResult = true; if (pubspec.isWorkspaceRoot) { + final workspacePackageIgnore = config.workspacePackageIgnore; logger.fine('In a workspace. Recursing through sub-packages...'); for (final package in pubspec.workspace ?? []) { - subResult &= await checkPackage(root: '$root/$package'); + if (workspacePackageIgnore.contains(package)) { + logger.info('Skipping ${package} because it is ignored'); + } else { + subResult &= await checkPackage( + root: '$root/$package', + inheritedWorkspaceGlobalIgnore: config.workspaceGlobalIgnore, + inheritedAllowedPins: config.allowPins); + } logger.info(''); } } logger.info('Validating dependencies for ${pubspec.name}...'); - - if (!config.allowPins) { + final allowedPins = + hasLocalFileConfig ? config.allowPins : inheritedAllowedPins; + if (!allowedPins) { checkPubspecForPins(pubspec, ignoredPackages: ignoredPackages); } diff --git a/lib/src/pubspec_config.dart b/lib/src/pubspec_config.dart index c9400a4..39a67c8 100644 --- a/lib/src/pubspec_config.dart +++ b/lib/src/pubspec_config.dart @@ -44,12 +44,20 @@ class DepValidatorConfig { @JsonKey(defaultValue: []) final List ignore; + @JsonKey(defaultValue: []) + final List workspaceGlobalIgnore; + + @JsonKey(defaultValue: []) + final List workspacePackageIgnore; + @JsonKey(defaultValue: false) final bool allowPins; const DepValidatorConfig({ this.exclude = const [], this.ignore = const [], + this.workspaceGlobalIgnore = const [], + this.workspacePackageIgnore = const [], this.allowPins = false, }); diff --git a/lib/src/pubspec_config.g.dart b/lib/src/pubspec_config.g.dart index 5d9586b..3811889 100644 --- a/lib/src/pubspec_config.g.dart +++ b/lib/src/pubspec_config.g.dart @@ -12,35 +12,55 @@ PubspecDepValidatorConfig _$PubspecDepValidatorConfigFromJson(Map json) => json, ($checkedConvert) { final val = PubspecDepValidatorConfig( - dependencyValidator: $checkedConvert( - 'dependency_validator', - (v) => v == null ? null : DepValidatorConfig.fromJson(v as Map), - ), + dependencyValidator: $checkedConvert('dependency_validator', + (v) => v == null ? null : DepValidatorConfig.fromJson(v as Map)), ); return val; }, fieldKeyMap: const {'dependencyValidator': 'dependency_validator'}, ); -DepValidatorConfig _$DepValidatorConfigFromJson(Map json) => - $checkedCreate('DepValidatorConfig', json, ($checkedConvert) { - final val = DepValidatorConfig( - exclude: $checkedConvert( - 'exclude', - (v) => (v as List?)?.map((e) => e as String).toList() ?? [], - ), - ignore: $checkedConvert( - 'ignore', - (v) => (v as List?)?.map((e) => e as String).toList() ?? [], - ), - allowPins: $checkedConvert('allow_pins', (v) => v as bool? ?? false), - ); - return val; - }, fieldKeyMap: const {'allowPins': 'allow_pins'}); +DepValidatorConfig _$DepValidatorConfigFromJson(Map json) => $checkedCreate( + 'DepValidatorConfig', + json, + ($checkedConvert) { + final val = DepValidatorConfig( + exclude: $checkedConvert( + 'exclude', + (v) => + (v as List?)?.map((e) => e as String).toList() ?? + []), + ignore: $checkedConvert( + 'ignore', + (v) => + (v as List?)?.map((e) => e as String).toList() ?? + []), + workspaceGlobalIgnore: $checkedConvert( + 'workspace_global_ignore', + (v) => + (v as List?)?.map((e) => e as String).toList() ?? + []), + workspacePackageIgnore: $checkedConvert( + 'workspace_package_ignore', + (v) => + (v as List?)?.map((e) => e as String).toList() ?? + []), + allowPins: $checkedConvert('allow_pins', (v) => v as bool? ?? false), + ); + return val; + }, + fieldKeyMap: const { + 'workspaceGlobalIgnore': 'workspace_global_ignore', + 'workspacePackageIgnore': 'workspace_package_ignore', + 'allowPins': 'allow_pins' + }, + ); Map _$DepValidatorConfigToJson(DepValidatorConfig instance) => { 'exclude': instance.exclude, 'ignore': instance.ignore, + 'workspace_global_ignore': instance.workspaceGlobalIgnore, + 'workspace_package_ignore': instance.workspacePackageIgnore, 'allow_pins': instance.allowPins, }; diff --git a/test/workspace_test.dart b/test/workspace_test.dart index 8498d39..93113ed 100644 --- a/test/workspace_test.dart +++ b/test/workspace_test.dart @@ -149,4 +149,122 @@ void main() => group('Workspaces', () { ), ); }); + + group('workspace_global_ignore', () { + test( + 'ignores packages in subpackages when set in workspace root', + () => checkWorkspace( + workspace: [], + workspaceDeps: {}, + workspaceConfig: DepValidatorConfig( + workspaceGlobalIgnore: ['http'], + ), + subpackage: usesHttp, + subpackageDeps: {}, + ), + ); + + test( + 'is inherited by subpackages without local config', + () => checkWorkspace( + workspace: usesHttp, + workspaceDeps: dependsOnHttp, + workspaceConfig: DepValidatorConfig( + workspaceGlobalIgnore: ['meta'], + ), + subpackage: usesMeta, + subpackageDeps: {}, + ), + ); + + test( + 'does not apply when subpackage has its own config', + () => checkWorkspace( + workspace: [], + workspaceDeps: {}, + workspaceConfig: DepValidatorConfig( + workspaceGlobalIgnore: ['http'], + ), + subpackage: usesHttp, + subpackageDeps: {}, + subpackageConfig: DepValidatorConfig(ignore: []), + matcher: isFalse, + ), + ); + }); + + group('workspace_package_ignore', () { + test( + 'skips validation for ignored workspace packages', + () => checkWorkspace( + workspace: [], + workspaceDeps: {}, + workspaceConfig: DepValidatorConfig( + workspacePackageIgnore: ['subpackage'], + ), + subpackage: usesHttp, + subpackageDeps: {}, + ), + ); + }); + + group('allow_pins inheritance', () { + // Note: Pin checking doesn't affect the return value of checkPackage, + // it only sets exitCode. These tests verify configuration inheritance, + // while actual pin detection is tested in executable_test.dart + + test( + 'workspace with explicit allow_pins configuration', + () => checkWorkspace( + workspace: usesHttp, + workspaceDeps: dependsOnHttp, + workspaceConfig: DepValidatorConfig(allowPins: true), + subpackage: usesMeta, + subpackageDeps: dependsOnMeta, + ), + ); + + test( + 'subpackage with local config can override workspace allow_pins', + () => checkWorkspace( + workspace: usesHttp, + workspaceDeps: dependsOnHttp, + workspaceConfig: DepValidatorConfig(allowPins: false), + subpackage: usesMeta, + subpackageDeps: dependsOnMeta, + subpackageConfig: DepValidatorConfig(allowPins: true), + ), + ); + }); + + group('configuration precedence', () { + test( + 'local config ignore list takes precedence over workspace global ignore', + () => checkWorkspace( + workspace: [], + workspaceDeps: {}, + workspaceConfig: DepValidatorConfig( + workspaceGlobalIgnore: ['http'], + ignore: ['meta'], + ), + subpackage: [...usesHttp, ...usesMeta], + subpackageDeps: {}, + subpackageConfig: DepValidatorConfig(ignore: ['http', 'meta']), + ), + ); + + test( + 'workspace root uses its own ignore list, not workspace_global_ignore', + () => checkWorkspace( + workspace: usesHttp, + workspaceDeps: {}, + workspaceConfig: DepValidatorConfig( + ignore: ['http'], + workspaceGlobalIgnore: ['meta'], + ), + subpackage: [], + subpackageDeps: {}, + ), + ); + }); }); From e092b5904d98e051e5af9a474eaa275e1aeedbfe Mon Sep 17 00:00:00 2001 From: Paul-Anatole CLAUDOT Date: Thu, 19 Feb 2026 17:02:23 +0100 Subject: [PATCH 2/2] fix: add support of workspace glob (* and **) --- lib/src/dependency_validator.dart | 20 ++++- test/utils.dart | 67 ++++++++++++++ test/workspace_test.dart | 139 ++++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 1 deletion(-) diff --git a/lib/src/dependency_validator.dart b/lib/src/dependency_validator.dart index 25d728f..33fc04f 100644 --- a/lib/src/dependency_validator.dart +++ b/lib/src/dependency_validator.dart @@ -90,7 +90,25 @@ Future checkPackage( if (pubspec.isWorkspaceRoot) { final workspacePackageIgnore = config.workspacePackageIgnore; logger.fine('In a workspace. Recursing through sub-packages...'); - for (final package in pubspec.workspace ?? []) { + + final subPackages = []; + for (final workspacePattern in pubspec.workspace ?? []) { + if (workspacePattern.contains('*')) { + final glob = makeGlob('$root/$workspacePattern'); + final matchingDirs = Directory(root) + .listSync(recursive: true) + .whereType() + .where((dir) => glob.matches(dir.path)) + .where((dir) => File('${dir.path}/pubspec.yaml').existsSync()) + .map((dir) => p.relative(dir.path, from: root)) + .toList(); + subPackages.addAll(matchingDirs); + } else { + subPackages.add(workspacePattern); + } + } + + for (final package in subPackages) { if (workspacePackageIgnore.contains(package)) { logger.info('Skipping ${package} because it is ignored'); } else { diff --git a/test/utils.dart b/test/utils.dart index 9b23add..cfef1da 100644 --- a/test/utils.dart +++ b/test/utils.dart @@ -114,3 +114,70 @@ Future checkWorkspace({ final result = await checkPackage(root: '${d.sandbox}/workspace'); expect(result, matcher); } + +Future checkWorkspaceWithMultiplePackages({ + required Map workspaceDeps, + required List workspacePatterns, + required Map subpackages, + required List workspace, + DepValidatorConfig? workspaceConfig, + Level logLevel = Level.OFF, + Matcher matcher = isTrue, +}) async { + final workspacePubspec = Pubspec( + 'workspace', + environment: requireDart36, + dependencies: workspaceDeps, + workspace: workspacePatterns, + ); + + final subpackageDirs = []; + for (final entry in subpackages.entries) { + final packageName = entry.key; + final config = entry.value; + final subpackagePubspec = Pubspec( + packageName, + environment: requireDart36, + dependencies: config.dependencies, + resolution: 'workspace', + ); + subpackageDirs.add( + d.dir(packageName, [ + ...config.descriptors, + d.file('pubspec.yaml', jsonEncode(subpackagePubspec.toJson())), + if (config.config != null) + d.file( + 'dart_dependency_validator.yaml', + jsonEncode(config.config!.toJson()), + ), + ]), + ); + } + + final dir = d.dir('workspace', [ + ...workspace, + d.file('pubspec.yaml', jsonEncode(workspacePubspec.toJson())), + if (workspaceConfig != null) + d.file( + 'dart_dependency_validator.yaml', + jsonEncode(workspaceConfig.toJson()), + ), + ...subpackageDirs, + ]); + await dir.create(); + Logger.root.level = logLevel; + final result = await checkPackage(root: '${d.sandbox}/workspace'); + expect(result, matcher); +} + +class SubpackageConfig { + final Map dependencies; + final List descriptors; + final DepValidatorConfig? config; + + SubpackageConfig({ + required this.dependencies, + required this.descriptors, + this.config, + }); +} diff --git a/test/workspace_test.dart b/test/workspace_test.dart index 93113ed..29f1818 100644 --- a/test/workspace_test.dart +++ b/test/workspace_test.dart @@ -267,4 +267,143 @@ void main() => group('Workspaces', () { ), ); }); + + group('glob patterns', () { + test( + 'handles single glob pattern matching multiple packages', + () => checkWorkspaceWithMultiplePackages( + workspace: [], + workspaceDeps: {}, + workspacePatterns: ['packages/*'], + subpackages: { + 'packages/package_a': SubpackageConfig( + dependencies: dependsOnHttp, + descriptors: usesHttp, + ), + 'packages/package_b': SubpackageConfig( + dependencies: dependsOnMeta, + descriptors: usesMeta, + ), + }, + ), + ); + + test( + 'handles nested glob pattern', + () => checkWorkspaceWithMultiplePackages( + workspace: [], + workspaceDeps: {}, + workspacePatterns: ['modules/*/packages/*'], + subpackages: { + 'modules/core/packages/utils': SubpackageConfig( + dependencies: dependsOnHttp, + descriptors: usesHttp, + ), + 'modules/ui/packages/components': SubpackageConfig( + dependencies: dependsOnMeta, + descriptors: usesMeta, + ), + }, + ), + ); + + test( + 'handles mix of glob patterns and literal paths', + () => checkWorkspaceWithMultiplePackages( + workspace: [], + workspaceDeps: {}, + workspacePatterns: ['packages/*', 'tools/special_package'], + subpackages: { + 'packages/package_a': SubpackageConfig( + dependencies: dependsOnHttp, + descriptors: usesHttp, + ), + 'tools/special_package': SubpackageConfig( + dependencies: dependsOnMeta, + descriptors: usesMeta, + ), + }, + ), + ); + + test( + 'ignores directories without pubspec.yaml when using glob', + () => checkWorkspaceWithMultiplePackages( + workspace: [], + workspaceDeps: {}, + workspacePatterns: ['packages/*'], + subpackages: { + 'packages/package_with_pubspec': SubpackageConfig( + dependencies: dependsOnHttp, + descriptors: usesHttp, + ), + // packages/not_a_package directory will be created but without pubspec.yaml + }, + ), + ); + + test( + 'fails when glob-matched subpackage has an issue', + () => checkWorkspaceWithMultiplePackages( + workspace: [], + workspaceDeps: {}, + workspacePatterns: ['packages/*'], + subpackages: { + 'packages/package_a': SubpackageConfig( + dependencies: dependsOnHttp, + descriptors: usesHttp, + ), + 'packages/package_b': SubpackageConfig( + dependencies: {}, + descriptors: usesHttp, // Uses http but doesn't declare it + ), + }, + matcher: isFalse, + ), + ); + + test( + 'workspace_package_ignore works with glob patterns', + () => checkWorkspaceWithMultiplePackages( + workspace: [], + workspaceDeps: {}, + workspacePatterns: ['packages/*'], + workspaceConfig: DepValidatorConfig( + workspacePackageIgnore: ['packages/package_b'], + ), + subpackages: { + 'packages/package_a': SubpackageConfig( + dependencies: dependsOnHttp, + descriptors: usesHttp, + ), + 'packages/package_b': SubpackageConfig( + dependencies: {}, + descriptors: usesHttp, // Has issue but should be ignored + ), + }, + ), + ); + + test( + 'workspace_global_ignore applies to glob-matched packages', + () => checkWorkspaceWithMultiplePackages( + workspace: [], + workspaceDeps: {}, + workspacePatterns: ['packages/*'], + workspaceConfig: DepValidatorConfig( + workspaceGlobalIgnore: ['http'], + ), + subpackages: { + 'packages/package_a': SubpackageConfig( + dependencies: {}, + descriptors: usesHttp, // Uses http but it's globally ignored + ), + 'packages/package_b': SubpackageConfig( + dependencies: dependsOnMeta, + descriptors: usesMeta, + ), + }, + ), + ); + }); });