diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cb890af..389cb1c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,14 +10,14 @@ jobs: build: uses: Workiva/gha-dart-oss/.github/workflows/build.yaml@v0.1.15 with: - sdk: 3.7.2 # mirrors .tool-versions + sdk: 3.8.1 # mirrors .tool-versions checks: uses: Workiva/gha-dart-oss/.github/workflows/checks.yaml@v0.1.15 with: - sdk: 3.7.2 # mirrors .tool-versions + sdk: 3.8.1 # mirrors .tool-versions unit-tests: uses: Workiva/gha-dart-oss/.github/workflows/test-unit.yaml@v0.1.15 with: - sdk: 3.7.2 # mirrors .tool-versions + sdk: 3.8.1 # mirrors .tool-versions diff --git a/.tool-versions b/.tool-versions index ed696fb..afa1372 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -dart 3.7.2 +dart 3.8.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d3057c..cc861b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ newest one the analyzer knows about, which may be unreleased and reject valid code +- Added support for Dart `@docImport` documentation imports when scanning + package usage. +- Packages referenced only via `@docImport` in `lib/` must still be declared + in `pubspec.yaml` as either a `dependency` or `dev_dependency`; when + declared, they are accepted in either section and are not flagged as + over-promoted or unused. If such a package also has real imports outside + `lib/`, the normal over-promotion check still applies. +- Removed the internal `getDartDirectivePackageNames` API in favor of + `getDartPackageUsage`. +- **Breaking:** requires Dart 3.8 or above. + - Allow up to analyzer 14 - Fix warning when `analyzer` is depended on but not used so that it is still diff --git a/lib/src/constants.dart b/lib/src/constants.dart index a7e7d29..17274a6 100644 --- a/lib/src/constants.dart +++ b/lib/src/constants.dart @@ -53,8 +53,8 @@ class DependencyPinEvaluation { /// possible prerelease. static const DependencyPinEvaluation buildOrPrerelease = DependencyPinEvaluation._( - 'Builds or preleases as max bounds block minor bumps and patches.', - ); + 'Builds or preleases as max bounds block minor bumps and patches.', + ); /// 1.2.3 static const DependencyPinEvaluation directPin = DependencyPinEvaluation._( diff --git a/lib/src/dependency_validator.dart b/lib/src/dependency_validator.dart index 835a360..192edd0 100644 --- a/lib/src/dependency_validator.dart +++ b/lib/src/dependency_validator.dart @@ -58,7 +58,7 @@ Future checkPackage({required String root}) async { .map((s) { try { return makeGlob("$root/$s"); - } catch (_, __) { + } catch (_) { logger.shout(yellow.wrap('invalid glob syntax: "$s"')); return null; } @@ -148,13 +148,14 @@ Future checkPackage({required String root}) async { '${bulletItems(publicLessFiles.map((f) => f.path))}\n', ); - // Read each file in lib/ and parse the package names from every import and - // export directive. + // Read each file in lib/ and parse the package names from every import, + // export directive, and doc import. final packagesUsedInPublicFiles = {}; + final packagesUsedViaDocImportInPublicFiles = {}; for (final file in publicDartFiles) { - packagesUsedInPublicFiles.addAll( - getDartDirectivePackageNames(file, featureSet: featureSet), - ); + final usage = getDartPackageUsage(file, featureSet: featureSet); + packagesUsedInPublicFiles.addAll(usage.directivePackageNames); + packagesUsedViaDocImportInPublicFiles.addAll(usage.docImportPackageNames); } for (final file in publicScssFiles) { final matches = importScssPackageRegex.allMatches(file.readAsStringSync()); @@ -206,15 +207,18 @@ Future checkPackage({required String root}) async { ); // Read each file outside lib/ and parse the package names from every - // import and export directive. + // import, export directive, and doc import. final packagesUsedOutsidePublicDirs = { // For more info on analysis options: // https://dart.dev/guides/language/analysis-options#the-analysis-options-file if (optionsIncludePackage != null) optionsIncludePackage, }; + final packagesUsedViaDocImportOutsidePublicDirs = {}; for (final file in nonPublicDartFiles) { - packagesUsedOutsidePublicDirs.addAll( - getDartDirectivePackageNames(file, featureSet: featureSet), + final usage = getDartPackageUsage(file, featureSet: featureSet); + packagesUsedOutsidePublicDirs.addAll(usage.directivePackageNames); + packagesUsedViaDocImportOutsidePublicDirs.addAll( + usage.docImportPackageNames, ); } for (final file in nonPublicScssFiles) { @@ -230,6 +234,19 @@ Future checkPackage({required String root}) async { } } + // Packages that are doc-imported in lib/ and have no real (non-doc) usage + // anywhere outside lib/. Doc imports are not runtime dependencies, so these + // are valid in either `dependencies` or `dev_dependencies`. A package with + // real usage outside lib/ is still subject to the normal over-promotion check. + final packagesUsedOnlyViaDocImport = packagesUsedViaDocImportInPublicFiles + .difference(packagesUsedOutsidePublicDirs); + + // Doc imports are not runtime dependencies, so treat them like usage outside + // lib/ for the missing/unused dependency checks. + packagesUsedOutsidePublicDirs + ..addAll(packagesUsedViaDocImportOutsidePublicDirs) + ..addAll(packagesUsedViaDocImportInPublicFiles); + logger.fine( 'packages used outside public dirs:\n' '${bulletItems(packagesUsedOutsidePublicDirs)}\n', @@ -283,9 +300,12 @@ Future checkPackage({required String root}) async { final overPromotedDependencies = // Start with dependencies that are not used in lib/ (deps - .difference(packagesUsedInPublicFiles) - // Intersect with deps that are used outside lib/ (excludes unused deps) - .intersection(packagesUsedOutsidePublicDirs)) + .difference(packagesUsedInPublicFiles) + // Intersect with deps that are used outside lib/ (excludes unused deps) + .intersection(packagesUsedOutsidePublicDirs)) + // Doc-import-only packages are accepted in either dependencies or + // dev_dependencies. + ..removeAll(packagesUsedOnlyViaDocImport) // Ignore known over-promoted packages. ..removeAll(ignoredPackages); @@ -343,11 +363,12 @@ Future checkPackage({required String root}) async { pubspec.dependencies.keys, '.', ); - bool rootPackageReferencesDependencyInBuildYaml(String dependencyName) => [ - ...rootBuildConfig.globalOptions.keys, - for (final target in rootBuildConfig.buildTargets.values) - ...target.builders.keys, - ] + bool rootPackageReferencesDependencyInBuildYaml(String dependencyName) => + [ + ...rootBuildConfig.globalOptions.keys, + for (final target in rootBuildConfig.buildTargets.values) + ...target.builders.keys, + ] .map((key) => normalizeBuilderKeyUsage(key, pubspec.name)) .any((key) => key.startsWith('$dependencyName:')); @@ -386,8 +407,9 @@ Future checkPackage({required String root}) async { if (providesExecutable(package)) package, }; - final nonDevPackagesWithExecutables = - packagesWithExecutables.where(pubspec.dependencies.containsKey).toSet(); + final nonDevPackagesWithExecutables = packagesWithExecutables + .where(pubspec.dependencies.containsKey) + .toSet(); if (nonDevPackagesWithExecutables.isNotEmpty) { logIntersection( Level.WARNING, @@ -434,10 +456,7 @@ Future checkPackage({required String root}) async { Future dependencyDefinesAutoAppliedBuilder(String path) async => (await BuildConfig.fromPackageDir( path, - )) - .builderDefinitions - .values - .any((def) => def.autoApply != AutoApply.none); + )).builderDefinitions.values.any((def) => def.autoApply != AutoApply.none); /// Checks for dependency pins. /// diff --git a/lib/src/import_export_ast_visitor.dart b/lib/src/import_export_ast_visitor.dart index 41df307..6628798 100644 --- a/lib/src/import_export_ast_visitor.dart +++ b/lib/src/import_export_ast_visitor.dart @@ -4,6 +4,7 @@ import 'package:analyzer/dart/analysis/features.dart'; import 'package:analyzer/dart/analysis/results.dart'; import 'package:analyzer/dart/analysis/utilities.dart'; import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/token.dart'; import 'package:analyzer/dart/ast/visitor.dart'; import 'package:pub_semver/pub_semver.dart'; @@ -22,9 +23,23 @@ FeatureSet featureSetForSdkConstraint(VersionConstraint? sdkConstraint) { ); } -/// Returns the list of package names that are exported and imported into the -/// provided dart file -Set getDartDirectivePackageNames(File file, {FeatureSet? featureSet}) { +/// Package names referenced in a Dart file via import/export directives and +/// doc imports. +class DartPackageUsage { + /// Package names from `import` and `export` directives. + final Set directivePackageNames; + + /// Package names from `@docImport` documentation imports. + final Set docImportPackageNames; + + const DartPackageUsage({ + required this.directivePackageNames, + required this.docImportPackageNames, + }); +} + +/// Returns the package names referenced in the provided Dart file. +DartPackageUsage getDartPackageUsage(File file, {FeatureSet? featureSet}) { ParseStringResult parsed; try { parsed = parseString( @@ -39,27 +54,102 @@ Set getDartDirectivePackageNames(File file, {FeatureSet? featureSet}) { } final visitor = ImportExportVisitor(); - parsed.unit.visitChildren(visitor); - return visitor.packageNames; + parsed.unit.accept(visitor); + _collectDocImportsFromPrecedingComments( + parsed.unit.beginToken.precedingComments, + visitor.docImportPackageNames, + ); + return DartPackageUsage( + directivePackageNames: visitor.directivePackageNames, + docImportPackageNames: visitor.docImportPackageNames, + ); } -class ImportExportVisitor extends GeneralizingAstVisitor { - Set packageNames = {}; +/// Collects `@docImport` package names from comment tokens that are not +/// attached to any AST node (e.g. a file containing only a doc comment). +/// +/// Mirrors the analyzer's own doc comment parsing as closely as is practical: +/// only `///` and `/** */` doc comments are considered, `@docImport` must start +/// a line, and fenced code blocks are skipped. +void _collectDocImportsFromPrecedingComments( + Token? commentToken, + Set docImportPackageNames, +) { + var inFencedCodeBlock = false; + for (var token = commentToken; token != null; token = token.next) { + if (token is! CommentToken) continue; - @override - void visitDirective(Directive node) { - if (node is! UriBasedDirective) return; + final lexeme = token.lexeme; + final isBlockDocComment = lexeme.startsWith('/**'); + if (!isBlockDocComment && !lexeme.startsWith('///')) continue; + + // A block doc comment is self-contained; don't carry fence state into it. + if (isBlockDocComment) inFencedCodeBlock = false; + + for (final line in lexeme.split('\n')) { + final content = _stripDocCommentDecoration(line); + if (content.startsWith('```')) { + inFencedCodeBlock = !inFencedCodeBlock; + continue; + } + if (inFencedCodeBlock) continue; + _collectDocImportFromLine(content, docImportPackageNames); + } + } +} + +/// Strips the leading `///`, `/**`, or ` * ` and trailing `*/` from a single +/// line of a doc comment lexeme. +String _stripDocCommentDecoration(String line) { + var content = line.trim(); + if (content.startsWith('///') || content.startsWith('/**')) { + content = content.substring(3); + } else if (content.startsWith('*')) { + content = content.substring(1); + } + if (content.endsWith('*/')) { + content = content.substring(0, content.length - 2); + } + return content.trim(); +} + +final _docImportUriPattern = RegExp(r'''^@docImport\s+(['"])(.+?)\1'''); - final uri = node.uri.stringValue; - if (uri == null) return; +void _collectDocImportFromLine(String line, Set docImportPackageNames) { + final match = _docImportUriPattern.firstMatch(line); + if (match == null) return; + _addPackageName(match.group(2), docImportPackageNames); +} + +void _addPackageName(String? uri, Set packageNames) { + if (uri == null) return; - // ignore relative path imports - if (!uri.startsWith('package:')) return; + // ignore relative path imports + if (!uri.startsWith('package:')) return; - final packageParts = uri.substring('package:'.length).split('/'); - if (packageParts.isEmpty) - return; // sanity check, this probably will never happen + final packageParts = uri.substring('package:'.length).split('/'); + if (packageParts.isEmpty) return; + + packageNames.add(packageParts.first); +} - packageNames.add(packageParts.first); +class ImportExportVisitor extends GeneralizingAstVisitor { + Set directivePackageNames = {}; + Set docImportPackageNames = {}; + + @override + void visitDirective(Directive node) { + if (node is UriBasedDirective) { + _addPackageName(node.uri.stringValue, directivePackageNames); + } + super.visitDirective(node); + } + + @override + void visitComment(Comment node) { + for (final docImport in node.docImports) { + _addPackageName(docImport.import.uri.stringValue, docImportPackageNames); + } + super.visitComment(node); } } diff --git a/lib/src/pubspec_config.dart b/lib/src/pubspec_config.dart index c9400a4..470fb1c 100644 --- a/lib/src/pubspec_config.dart +++ b/lib/src/pubspec_config.dart @@ -17,7 +17,7 @@ class PubspecDepValidatorConfig { dependencyValidator.ignore.isNotEmpty; PubspecDepValidatorConfig({DepValidatorConfig? dependencyValidator}) - : dependencyValidator = dependencyValidator ?? DepValidatorConfig(); + : dependencyValidator = dependencyValidator ?? DepValidatorConfig(); factory PubspecDepValidatorConfig.fromJson(Map json) => _$PubspecDepValidatorConfigFromJson(json); diff --git a/pubspec.yaml b/pubspec.yaml index 3420e1a..d65e9eb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,10 +4,10 @@ description: Checks for missing, under-promoted, over-promoted, and unused depen homepage: https://github.com/Workiva/dependency_validator environment: - sdk: ^3.0.0 + sdk: ^3.8.0 dependencies: - analyzer: ">=7.1.0 <15.0.0" + analyzer: ">=8.0.0 <15.0.0" args: ^2.0.0 build_config: ^1.0.0 checked_yaml: ^2.0.1 @@ -18,7 +18,7 @@ dependencies: package_config: ">=2.0.0 <4.0.0" path: ^1.8.0 pub_semver: ^2.0.0 - pubspec_parse: ^1.5.0 + pubspec_parse: ^1.6.0 yaml: ^3.1.0 dev_dependencies: diff --git a/test/executable_test.dart b/test/executable_test.dart index a452d16..734424b 100644 --- a/test/executable_test.dart +++ b/test/executable_test.dart @@ -261,6 +261,181 @@ void main() { ); }); + group('doc imports', () { + test( + 'passes when a dev_dependency is only referenced via doc import in lib/', + () async { + result = await checkProject( + devDependencies: {'meta': hostedAny}, + environment: requireDart38, + project: [ + d.dir('lib', [ + d.file('main.dart', ''' +/// @docImport 'package:meta/meta.dart'; +library; + +/// References [Deprecated]. +class Foo {} +'''), + ]), + ], + ); + + expect(result.exitCode, 0); + expect(result.stdout, contains('No dependency issues found!')); + }, + ); + + test( + 'fails when a package referenced via doc import in lib/ is missing from pubspec', + () async { + result = await checkProject( + environment: requireDart38, + project: [ + d.dir('lib', [ + d.file('main.dart', ''' +/// @docImport 'package:meta/meta.dart'; +library; + +/// References [Deprecated]. +class Foo {} +'''), + ]), + ], + ); + + expect(result.exitCode, 1); + expect( + result.stderr, + contains( + 'These packages are used outside lib/ but are not dev_dependencies:', + ), + ); + expect(result.stderr, contains('meta')); + }, + ); + + test( + 'passes when a dependency is only referenced via doc import in lib/', + () async { + result = await checkProject( + dependencies: {'meta': hostedAny}, + environment: requireDart38, + project: [ + d.dir('lib', [ + d.file('main.dart', ''' +/// @docImport 'package:meta/meta.dart'; +library; + +/// References [Deprecated]. +class Foo {} +'''), + ]), + ], + ); + + expect(result.exitCode, 0); + expect(result.stdout, contains('No dependency issues found!')); + }, + ); + + test('flags a dependency as over-promoted when it is doc-imported in lib/ ' + 'but only truly imported outside lib/', () async { + result = await checkProject( + dependencies: {'meta': hostedAny}, + environment: requireDart38, + project: [ + d.dir('lib', [ + d.file('main.dart', ''' +/// @docImport 'package:meta/meta.dart'; +library; + +/// References [Deprecated]. +class Foo {} +'''), + ]), + d.dir('test', [ + d.file('main_test.dart', ''' +import 'package:meta/meta.dart'; + +void main() {} +'''), + ]), + ], + ); + + expect(result.exitCode, 1); + expect( + result.stderr, + contains( + 'These packages are only used outside lib/ and should be downgraded to dev_dependencies:', + ), + ); + expect(result.stderr, contains('meta')); + }); + + test( + 'accepts a dependency that is doc-imported in both lib/ and outside lib/', + () async { + result = await checkProject( + dependencies: {'meta': hostedAny}, + environment: requireDart38, + project: [ + d.dir('lib', [ + d.file('main.dart', ''' +/// @docImport 'package:meta/meta.dart'; +library; + +/// References [Deprecated]. +class Foo {} +'''), + ]), + d.dir('test', [ + d.file('main_test.dart', ''' +/// @docImport 'package:meta/meta.dart'; +library; + +/// References [Deprecated]. +void main() {} +'''), + ]), + ], + ); + + expect(result.exitCode, 0); + expect(result.stdout, contains('No dependency issues found!')); + }, + ); + + test('does not flag doc-import-only packages as unused', () async { + result = await checkProject( + devDependencies: {'meta': hostedAny}, + environment: requireDart38, + project: [ + d.dir('lib', [ + d.file('main.dart', ''' +/// @docImport 'package:meta/meta.dart'; +library; + +/// References [Deprecated]. +class Foo {} +'''), + ]), + ], + ); + + expect(result.exitCode, 0); + expect( + result.stderr, + isNot( + contains( + 'These packages may be unused, or you may be using assets from these packages:', + ), + ), + ); + }); + }); + test( 'warns when the analyzer package is depended on but not used', () async { diff --git a/test/import_export_ast_visitor_test.dart b/test/import_export_ast_visitor_test.dart new file mode 100644 index 0000000..7653822 --- /dev/null +++ b/test/import_export_ast_visitor_test.dart @@ -0,0 +1,219 @@ +import 'dart:io'; + +import 'package:dependency_validator/src/import_export_ast_visitor.dart'; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; + +void main() { + group('getDartPackageUsage', () { + test('collects import and export directives', () async { + await d.dir('project', [ + d.file('main.dart', ''' +import 'package:logging/logging.dart'; +export 'package:meta/meta.dart'; +'''), + ]).create(); + + final usage = getDartPackageUsage(File('${d.sandbox}/project/main.dart')); + + expect(usage.directivePackageNames, {'logging', 'meta'}); + expect(usage.docImportPackageNames, isEmpty); + }); + + test('collects doc imports from documentation comments', () async { + await d.dir('project', [ + d.file('main.dart', ''' +/// @docImport 'package:meta/meta.dart'; +library; + +/// References [Deprecated]. +class Foo {} +'''), + ]).create(); + + final usage = getDartPackageUsage(File('${d.sandbox}/project/main.dart')); + + expect(usage.directivePackageNames, isEmpty); + expect(usage.docImportPackageNames, {'meta'}); + }); + + test( + 'collects doc imports from declaration doc comments without a library directive', + () async { + await d.dir('project', [ + d.file('main.dart', ''' +/// @docImport 'package:meta/meta.dart'; +/// References [Deprecated]. +class Foo {} +'''), + ]).create(); + + final usage = getDartPackageUsage( + File('${d.sandbox}/project/main.dart'), + ); + + expect(usage.directivePackageNames, isEmpty); + expect(usage.docImportPackageNames, {'meta'}); + }, + ); + + test( + 'collects file-level dangling doc imports from beginToken.precedingComments', + () async { + await d.dir('project', [ + d.file('main.dart', ''' +/// @docImport 'package:meta/meta.dart'; +'''), + ]).create(); + + final usage = getDartPackageUsage( + File('${d.sandbox}/project/main.dart'), + ); + + expect(usage.directivePackageNames, isEmpty); + expect(usage.docImportPackageNames, {'meta'}); + }, + ); + + test('ignores @docImport text in non-doc comments', () async { + await d.dir('project', [ + d.file('main.dart', ''' +// @docImport 'package:meta/meta.dart'; +/* @docImport 'package:yaml/yaml.dart'; */ +// /// @docImport 'package:logging/logging.dart'; +class Foo {} +'''), + ]).create(); + + final usage = getDartPackageUsage(File('${d.sandbox}/project/main.dart')); + + expect(usage.directivePackageNames, isEmpty); + expect(usage.docImportPackageNames, isEmpty); + }); + + test( + 'ignores @docImport inside fenced code blocks in doc comments', + () async { + await d.dir('project', [ + d.file('main.dart', ''' +/// Example: +/// ```dart +/// /// @docImport 'package:meta/meta.dart'; +/// ``` +library; + +/** Another example: + * ``` + * /// @docImport 'package:yaml/yaml.dart'; + * ``` + */ +class Foo {} +'''), + ]).create(); + + final usage = getDartPackageUsage( + File('${d.sandbox}/project/main.dart'), + ); + + expect(usage.docImportPackageNames, isEmpty); + }, + ); + + test('ignores @docImport mentioned mid-line in a doc comment', () async { + await d.dir('project', [ + d.file('main.dart', ''' +/// Use `@docImport 'package:meta/meta.dart';` to reference [Deprecated]. +library; +'''), + ]).create(); + + final usage = getDartPackageUsage(File('${d.sandbox}/project/main.dart')); + + expect(usage.docImportPackageNames, isEmpty); + }); + + test('collects both directives and doc imports', () async { + await d.dir('project', [ + d.file('main.dart', ''' +/// @docImport 'package:yaml/yaml.dart'; +library; + +import 'package:logging/logging.dart'; + +/// References [YamlMap]. +class Foo {} +'''), + ]).create(); + + final usage = getDartPackageUsage(File('${d.sandbox}/project/main.dart')); + + expect(usage.directivePackageNames, {'logging'}); + expect(usage.docImportPackageNames, {'yaml'}); + }); + + test('collects package names from doc imports with show clauses', () async { + await d.dir('project', [ + d.file('main.dart', ''' +/// @docImport 'package:collection/collection.dart' show IterableExtension; +library; + +/// References [IterableExtension]. +class Foo {} +'''), + ]).create(); + + final usage = getDartPackageUsage(File('${d.sandbox}/project/main.dart')); + + expect(usage.docImportPackageNames, {'collection'}); + }); + + test('collects package names from doc imports with as clauses', () async { + await d.dir('project', [ + d.file('main.dart', ''' +/// @docImport 'package:collection/collection.dart' as collection; +library; + +/// References [collection.IterableExtension]. +class Foo {} +'''), + ]).create(); + + final usage = getDartPackageUsage(File('${d.sandbox}/project/main.dart')); + + expect(usage.docImportPackageNames, {'collection'}); + }); + + test('collects doc imports from bin/ files', () async { + await d.dir('project', [ + d.dir('bin', [ + d.file('main.dart', ''' +/// @docImport 'package:meta/meta.dart'; +/// References [Deprecated]. +void main() {} +'''), + ]), + ]).create(); + + final usage = getDartPackageUsage( + File('${d.sandbox}/project/bin/main.dart'), + ); + + expect(usage.directivePackageNames, isEmpty); + expect(usage.docImportPackageNames, {'meta'}); + }); + + test('ignores relative and dart scheme imports', () async { + await d.dir('project', [ + d.file('main.dart', ''' +/// @docImport 'dart:async'; +import 'other.dart'; +'''), + ]).create(); + + final usage = getDartPackageUsage(File('${d.sandbox}/project/main.dart')); + + expect(usage.directivePackageNames, isEmpty); + expect(usage.docImportPackageNames, isEmpty); + }); + }); +} diff --git a/test/nested_packages_test.dart b/test/nested_packages_test.dart index df1c643..15f9eb6 100644 --- a/test/nested_packages_test.dart +++ b/test/nested_packages_test.dart @@ -9,198 +9,192 @@ import 'pubspec_to_json.dart'; import 'utils.dart'; void main() => group('Nested packages', () { - initLogs(); - - test('ignores dependencies used only in nested packages', () async { - final rootPubspec = Pubspec( - 'code_assets', - environment: requireDart36, - dependencies: { - 'http': HostedDependency(version: VersionConstraint.any), - }, - ); - - final nestedPubspec = Pubspec( - 'host_name', - environment: requireDart36, - devDependencies: { - 'ffigen': HostedDependency(version: VersionConstraint.any), - }, - ); - - final dir = d.dir('code_assets', [ - d.file('pubspec.yaml', jsonEncode(rootPubspec.toJson())), + initLogs(); + + test('ignores dependencies used only in nested packages', () async { + final rootPubspec = Pubspec( + 'code_assets', + environment: requireDart36, + dependencies: {'http': HostedDependency(version: VersionConstraint.any)}, + ); + + final nestedPubspec = Pubspec( + 'host_name', + environment: requireDart36, + devDependencies: { + 'ffigen': HostedDependency(version: VersionConstraint.any), + }, + ); + + final dir = d.dir('code_assets', [ + d.file('pubspec.yaml', jsonEncode(pubspecToJson(rootPubspec))), + d.dir('lib', [ + d.file('code_assets.dart', 'import "package:http/http.dart";'), + ]), + d.dir('example', [ + d.dir('host_name', [ + d.file('pubspec.yaml', jsonEncode(pubspecToJson(nestedPubspec))), + d.dir('tool', [ + d.file('ffigen.dart', 'import "package:ffigen/ffigen.dart";'), + ]), d.dir('lib', [ - d.file('code_assets.dart', 'import "package:http/http.dart";'), + d.file('host_name.dart', 'import "package:archive/archive.dart";'), ]), - d.dir('example', [ - d.dir('host_name', [ - d.file('pubspec.yaml', jsonEncode(nestedPubspec.toJson())), - d.dir('tool', [ - d.file('ffigen.dart', 'import "package:ffigen/ffigen.dart";'), - ]), - d.dir('lib', [ - d.file( - 'host_name.dart', 'import "package:archive/archive.dart";'), - ]), + ]), + ]), + ]); + + await dir.create(); + final result = await checkPackage(root: '${d.sandbox}/code_assets'); + expect(result, isTrue); + }); + + test( + 'fails when root package itself has undeclared dependencies outside nested packages', + () async { + final rootPubspec = Pubspec( + 'code_assets', + environment: requireDart36, + dependencies: {}, + ); + + final nestedPubspec = Pubspec( + 'host_name', + environment: requireDart36, + devDependencies: { + 'ffigen': HostedDependency(version: VersionConstraint.any), + }, + ); + + final dir = d.dir('code_assets_with_issue', [ + d.file('pubspec.yaml', jsonEncode(pubspecToJson(rootPubspec))), + d.dir('tool', [ + // Undeclared dependency in root package's own tool dir + d.file('root_tool.dart', 'import "package:meta/meta.dart";'), + ]), + d.dir('example', [ + d.dir('host_name', [ + d.file('pubspec.yaml', jsonEncode(pubspecToJson(nestedPubspec))), + d.dir('tool', [ + d.file('ffigen.dart', 'import "package:ffigen/ffigen.dart";'), ]), ]), - ]); - - await dir.create(); - final result = await checkPackage(root: '${d.sandbox}/code_assets'); - expect(result, isTrue); - }); - - test( - 'fails when root package itself has undeclared dependencies outside nested packages', - () async { - final rootPubspec = Pubspec( - 'code_assets', - environment: requireDart36, - dependencies: {}, - ); - - final nestedPubspec = Pubspec( - 'host_name', - environment: requireDart36, - devDependencies: { - 'ffigen': HostedDependency(version: VersionConstraint.any), - }, - ); - - final dir = d.dir('code_assets_with_issue', [ - d.file('pubspec.yaml', jsonEncode(rootPubspec.toJson())), - d.dir('tool', [ - // Undeclared dependency in root package's own tool dir - d.file('root_tool.dart', 'import "package:meta/meta.dart";'), - ]), - d.dir('example', [ - d.dir('host_name', [ - d.file('pubspec.yaml', jsonEncode(nestedPubspec.toJson())), - d.dir('tool', [ - d.file('ffigen.dart', 'import "package:ffigen/ffigen.dart";'), - ]), + ]), + ]); + + await dir.create(); + final result = await checkPackage( + root: '${d.sandbox}/code_assets_with_issue', + ); + expect(result, isFalse); + }, + ); + + test('ignores deeply nested packages', () async { + final rootPubspec = Pubspec('root_pkg', environment: requireDart36); + + final deeplyNestedPubspec = Pubspec('deep_pkg', environment: requireDart36); + + final dir = d.dir('root_pkg', [ + d.file('pubspec.yaml', jsonEncode(pubspecToJson(rootPubspec))), + d.dir('example', [ + d.dir('nested', [ + d.dir('deep', [ + d.file( + 'pubspec.yaml', + jsonEncode(pubspecToJson(deeplyNestedPubspec)), + ), + d.dir('lib', [ + d.file('deep.dart', 'import "package:meta/meta.dart";'), ]), ]), - ]); - - await dir.create(); - final result = - await checkPackage(root: '${d.sandbox}/code_assets_with_issue'); - expect(result, isFalse); - }); - - test('ignores deeply nested packages', () async { - final rootPubspec = Pubspec( - 'root_pkg', - environment: requireDart36, - ); - - final deeplyNestedPubspec = Pubspec( - 'deep_pkg', - environment: requireDart36, - ); - - final dir = d.dir('root_pkg', [ - d.file('pubspec.yaml', jsonEncode(rootPubspec.toJson())), - d.dir('example', [ - d.dir('nested', [ - d.dir('deep', [ - d.file( - 'pubspec.yaml', jsonEncode(deeplyNestedPubspec.toJson())), - d.dir('lib', [ - d.file('deep.dart', 'import "package:meta/meta.dart";'), - ]), - ]), - ]), + ]), + ]), + ]); + + await dir.create(); + final result = await checkPackage(root: '${d.sandbox}/root_pkg'); + expect(result, isTrue); + }); + + test('ignores SCSS and Less files in nested packages', () async { + final rootPubspec = Pubspec('web_pkg', environment: requireDart36); + + final nestedPubspec = Pubspec('nested_web_pkg', environment: requireDart36); + + final dir = d.dir('web_pkg', [ + d.file('pubspec.yaml', jsonEncode(pubspecToJson(rootPubspec))), + d.dir('example', [ + d.dir('nested_web', [ + d.file('pubspec.yaml', jsonEncode(pubspecToJson(nestedPubspec))), + d.dir('web', [ + d.file('style.scss', '@import "package:foo_styles/style.scss";'), + d.file('style.less', '@import "packages/bar_styles/style.less";'), ]), - ]); - - await dir.create(); - final result = await checkPackage(root: '${d.sandbox}/root_pkg'); - expect(result, isTrue); - }); - - test('ignores SCSS and Less files in nested packages', () async { - final rootPubspec = Pubspec( - 'web_pkg', - environment: requireDart36, - ); - - final nestedPubspec = Pubspec( - 'nested_web_pkg', - environment: requireDart36, - ); - - final dir = d.dir('web_pkg', [ - d.file('pubspec.yaml', jsonEncode(rootPubspec.toJson())), - d.dir('example', [ - d.dir('nested_web', [ - d.file('pubspec.yaml', jsonEncode(nestedPubspec.toJson())), - d.dir('web', [ - d.file( - 'style.scss', '@import "package:foo_styles/style.scss";'), - d.file( - 'style.less', '@import "packages/bar_styles/style.less";'), - ]), + ]), + ]), + ]); + + await dir.create(); + final result = await checkPackage(root: '${d.sandbox}/web_pkg'); + expect(result, isTrue); + }); + + test( + 'works with workspace subpackages that contain nested packages', + () async { + final workspacePubspec = Pubspec( + 'workspace_root', + environment: requireDart36, + workspace: ['pkgs/code_assets'], + ); + + final subpackagePubspec = Pubspec( + 'code_assets', + environment: requireDart36, + resolution: 'workspace', + dependencies: { + 'http': HostedDependency(version: VersionConstraint.any), + }, + ); + + final nestedPubspec = Pubspec( + 'host_name', + environment: requireDart36, + dependencies: { + 'ffigen': HostedDependency(version: VersionConstraint.any), + }, + ); + + final dir = d.dir('workspace', [ + d.file('pubspec.yaml', jsonEncode(pubspecToJson(workspacePubspec))), + d.dir('pkgs', [ + d.dir('code_assets', [ + d.file( + 'pubspec.yaml', + jsonEncode(pubspecToJson(subpackagePubspec)), + ), + d.dir('lib', [ + d.file('code_assets.dart', 'import "package:http/http.dart";'), ]), - ]), - ]); - - await dir.create(); - final result = await checkPackage(root: '${d.sandbox}/web_pkg'); - expect(result, isTrue); - }); - - test('works with workspace subpackages that contain nested packages', - () async { - final workspacePubspec = Pubspec( - 'workspace_root', - environment: requireDart36, - workspace: ['pkgs/code_assets'], - ); - - final subpackagePubspec = Pubspec( - 'code_assets', - environment: requireDart36, - resolution: 'workspace', - dependencies: { - 'http': HostedDependency(version: VersionConstraint.any), - }, - ); - - final nestedPubspec = Pubspec( - 'host_name', - environment: requireDart36, - dependencies: { - 'ffigen': HostedDependency(version: VersionConstraint.any), - }, - ); - - final dir = d.dir('workspace', [ - d.file('pubspec.yaml', jsonEncode(workspacePubspec.toJson())), - d.dir('pkgs', [ - d.dir('code_assets', [ - d.file('pubspec.yaml', jsonEncode(subpackagePubspec.toJson())), - d.dir('lib', [ - d.file('code_assets.dart', 'import "package:http/http.dart";'), - ]), - d.dir('example', [ - d.dir('host_name', [ - d.file('pubspec.yaml', jsonEncode(nestedPubspec.toJson())), - d.dir('tool', [ - d.file( - 'ffigen.dart', 'import "package:ffigen/ffigen.dart";'), - ]), + d.dir('example', [ + d.dir('host_name', [ + d.file( + 'pubspec.yaml', + jsonEncode(pubspecToJson(nestedPubspec)), + ), + d.dir('tool', [ + d.file('ffigen.dart', 'import "package:ffigen/ffigen.dart";'), ]), ]), ]), ]), - ]); - - await dir.create(); - final result = await checkPackage(root: '${d.sandbox}/workspace'); - expect(result, isTrue); - }); - }); + ]), + ]); + + await dir.create(); + final result = await checkPackage(root: '${d.sandbox}/workspace'); + expect(result, isTrue); + }, + ); +}); diff --git a/test/pubspec_to_json.dart b/test/pubspec_to_json.dart index cadcbf6..9b4b381 100644 --- a/test/pubspec_to_json.dart +++ b/test/pubspec_to_json.dart @@ -1,56 +1,27 @@ -import "package:pubspec_parse/pubspec_parse.dart"; - -extension on Map { - Iterable<(K, V)> get records sync* { - for (final entry in entries) { - yield (entry.key, entry.value); - } - } -} +import 'package:pubspec_parse/pubspec_parse.dart'; typedef Json = Map; -extension on Dependency { - Json toJson() => switch (this) { - SdkDependency(:final sdk, :final version) => { - "sdk": sdk, - "version": version.toString(), - }, - HostedDependency(:final hosted, :final version) => { - if (hosted != null) "hosted": hosted.url.toString(), - "version": version.toString(), - }, - GitDependency(:final url, :final ref, :final path) => { - "git": { - "url": url.toString(), - if (path != null) "ref": ref, - if (path != null) "path": path, - }, - }, - PathDependency(:final path) => {"path": path.replaceAll(r'\', '/')}, - }; -} - -/// An as-needed implementation of `Pubspec.toJson` for testing. +/// Serializes [pubspec] for test sandbox `pubspec.yaml` files. /// -/// See: https://github.com/dart-lang/tools/issues/1801 -extension PubspecToJson on Pubspec { - Json toJson() => { - "name": name, - "environment": { - for (final (sdk, version) in environment.records) - sdk: version.toString(), - }, - if (resolution != null) "resolution": resolution, - if (workspace != null) "workspace": workspace, - "dependencies": { - for (final (name, dependency) in dependencies.records) - name: dependency.toJson(), - }, - "dev_dependencies": { - for (final (name, dependency) in devDependencies.records) - name: dependency.toJson(), - }, - // ... - }; +/// [Pubspec.toJson] from `pubspec_parse` includes null fields that `pub` +/// rejects, so this helper omits null and empty entries. +Json pubspecToJson(Pubspec pubspec) => _omitNullAndEmpty(pubspec.toJson()); + +Json _omitNullAndEmpty(Json json) { + final result = {}; + for (final entry in json.entries) { + final value = entry.value; + if (value == null) continue; + if (value is Map) { + final nested = _omitNullAndEmpty(Map.from(value)); + if (nested.isNotEmpty) { + result[entry.key] = nested; + } + continue; + } + if (value is List && value.isEmpty) continue; + result[entry.key] = value; + } + return result; } diff --git a/test/utils.dart b/test/utils.dart index 9b23add..566b93f 100644 --- a/test/utils.dart +++ b/test/utils.dart @@ -9,10 +9,10 @@ import 'package:pubspec_parse/pubspec_parse.dart'; import 'package:test/test.dart'; import 'package:test_descriptor/test_descriptor.dart' as d; -export 'package:logging/logging.dart' show Level; - import 'pubspec_to_json.dart'; +export 'package:logging/logging.dart' show Level; + Future checkProject({ DepValidatorConfig? config, Map dependencies = const {}, @@ -20,17 +20,18 @@ Future checkProject({ List project = const [], List args = const [], bool embedConfigInPubspec = false, + Map? environment, }) async { final pubspec = Pubspec( 'project', - environment: requireDart36, + environment: environment ?? requireDart36, dependencies: dependencies, devDependencies: { ...devDependencies, 'dependency_validator': PathDependency(Directory.current.absolute.path), }, ); - final pubspecJson = pubspec.toJson(); + final pubspecJson = pubspecToJson(pubspec); if (embedConfigInPubspec && config != null) { pubspecJson['dependency_validator'] = config.toJson(); } @@ -47,8 +48,8 @@ Future checkProject({ } Dependency hostedCompatibleWith(String version) => HostedDependency( - version: VersionConstraint.compatibleWith(Version.parse(version)), - ); + version: VersionConstraint.compatibleWith(Version.parse(version)), +); Dependency hostedPinned(String version) => HostedDependency(version: Version.parse(version)); @@ -69,6 +70,10 @@ final requireDart36 = { "sdk": VersionConstraint.compatibleWith(Version.parse('3.6.0')), }; +final requireDart38 = { + "sdk": VersionConstraint.compatibleWith(Version.parse('3.8.0')), +}; + Future checkWorkspace({ required Map workspaceDeps, required Map subpackageDeps, @@ -93,7 +98,7 @@ Future checkWorkspace({ ); final dir = d.dir('workspace', [ ...workspace, - d.file('pubspec.yaml', jsonEncode(workspacePubspec.toJson())), + d.file('pubspec.yaml', jsonEncode(pubspecToJson(workspacePubspec))), if (workspaceConfig != null) d.file( 'dart_dependency_validator.yaml', @@ -101,7 +106,7 @@ Future checkWorkspace({ ), d.dir('subpackage', [ ...subpackage, - d.file('pubspec.yaml', jsonEncode(subpackagePubspec.toJson())), + d.file('pubspec.yaml', jsonEncode(pubspecToJson(subpackagePubspec))), if (subpackageConfig != null) d.file( 'dart_dependency_validator.yaml', diff --git a/test/utils_test.dart b/test/utils_test.dart index bcd82e5..e50fc58 100644 --- a/test/utils_test.dart +++ b/test/utils_test.dart @@ -135,8 +135,9 @@ include: package:pedantic/analysis_options.1.8.0.yaml expect(input, matches(importExportDartPackageRegex)); - final allMatches = - importExportDartPackageRegex.allMatches(input).toList(); + final allMatches = importExportDartPackageRegex + .allMatches(input) + .toList(); expect(allMatches, hasLength(2)); expect(allMatches[0].groups([1, 2]), [importOrExport, 'foo']); @@ -482,16 +483,17 @@ include: package:pedantic/analysis_options.1.8.0.yaml ]), ]), d.dir('.dart_tool', [ - d.dir('hidden_sub', [ - d.file('pubspec.yaml', 'name: hidden_sub'), - ]), + d.dir('hidden_sub', [d.file('pubspec.yaml', 'name: hidden_sub')]), ]), ]).create(); - final nested = listNestedPackages('${d.sandbox}/complex_pkg') - .map((dir) => p.relative(dir.path, from: '${d.sandbox}/complex_pkg')) - .toList() - ..sort(); + final nested = + listNestedPackages('${d.sandbox}/complex_pkg') + .map( + (dir) => p.relative(dir.path, from: '${d.sandbox}/complex_pkg'), + ) + .toList() + ..sort(); expect(nested, [ p.join('example', 'host_name'), diff --git a/test/workspace_test.dart b/test/workspace_test.dart index 8498d39..223e245 100644 --- a/test/workspace_test.dart +++ b/test/workspace_test.dart @@ -25,128 +25,128 @@ final dependsOnMeta = { final excludeMain = DepValidatorConfig(exclude: ['lib/main.dart']); void main() => group('Workspaces', () { - initLogs(); - test( - 'works in the trivial case', - () => checkWorkspace( - workspaceDeps: {}, - workspace: [], - subpackage: [], - subpackageDeps: {}, - ), - ); - - test( - 'works in a basic case', - () => checkWorkspace( - workspace: usesHttp, - workspaceDeps: dependsOnHttp, - subpackage: usesHttp, - subpackageDeps: dependsOnHttp, - ), - ); - - test( - 'works when the packages have different dependencies', - () => checkWorkspace( - workspace: usesHttp, - workspaceDeps: dependsOnHttp, - subpackage: usesMeta, - subpackageDeps: dependsOnMeta, - ), - ); - - group('fails when the root has an issue', () { - test( - '(sub-package is okay)', - () => checkWorkspace( - workspace: [], - workspaceDeps: {}, - subpackage: usesHttp, - subpackageDeps: dependsOnHttp, - ), - ); - - test( - 'even when it shares a dependency with the subpackage', - () => checkWorkspace( - workspaceDeps: dependsOnHttp, - workspace: [], - subpackageDeps: dependsOnHttp, - subpackage: usesHttp, - matcher: isFalse, - ), - ); - }); - - group('fails when the subpackage has an issue', () { - test( - '(root is okay)', - () => checkWorkspace( - workspace: usesHttp, - workspaceDeps: dependsOnHttp, - subpackage: [], - subpackageDeps: {}, - ), - ); - - test( - 'even when it shares a dependency with the subpackage', - () => checkWorkspace( - workspace: usesHttp, - workspaceDeps: dependsOnHttp, - subpackage: usesHttp, - subpackageDeps: {}, - matcher: isFalse, - ), - ); - }); - - group('handles configs', () { - test( - 'at the root', - () => checkWorkspace( - workspace: usesHttp, - workspaceDeps: {}, - workspaceConfig: excludeMain, - subpackage: [], - subpackageDeps: {}, - ), - ); - - test( - 'and fails at root when config is in subpackage', - () => checkWorkspace( - workspace: usesHttp, - workspaceDeps: {}, - subpackage: [], - subpackageDeps: {}, - subpackageConfig: excludeMain, - matcher: isFalse, - ), - ); - - test( - 'in a subpackage', - () => checkWorkspace( - workspace: [], - workspaceDeps: {}, - subpackage: usesHttp, - subpackageDeps: {}, - subpackageConfig: excludeMain, - ), - ); - - test( - 'and fails in subpackage when config is in root', - () => checkWorkspace( - workspace: [], - workspaceDeps: {}, - workspaceConfig: excludeMain, - subpackage: usesHttp, - subpackageDeps: {}, - matcher: isFalse, - ), - ); - }); - }); + initLogs(); + test( + 'works in the trivial case', + () => checkWorkspace( + workspaceDeps: {}, + workspace: [], + subpackage: [], + subpackageDeps: {}, + ), + ); + + test( + 'works in a basic case', + () => checkWorkspace( + workspace: usesHttp, + workspaceDeps: dependsOnHttp, + subpackage: usesHttp, + subpackageDeps: dependsOnHttp, + ), + ); + + test( + 'works when the packages have different dependencies', + () => checkWorkspace( + workspace: usesHttp, + workspaceDeps: dependsOnHttp, + subpackage: usesMeta, + subpackageDeps: dependsOnMeta, + ), + ); + + group('fails when the root has an issue', () { + test( + '(sub-package is okay)', + () => checkWorkspace( + workspace: [], + workspaceDeps: {}, + subpackage: usesHttp, + subpackageDeps: dependsOnHttp, + ), + ); + + test( + 'even when it shares a dependency with the subpackage', + () => checkWorkspace( + workspaceDeps: dependsOnHttp, + workspace: [], + subpackageDeps: dependsOnHttp, + subpackage: usesHttp, + matcher: isFalse, + ), + ); + }); + + group('fails when the subpackage has an issue', () { + test( + '(root is okay)', + () => checkWorkspace( + workspace: usesHttp, + workspaceDeps: dependsOnHttp, + subpackage: [], + subpackageDeps: {}, + ), + ); + + test( + 'even when it shares a dependency with the subpackage', + () => checkWorkspace( + workspace: usesHttp, + workspaceDeps: dependsOnHttp, + subpackage: usesHttp, + subpackageDeps: {}, + matcher: isFalse, + ), + ); + }); + + group('handles configs', () { + test( + 'at the root', + () => checkWorkspace( + workspace: usesHttp, + workspaceDeps: {}, + workspaceConfig: excludeMain, + subpackage: [], + subpackageDeps: {}, + ), + ); + + test( + 'and fails at root when config is in subpackage', + () => checkWorkspace( + workspace: usesHttp, + workspaceDeps: {}, + subpackage: [], + subpackageDeps: {}, + subpackageConfig: excludeMain, + matcher: isFalse, + ), + ); + + test( + 'in a subpackage', + () => checkWorkspace( + workspace: [], + workspaceDeps: {}, + subpackage: usesHttp, + subpackageDeps: {}, + subpackageConfig: excludeMain, + ), + ); + + test( + 'and fails in subpackage when config is in root', + () => checkWorkspace( + workspace: [], + workspaceDeps: {}, + workspaceConfig: excludeMain, + subpackage: usesHttp, + subpackageDeps: {}, + matcher: isFalse, + ), + ); + }); +});